A showcase of different ways to handle asynchronous control flow in Javascript.
JavaScript
Latest commit 5ca53aa Jul 26, 2014 @jlipps Merge pull request #9 from bevacqua/patch-1
 Sure :)

README.md

Async JS Control Flow Showcase

This is a project designed to showcase different ways to handle asynchronous control flow in Javascript. If you're familiar with this concept, you might want to skip to the list of projects or the description of callback issues.

Note that this is meant to be an informational, not a competitive, guide to all the approaches to flow control on offer.

Background

Javascript applications, including Node.js-based software, are most frequently structured around event-driven or asynchronous methods. It's possible that a function foo or bar could kick off some logic that is not yet complete when the functions themselves complete. One common example of this is Javascript's setTimeout(). setTimeout, as the name implies, doesn't actually pause execution for a designated amount of time. Instead, it schedules execution for a later time, and meanwhile returns control to the script:

var done = function() {
    console.log("set timeout finished!");
};

setTimeout(done, 1000);
console.log("hello world");

The output of this script is:

hello world
set timeout finished!

Callbacks

Node.js has generalized this asynchronous control flow idea through the use of callbacks. This is a convention whereby an asynchronous function takes a parameter which is itself a function. This function, called a callback, is then called whenever the asynchronous result is ready. In Node, callbacks are called with the convention that an Error object is passed as the first parameter for use in error handling, and further results are sent as subsequent parameters. Let's take a look at an example:

var fs = require('fs');

var readFileCallback = function(err, fileData) {
    if (err) {
        console.log("We got an error! Oops!");
        return;
    }
    console.log(fileData.toString('utf8'));
};

fs.readFile('/path/to/my/file', readFileCallback);

In this example, we use a callback-based method fs.readFile() to get the contents of a file. The function readFileCallback is called when the file data has been retrieved. If there was an error, say because the file doesn't exist, we get that as the first parameter, and can log a helpful message. Otherwise, we can display the file contents.

Because we can use anonymous functions, it's much more common to see code that looks like this:

var fs = require('fs');

fs.readFile('/path/to/my/file', function(err, fileData) {
    if (err) {
        console.log("We got an error! Oops!");
        return;
    }
    console.log(fileData.toString('utf8'));
});

This architecture can be quite powerful because it is non-blocking. We could easily read two files at the same time just by putting calls to fs.readFile one after the other:

var fs = require('fs');

var readFileCallback = function(err, fileData) {
    if (err) {
        console.log("We got an error! Oops!");
        return;
    }
    console.log(fileData.toString('utf8'));
};

fs.readFile('/path/to/my/file', readFileCallback);
fs.readFile('/path/to/another/file', readFileCallback);

In this example, the file contents will be printed out in an undetermined order because they're kicked off at roughly the same time, and their callbacks will be called when the system is done reading each one, which could vary based on many factors.

The impact of callbacks

There are a number of issues that arise as part of a callback-based architecture. These issues range from the aesthetic to the practical (e.g., some argue callbacks lead to less readable or less maintainable code).

Rightward drift

It often happens that you want to run a number of asynchronous methods, one after the other. In this case, each method must be called in the callback of the previous method. Using the anonymous function strategy detailed above, you end up with code that looks like this:

asyncFn1(function() {
    asyncFn2(function() {
        asyncFn3(function() {
            asyncFn4(function() {
                console.log("We're done!");
            });
        });
    });
});

To some people, once you start filling these functions out with their own particular logic, it's very easy to lose track of where you are in the logical flow.

Branching logic

Sometimes you might want to call a function bar() only if the result of another function foo() matches some criteria. If these are synchronous functions, the logic looks like this:

var res = foo();
if (res === "5") {
    res = bar(res);
}
res = baz(res);
console.log("After transforming, res is " + res);

If foo and bar are asynchronous functions, however, it gets a little more complicated. One option is to duplicate code:

foo(function(res) {
    if (res === "5") {
        bar(res, function(res2) {
            baz(res2, function(res3) {
                console.log("After transforming, res is " + res3);
            });
        });
        return;
    }
    baz(res, function(res2) {
        console.log("After transforming, res is " + res2);
    });
});

In this case, we've duplicated the calls to baz. An alternative is to create a next function that encapsulates the baz call and subsequent log statement:

var next = function(res) {
    baz(res, function(res2) {
        console.log("After transforming, res is " + res2);
    });
};

foo(function(res) {
    if (res === "5") {
        bar(res, function(res2) {
            next(res2);
        });
        return;
    }
    next(res);
});

This is more DRY, but at the cost of creating a function whose only purpose is to continue the logical flow of the code, called in multiple places.

Looping

If you need to perform an async method on a number of objects, it can be a little mind-bending. Synchronously, we can do something like this:

var collection = [objA, objB, objC];
var response = [];
for (var i = 0; i < collection.length; i++) {
    response.push(transformMyObject(collection[i]));
}
console.log(response);

If transformMyObject is actually asynchronous, and we need to transform each object one after the other, we need to do something more like this:

var collection = [objA, objB, objC];
var response = [];
var doTransform = function() {
    var obj = collection.unshift();
    if (typeof obj === "undefined") {
        console.log(response);
    } else {
        transformMyObject(obj, function(err, newObj) {
            response.push(newObj);
            doTransform();
        });
    }
};
doTransform();

Error handling

You can't use Javascript's basic error handling techniques (try/catch) to handle errors in callbacks, even if they're defined in the same scope. In other words, this doesn't work:

var crashyFunction = function(cb) {
    throw new Error("uh oh!");
};

var runFooBar = function(cb) {
    foo(function() {
        crashyFunction(function() {
            bar(function() {
                cb();
            });
        });
    });
};

try {
    runFooBar(function() {
        console.log("We're done");
    });
} catch (err) {
    console.log(err.message);
}

This is why Node.js uses a convention of passing errors into callbacks so they can be handled:

var crashyFunction = function(cb) {
    try {
        throw new Error("uh oh!");
    } catch (e) {
        cb(e);
    }
};

var runFooBar = function(cb) {
    foo(function(err) {
        if (err) return cb(err);
        crashyFunction(function(err) {
            if (err) return cb(err);
            bar(function(err) {
                if (err) return cb(err);
                cb();
            });
        });
    });
};

runFooBar(function(err) {
    if (err) return console.log(err.message);
    console.log("We're done!");
});

As you can see, the result of this is that we have to check the error state in every callback so we can short-circuit the chain and pass the error to the top-level callback. This is a bit redundant at best.

Node.js now has domains, which makes this problem a little easier to handle.

Alternatives to callbacks

Of course, callbacks aren't the only way to do asynchronous control flow. There are many helpful libraries that make using callbacks less prone to the issues above. There are also alternatives which aren't callback-based at all, though they might rely on callbacks under the hood. The point of this project is to enable a side-by-side comparison of these approaches.

The callbacks directory of this repo has a number of reference Javascript files which demonstrate in code how callback-based flow control works. These files can all be run using Node.js.

The following projects have implemented, or are in the process of implementing, revisions of the reference scripts using their particular approach to asynchronous control flow. Please check them out, read the directory's README to see how they address the issues above, run the code, and make an informed decision about which approach to adopt in your own projects!

Hint: take a look at the "kitchen sink" example file in each of the projects to see how they handle all the issues in one script.

  • Callbacks (i.e., the "standard" or "naive" approach above)

Other approaches:

Bitdeli Badge