JavaScript, in particular Node.js, has been frequently associated with callback hell[1]. If you've written code that deals with a lot of async I/O, you're probably familiar with this pattern:
export default function getLikes () {
getUsers((err, users) => {
if (err) return fn(err);
filterUsersWithFriends((err, usersWithFriends) => {
if (err) return fn(err);
getUsersLikes(usersWithFriends, (err, likes) => {
if (err) return fn (err);
fn(null, likes);
});
});
});
}
It turns out, this code can be much easier and safer to write.
I'll show you how Promise
combined with async
/ await
enables this, but also some of the lessons we've learned from using these new features in production.
Let's start with the pitfalls of the example above.
In a great majority of cases, you want to just pass the error along.
In the example above, however, you repeat yourself many times. It's also easy to miss a return
and only discover it (with non-obvious debugging) when the error actually occurs.
When errors occur, most popular libraries will invoke the callback with an Error
parameter, or in the success case use null
instead.
Unfortunately this is not always the case. You might get false
instead of null
. Some libraries omit it altogether. If several errors occur, you might even get multiple callbacks! Which leads us to…
Does the callback fire immediately? or on a different microtask? or on a different tick? Sometimes? Always?
Who knows! Reading your own code certainly won't tell you. Reading the library's documentation might tell you, if you're lucky.
It's possible that the callback will fire more than once without you expecting it. Once again, this will almost certainly result in code that's extremely hard to debug.
In certain cases, the code might continue to run but not doing quite what it should. In others, you might get a stack trace that doesn't exactly make the root cause obvious.
The solution to these problems is the standarization on Promise
.
Promises present a clear contract and API to you. While we might disagree on whether the details and API of this contract are the best ones, they're strictly defined.
Thus, the lack of specification we mentioned above is not a concern when you're dealing with code that uses Promise
.
This is what the equivalent to setTimeout
would look like using Promise
:
function sleep (time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
sleep(100)
.then(() => console.log('100ms elapsed'))
.catch(() => console.error('error!'));
Promises can be in two settled states: resolved and rejected. As seen above, you can set up a pair of callbacks to obtain the resolved value and the rejected value.
The fact that we pass callbacks to a promise shows that we often deal with somewhat of a false dichotomy. Obviously, promises need callbacks to do anything meaningful. The real comparison is then between promises and the callback pattern that the JavaScript community has informally agreed upon[2].
Promises represent a single value. Unlike the callback pattern above, you can't get an error followed by success for the a certain invocation. Or get a value and an error later on.
You can think of resolve
as the Promise
equivalent of return
and reject
as throw
. As we'll see later on, this semantic equivalency is syntactically realized by the async
and await
keywords.
As far as scheduling goes, the Promise
spec has settled on always invoking callbacks "at a future time" (i.e.: the next microtask). This meants the behavior of a Promise
is consistently asynchronous every time you call then
or catch
, whether it's already been settled or not.
If we write our initial example with this API it would look as follows:
export default function getUsers () {
return getUsers()
.then(users => filterUsersWithFriends)
.then(usersWithFriends => getUsersLikes);
}
This already looks much better! That said, if our logic were to change, refactoring the code gets complicated very quickly.
Imagine that in the code above, a particular type of failure of filterUsersWithFriends
needs to be handled differently:
export default function getUsers () {
return new Promise((resolve, reject) => {
getUsers().then(users => {
filterUsersWithFriends(users)
.then(resolve)
.catch((err) => {
resolve(trySomethingElse(users));
});
}, reject)
});
}
No amount of chaining "convenience" can save us. Let's look at the solution.
As known for a while in the C# and F# world, there's an elegant solution to our problems:
export default async function getLikes () {
const users = await getUsers();
const filtered = await filterUsersWithFriends(users);
return getUsersLikes(filtered);
}
For this to work, we just need to make sure that the functions that perform I/O that we depend on (like getUsers
) return a Promise
.
Not only is it easier to read (as the chaining example was), but now the error handling behavior is the exact same as regular synchronous JavaScript code.
That is, when we await
a function, errors (if any) are surfaced and thrown. If our getLikes
function is invoked, errors bubble up by default. If you want to handle a particular error differently, just wrap your await
invocation with try
/catch
.
This will increase your productivity and correctness as you won't be writing (or worse, ignoring!) if (err) return fn(err)
everywhere.
How certain are we of this future?
Promise
is already in all modern mobile and desktop browsers and Node.js 0.12+async
/ await
has been almost completely implemented in V8, Edge and Firefox.We've been using these features at ▲ZEIT for many months now and have been extremely happy and productive with them.
I recently published a guide to transpiling with Babel and Node 6, which due to its great support for ES6 now only needs two transformation plugins and exhibits great compilation performance.
If you want support for the browser or older versions of Node, I suggest you also include the es2015
preset. This will compile to a state machine instead of generators.
In order to maximize your usage of this feature, you'll want to use modules from the ecosystem that expose Promise
instead of just a callback.
node_redis
, for example, exposes Promise
if you suffix Async
to the methods it exposes.Promise
. You can usually identify these by their prefix or suffix then
or promise
. Examples: fs-promise
, then-sleep
.In addition to these, Node is considering returning Promise
s directly in the standard library. You can follow that discussion here.
I also need to stress that this syntax doesn't make Promise
go away from your codebase. In fact, you must have a thorough understanding of them, which you'll frequently need.
A common example where Promise
makes an appearence is code that requires multiple values as part of a loop, which are requested concurrently:
const ids = [1, 2, 3];
const values = await Promise.all(ids.map((id) => {
return db.query('SELECT * from products WHERE id = ?', id);
}));
Notice also that in the example presented above (async getLikes()
), I opted to return getUserLikes()
instead of return await getUserLikes()
.
Since the goal of the async
keyword is to make the function return a Promise
, those two snippets are therefore equivalent.
This means that the following code:
async function getAnswer () {
return 42;
}
is perfectly valid and equivalent to its sync counter-part const getAnswer = () => 42
with the exception that when invoked with await
it will resolve in the next microtask. When called without await
, it will return a Promise
.
Earlier I mentioned the Promise
spec set out to solve a host of problems we would frequently run into with callbacks.
I'll cover some of the problems that have remained or have been now introduced, and some behaviors that were left unspecified but are critical for our needs.
When you use a Promise
and don't attach an error handler, in many environments you might never find out about the error.
This is the equivalent to ignoring the err
parameter in the callback pattern, with the difference that a TypeError
is likely to occur when you try to access the value you're interested in.
In the callback pattern, while you can manage to ignore err
, you're likely to find out with a crash later on when the error does occur.
Ignoring errors is normally quite difficult to do with async
and await
, however. The exception would be the entry point of your asynchronous code:
async function run () {
// your app code…
}
run().catch((err) => {
// make sure to handle the error!
});
Fortunately, there are workarounds and a potential definitive solution to this problem:
unhandledRejection
, with which you can log manually. I recommend you read this discussion about the implications of unhandled rejections for backend systems.await
in the future would make the manual Promise
instantiation and catching unnecessary!Finally, I mentioned earlier that Promise
s will be resolved once, unlike callbacks that could fire multiple times unexpectedly.
The problem is that once again, Promise
s will swallow subsequent resolutions and more concerningly, rejections. There might be errors that are never logged!
The original Promise
spec left out the semantics of cancelling the ongoing asynchronous retrieval of a value.
As fate would have it, browser vendors went on to implement them as the return value of functions that have historically needed cancelation, like HTTP requests.
Namely, with XMLHttpRequest
you can call abort
on the resulting object, but with the new and shiny fetch
… you can't.
TC39 is now considering the addition of a third state: cancelled. You can read more about the stage 1 proposal here.
While retro-fitted to Promise
s, cancellation is a fundamental property of the next abstraction we'll cover: the Observable
.
Earlier in the post it became evident that waiting on a Promise
to resolve is somewhat equivalent to a function doing some work and returning a value synchronously.
The Observable
is a more general (and therefore more powerful) abstraction that represents a function invokation that can return several values.
Unlike Promise
, Observable
objects can return synchronously (same tick) or asynchronously.
These design decisions make an Observable
suitable for a wider range of usecases.
In the spirit of our earlier examples, here's how Observable
can work with setInterval
to give us a value over time:
function timer (ms) {
return new Observable(obv => {
let i = 0;
const iv = setInterval(() => {
obv.next(i++);
}, ms);
return () => clearInterval(iv);
});
}
As I mentioned earlier, Observable
covers a broader spectrum of possibility. From this lense, a Promise
is simply an Observable
that returns a single value and completes:
function delay(ms) {
return new Observable(obv => {
const t = setTimeout(() => {
obv.next();
obv.complete();
}, ms);
return () => clearTimeout(t);
});
}
Notice that the value returned in the setup of the Observable
is a function that performs cleanup. Such a function is executed when no subscriptions are left:
const subscription = delay(100).subscribe();
subscription.unsubscribe(); // cleanup happens
This means that Observable
also fills another missing gap in Promise
: cancelation. In this model, cancelation is simply a consequence of the cease of observation.
With this said, a lot of asynchronous work can be expressed with only the Promise
subset just fine. As a matter of fact, a great portion of the core library of Node.js only needs that (the exceptions being Stream
and some EventEmitter
).
What about async
and await
? One could implement an operator that restricts the behavior of a givenObservable
to that of a Promise
(which libraries like RxJS already have) and await
it:
await toPromise(timer(1000));
This example shows us the generalization in action: the timer
function is just as useful as delay
, but also works for intervals!
async
and await
will enable significant improvements in your codebases.
Our open-source library micro is a great example of how the request / response cycle can be made a lot more straightforward.
The following microservice responds with a JSON
encoded array of users a database.
If any of the handlers throw, the response is aborted with err.statusCode
.
If unhandled exceptions occur, a 500 response is produced and the error logged.
export default async function (req, res) {
await rateLimit(req);
const uid = await authenticate(req);
return getUsers(uid);
}
As mentioned, proposals have been made for ES6 modules to admit a top-level await
. For Node.js this would mean being able to write code like this:
import request from 'request';
const file = await fs.readFile('some-file');
const res = await request.post('/some-api', { body: { file } });
console.log(res.data);
and then run it without any wrappers (and straight-forward error handling)!
▲ node my-script.mjs
Simultaneously, Observable
continues to make progress within TC39 to become a first-class construct of the language.
I believe these new primitives for managing concurrency and asynchrony will have a very profound impact on the JavaScript ecosystem. It's about time.
Error
object as the first parameter in the case of an error, or null
and the intended value as the second. However, deviations from this implicit agreement are commonly encountered in the ecosystem. Some libraries omit the error object and emit an error
event somewhere else. Some callbacks fire with multiple values. Et cetera.