How we got here
Promises marked a huge turning point in async js, they enabled a new type of control flow that saved us from callback hell. But some people found that calling .then()
multiple times was too much, too callbacky.
Then after a while, we resorted to generator functions and cogenerators, which made async code feel like its synchronous, at the cost of wrapping it in a generator function, yield
ing every line and introducing a cogenerator library (for example co) to deal with unwrapping the promises like the following example, where we could just yield
a promise whenever we encounter it and pretend that the yield
does not exist on that line of code.
co(function* () {
let result1 = yield somePromise1;
let result1 = yield anotherPromise;
dostuff(result1, result2);
});
This evolution served as the inspiration of the async/await
syntax introduced in es7, and finally we could just
let value = await somePromise
doStuff(value)
// instead of
somePromise.then(value => doStuff(value)
Oh, and you had to wrap it in an async
function to be able to use it, but that’s changing with top level await
.
Why I use both
One simple reason: error handling.
Writing code for the happy path feels good, if only the world were a perfect place. But hélas, if you omit error handling during development, you will pay for it later while digging through a mysterious bug report.
Promises have a .catch(callback)
method similar to .then(callback)
where the callback
expects an error.
myPromise
.then(value => handleHappyPath(value))
.then(value2 => handleAnotherHappyPath(value2))
.catch(err => handleError(err));
The async/await
version looks like this:
try {
let value = await myPromise;
let value2 = await handleHappyPath(value);
handleAnotherHappyPath(value2);
} catch (err) {
handleError(err);
}
One least used - but very useful - feature of .then
is that it accepts a second parameter as an error handler.
myPromise
.then(handleHappyPath, handleErrorScoped)
.then(anotherHappyPath)
.catch(err => handleError(err));
In this example, handleErrorScoped
will take care of errors for this particular step. While handleError
will handle errors of the whole chain (including errors inside handleErrorScoped
).
The equivalent sync/await
version requires a nested try/catch
block.
try {
let value;
try {
value = await myPromise;
} catch (err) {
// possibly setting `value` to something
handleErrorScoped(err);
}
let value2 = await handleHappyPath(value);
handleAnotherHappyPath(value2);
} catch (err) {
handleError(err);
}
Maybe it’s just me, but I find the latter a hell of lot more verbose, running away from callback hell, ran directly into try/catch
hell.
An example of an instance where I found myself combining both is when I use puppeteer to check if an element exists in a page.
let hasElement = await page
.evaluate(() => document.querySelector("some selector"))
.then(() => true)
.catch(() => false);
Conclusion
async/await
was a huge stepping stone towards simplifying async javascript, but it does not obsolete .then()
and .catch()
, both have their use cases, especially when
we need granular control over error handling.
A combination of both seems to give the most readable code, robust and maintainable code.