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, yielding 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.