You add the final semicolon to a brand new piece of asynchronous code. Save,
run, and… nothing happens. Like, literally nothing. No errors, no output.
The code just stopped execution midway through. You spend the next five
minutes using console.log
and a manual binary search to figure out where your
code died. Unhappiness ensues.
Or maybe:
- Your async code swallows an important error with no output, in production.
- Your callbacks sometimes mysteriously get run twice.
- Your async code just generally makes you feel sad inside.
These rules will help you out.
Note: Do you use promises? That’s great! I use Async.js though, so this article is probably not for you.
Rule 1: Use consistent naming
Choose a name for the callback variable, and stick with it. Make it short,
since you’ll be typing it quite often. I use cb
.
Choose a name for the error that a callback receives. I use err
, and I think
that’s pretty standard.
A quick example:
// Callback is always the last argument to an async function.
function readFileAsync(filePath, cb) {
// read the file
// then call cb
}
// Let's use our function!
// First argument to the callback is always `err`
readFileAsync('/path/to/file', function (err, result) {
// ...
});
Easy, but important: consistency is crucial for being able to scan code quickly and accurately.
Rule 2: Async functions must return immediately after using the callback
In my experience, using the callback earlier makes it easy to use it twice by
accident. It also makes it more difficult to scan the function for exit
points. An “async function”, for the purpose of this article, is any function
that accepts a callback cb
as an argument.
// BAD
function loadTable(cb) {
// ...
cb(); // Oops! This probably came from a bad copy/paste.
// ...
// some more code here
// ...
}
// GOOD
function loadTable(cb) {
// ...
if (isFoo) {
cb(); // OK: return immediately after
return;
}
// ...
// some more code here
// ...
cb(); // OK: returns (due to end of function) immediately after
}
Rule 3: Async functions must always use the callback before returning
If an asynchronous function exits before using the callback, execution will
stop and nothing will happen next. This is always undesirable. If something
has gone wrong, pass the callback an Error
to help with debugging.
// BAD
function loadTable(cb) {
// ...
if (isFoo) {
return; // Oops: missed call to cb, code will stop here
}
// ...
// Oops: no call to cb before end of function, code will stop here
}
// GOOD
function loadTable(cb) {
// ...
if (isFoo) {
cb();
return;
}
// ...
cb();
}
Using the callback
There are three ways a function can “use the callback”. The first option is to invoke the callback directly, as above:
cb(null, result); // pass back a result, if you like
Second, the function can pass the callback to another async function:
uploadResults('/upload-file', results, cb); // `uploadResults` is now responsible for invoking the callback
Finally, the function can call some other asynchronous function, and use cb
inside that function’s callback. Crucially, the same rules about use of cb
now apply to the callback’s code:
uploadResults('/upload-file', results, function (err, requiresRefresh) {
if (err) {
// Use callback immediately before return
cb(null, {status: 'upload-failed'});
return;
}
if (requiresRefresh) {
// Use callback immediately before return by passing it to another
// async function
refreshResultList(cb);
return;
}
// Use callback immediately before end of function
cb(null, {status: 'ok'});
});
Putting it all together, an asychronous function might look something like this:
function submitResults(rawData, cb) {
var results = processData(rawData);
if (results.length === 0) {
// invoke callback directly
cb(new Error('no results from data processing'));
return;
}
// Here, we call an async function just before the end of the function.
// This qualifies as "using cb", as long as the callback code below also
// follows the rules.
uploadResults('/upload-file', results, function (err, requiresRefresh) {
if (err) {
// Use callback immediately before return
cb(null, {status: 'upload-failed'});
return;
}
if (requiresRefresh) {
// Use callback immediately before return by passing it to another
// async function
refreshResultList(cb);
return;
}
// Use callback immediately before end of function
cb(null, {status: 'ok'});
});
// No more code allowed here, since the `uploadResults` statement used cb.
}
Rule 4: Every callback must start with an error check
Asynchronous code, at least in JavaScript, means handling errors yourself. In C, the presence of an error is indicated using a return value. Programming with callbacks is very similar: errors are passed to the callback, which is the asynchronous equivalent of a “return value”. Every callback is responsible for checking for and handling errors that it receives.
Unfortunately, this makes it very easy to ignore errors, and silently swallowed errors can easily add an hour of debugging to my day. To mitigate this, I have a strict rule of always starting my callbacks with an error check:
function loadPage(cb) {
loadStuff(function (err, stuff) {
if (err) {
// Optional: we can handle the error here if we recognize it
if (err instanceof WhateverError) {
// ... handle the error here ...
// use `cb` before returning
return;
}
// Since we can't handle it, just pass it up to the callback.
// This will cause the error to "bubble up" to the function that
// called `loadPage`.
cb(err);
return;
}
// OK, now we can actually do stuff.
// ...
// use `cb` before finishing
});
}
This will ensure that the error “bubbles up” until it gets handled, just like a
normal synchronous exception. Eventually, the error will hit the top level,
and there will no longer be a cb
to pass the error to. At that point, the
error can be logged or shown to the user.
Rule 5: Callbacks and async functions must not return a value
Let’s start with callbacks. Think back to Rule 2: an async function should
exit immediately after invoking a callback. Clearly, if a callback were to
return a value, it would just be ignored. Usually, return something
shows up
in a callback when code was translated from synchronous to asynchronous: it
often indicates a bug.
Asynchronous functions are a bit different. Here’s a legitimate, simplified example of when returning a value synchronously might seem reasonable:
// Example path: '/2015/01/01/document-title-here'
function loadDocument(path, cb) {
// Get the date out of the path
var docDate = ...;
// Get the title out of the path
var docTitle = ...;
var docInfo = {
date: docDate,
title: docTitle,
};
// ... some more stuff ...
loadFile(path, extraArg, cb);
// Return the date and title right away to show while things are loading
return docInfo;
}
I’ve found that refactoring the synchronous work into a separate function makes the code much easier to follow:
// Example path: '/2015/01/01/document-title-here'
function getDocumentInfo(path) {
// Get the date out of the path
var docDate = ...;
// Get the title out of the path
var docTitle = ...;
return {
date: docDate,
title: docTitle,
};
}
function loadDocument(path, cb) {
// ... some more stuff ...
loadFile(path, extraArg, cb);
}
There’s one exception, however.
The operation pattern
Sometimes, it needs to be possible to cancel the asynchronous operation. The best solution that I’ve found so far involves returning an “operation object”:
function startLongOperation(arg1, arg2, cb) {
var isCancelled = false;
// ...
// do the actual work
// check isCancelled periodically, and stop if set to true
// `cb` must not be called once `cancel` is called
// ...
return {
cancel: function () {
isCancelled = true;
},
};
}
var longOp = startLongOperation(arg1, arg2, function (err) {
// ...
});
// later...
longOp.cancel()
Unfortunately, this breaks Rule 5 (and makes code somewhat harder to follow), so I’d be interested if you have a simpler alternative.
That’s it.
These five rules have made my work with callbacks massively easier – I can’t imagine working without them. Hopefully, they can work well for you too!
- Use consistent naming
- Async functions must return immediately after using the callback
- Async functions must always use the callback before returning
- Every callback must start with an error check
- Callbacks and async functions must not return a value
Additionally, be sure to familiarize yourself with
Async.js. I only use series
, parallel
,
map
, each
, and whilst
.