JavaScript asynchronous functions and error handling
What is asynchronous I/O?
The web services I build spend most of their time doing one of these two classes of things:
- Processing – Burning CPU cycles calculating things. For example JSON parsing, HTML rendering, image processing, etc.
- I/O – Waiting for some event external to the CPU/RAM system to finish. For example an HTTP request or DNS request.
Consider this example:
print "Line 1:", calculatePi() connectToServer() print "Line 3", calculatePi()
In a language like Python, C or Java calling a function like connectToServer() will block until the operation is finished. Your process/thread is paused while the operating system establishes the network connection. The “Line 3” text will not be printed until after the connection is established.
By contrast, in JavaScript functions can never block on I/O. The connectToServer() function would instruct the operating system to start connecting to the server in the background. The “Line 3” text will be printed immediately, almost certainly before the connection is established. If we wanted to run code only after the connection is done we’d need to use callbacks or promises.
This is the core difference between the synchronous and asynchronous I/O models. It has important implications for error handling which I’ll talk about below.
Asynchronous I/O | Synchronous I/O | |
Tech Stacks |
|
|
Example 1 – synchronous function
This example assumes that somebody has defined an isFeatureEnabled() function which synchronously returns true or false.
function isFeatureEnabled() { // imagine this checks an environment variable return true; } function divide(x, y) { if (!isFeatureEnabled()) { return 0; } if (y === 0) { throw new Error('Cannot divide by zero!'); } return x / y; } function main() { const result = divide(10, 2); console.log(result); // prints: 5 try { divide(10, 0); } catch (err) { console.log(err.message); // prints: 'Cannot divide by zero!' } } main();
Example 2 – promises
Now we’ll assume that in order to tell if the feature is enabled we need to make a network call. This means isFeatureEnabled() cannot just return synchronously, it must do some work. We’ll have it return a promise.
Promises are part of the JavaScript language as of ES2015. They allow us to return an object to which the caller can attach callbacks. Depending on whether the operation succeeds (resolves) or fails (rejects) the appropriate callbacks will be executed.
function isFeatureEnabled() { // imagine this makes an HTTP call to LaunchDarkly return Promise.resolve(true); } function divide(x, y) { return isFeatureEnabled() .then((isEnabled) => { if (!isEnabled) { return 0; } if (y === 0) { throw new Error('Cannot divide by zero!'); } return x / y; }); } function main() { return divide(10, 2) .then((result) => console.log(result)) // prints: 5 .then(() => divide(10, 0)) .catch((err) => console.log(err.message)); // prints: 'Cannot divide by zero!' } main();
Example 3 – async/await keywords
In ES2017 the language gained two new keywords, async and await. These are mostly syntactic sugar for promises, and they are fully compatible with promises. You can await on any function which returns a promise. Also functions annotated with async return a promise themselves.
This compatibility means that the main() functions from example 3 and 4 are interchangeable.
function isFeatureEnabled() { return Promise.resolve(true); } async function divide(x, y) { if (!await isFeatureEnabled()) { return 0; } if (y === 0) { throw new Error('Cannot divide by zero!'); } return x / y; } async function main() { const result = await divide(10, 2); console.log(result); // 5 try { await divide(10, 0); } catch (err) { console.log(err.message); // 'Cannot divide by zero!' } } main();
Bonus example – old-school Node.js style callbacks
I strongly discourage writing any new code in this style because it’s very error prone and verbose. It’s worth knowing how this works because everything else is ultimately built on this technique.
Much of the Node.js core API and many Node.js libraries still expose callback based APIs. If you need to call these you should use a wrapper like util.promisify().
function isFeatureEnabled(cb) { // imagine this makes an HTTP call to LaunchDarkly return cb(null, true); } function divide(x, y, cb) { isFeatureEnabled((err, isEnabled) => { if (err) { cb(err); return; } if (!isEnabled) { return 0; } if (y === 0) { cb(new Error('Cannot divide by zero!')); return; } cb(null, x / y); }); } function main() { divide(10, 2, (err, result) => { if (err) { console.log('1', err.message); // not reached } else { console.log(result); // prints: 5 } divide(10, 0, (err, result) => { if (err) { console.log(err.message); // prints: 'Cannot divide by zero!' } else { console.log(result); // not reached } }); }); } main();
Some rules to follow
When writing a new function consider whether it should be async
Modifying a function from being synchronous to asynchronous is a breaking API change. So if you’re writing a new function you should consider whether it may ever need to perform some sort of I/O.
For example you may choose to implement a cache API synchronously on top of an in-memory map. However in the future if you want to switch to memcached as a backend you’ll need to refactor all your code to call this function asynchronously. Writing the API as an async one from the beginning avoids this inconvenience.
Asynchronous functions should always report errors asynchronously
As a caller I should be able to able to rely on getting errors in only one way. For synchronous functions this is done by throwing an exception. For asynchronous functions it is either a callback argument or promise rejection.
When writing a function you should follow one of these rules:
- Return success or failure synchronously through the function return value or an exception.
- Return success or failure asynchronously using a Promise.
Note that by using the async keyword you are guaranteed that your function will always return a promise.
function checkUser(name) { if (name === 'DrEvil') { // using `throw` here would be wrong, because the caller would need to synchronously catch the exception return Promise.reject('DrEvil is not allowed!'); } return makeHttpRequest(`https://user-checker.example.com/${name}`) .then((result) => { if (result !== 'OK') { // using throw here is fine, because we're inside a Promise callback it will be turned into a Promise rejection throw new Error(`${name} is not allowed!`); } }); }
You probably don’t need ‘new Promise()’
Promises compose nicely, so you should only be writing new Promise() in exceptional circumstances, usually library code.
If you’re working with some non-Promise library you can use something like util.promisify().
Use only one type of async within a single function, that one type should be promises
As previously mentioned, writing callback code is error prone and tedious. Prefer promises whenever possible.
If you must use a callback-style async function you should write a small wrapper which exposes it using a promise interface instead. Usually you can use something like util.promisify() to do this for you. If that’s not possible you should write a very small function which does nothing more than map the callback function to a promise function. For example I’ve used this child_process.spawn() wrapper in a few places.
const childProcess = require('child_process'); function spawnAsync(...args) { return new Promise((resolve, reject) => { const child = childProcess.spawn(...args); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(`Process exited with non-zero status: ${code}`); } }); }); }
Don’t use non-standard ways of creating promises, eg q.defer()
Never use q.defer(), you should always prefer the now-standard ES2015 Promise constructor.
new Promise((resolve, reject) => { // Anything thrown here will be turned into a promise rejection. // Trigger your async operation and call resolve() or reject() when needed. // Once the promise is resolved or rejected any further calls to resolve/reject are ignored. })
Further reading on promises
The We have a problem with promises article covers many more subtle and not-so-subtle mistakes you can make with promises.