Error handling in async/await causes a lot of confusion. There are numerous patterns for handling errors in async functions, and even experienced developers sometimes get it wrong.

Suppose you have an async function run() . In this article, I'll describe 3 different patterns for handling errors in run() : try/catch , Golang-style, and catch() on the function call. I'll also explain why you rarely need anything but catch() with async functions.

try/catch

When you're first getting started with async/await, it is tempting to use try/catch around every async operation. That's because if you await on a promise that rejects, JavaScript throws a catchable error.

run(); async function run ( ) { try { await Promise .reject( new Error ( 'Oops!' )); } catch (error) { error.message; } }

try/catch also handles synchronous errors.

run(); async function run ( ) { const v = null ; try { await Promise .resolve( 'foo' ); v.thisWillThrow; } catch (error) { error.message; } }

So all you need to do is wrap all your logic in a try/catch , right? Not quite. The below code will result in an unhandled promise rejection. The await keyword converts promise rejections to catchable errors, but return does not.

run(); async function run ( ) { try { return Promise .reject( new Error ( 'Oops!' )); } catch (error) { } }

You could work around this limitation using return await . However, it is easy to forget return await .

Another disadvantage is that try/catch is hard to compose. Once you realize that try/catch handles sync and async errors, it is tempting to wrap all your async logic in one try/catch , as shown below.

Golang in JS

Another common pattern is using .then() to convert a promise that rejects into a promise that fulfills with an error. You can then use an if (err) check like in Golang.

run(); async function throwAnError ( ) { throw new Error ( 'Oops!' ); } async function noError ( ) { return 42 ; } async function run ( ) { let err = await throwAnError().then(() => null , err => err); if (err != null ) { err.message; } err = await noError().then(() => null , err => err); err; }

If you need both the error and the value, you can really pretend to write Golang in JavaScript.

run(); async function throwAnError ( ) { throw new Error ( 'Oops!' ); } async function noError ( ) { return 42 ; } async function run ( ) { let [err, res] = await throwAnError(). then(v => [ null , v], err => [err, null ]); if (err != null ) { err.message; } [err, res] = await noError(). then(v => [ null , v], err => [err, null ]); err; res; }

This pattern can be neater syntactically because declaring a variable in a try block with let scopes the variable to the try block.

const getAnswer = async () => 42 ; run(); async function run ( ) { try { let val = await getAnswer(); } catch (error) {} val; }

Golang-style error handling doesn't get rid of the return quirk. It just makes missing error checks harder, because you know that if you don't have if (err != null) after an async operation, something is wrong.

There are two major disadvantages to Golang-style error handling:

It is extremely repetitive. Typing if (err != null) every time you want to do something async puts you on the express lane to carpal tunnel. It doesn't help you with synchronous errors in run() .

So Golang-style error handling is a neat syntactic shortcut that should be used sparingly. It doesn't have much benefit over using try/catch .

Using catch() on the Function Call

Both try/catch and Golang-style error handling have their uses, but the best way to ensure you've handled all errors in your run() function is to use run().catch() . In other words, handle errors when calling the function as opposed to handling each individual error.

run(). catch ( function handleError ( err ) { err.message; }). catch (err => { process.nextTick(() => { throw err; }) }); async function run ( ) { await Promise .reject( new Error ( 'Oops!' )); }

Remember that async functions always return promises. This promise rejects if any uncaught error occurs in the function. If your async function body returns a promise that rejects, the returned promise will reject too.

run(). catch ( function handleError ( err ) { err.message; }). catch (err => { process.nextTick(() => { throw err; }) }); async function run ( ) { return Promise .reject( new Error ( 'Oops!' )); }

Why run().catch() as opposed to wrapping the entire run() function body in a try/catch ? For handling errors in the error handler. What happens if the catch block in your try/catch throws an error? The only solution is to nest a try/catch in your catch block, in every single function. .catch() makes handling unexpected errors in your error handler cleaner.

Takeaways

In general, errors are either expected or unexpected. In async functions, try/catch can help you recover gracefully from expected errors. But unexpected errors do happen, we all occasionally end up with a surprise "TypeError: Cannot read property 'foo' of null" sometimes.

You should handle unexpected errors in your async functions in the calling function. The run() function shouldn't be responsible for handling every possible error, you should instead do run().catch(handleError) .

Looking to become fluent in async/await? My new ebook, Mastering Async/Await, is designed to give you an integrated understanding of async/await fundamentals and how async/await fits in the JavaScript ecosystem in a few hours. Get your copy!