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.