Error handling in async functions
Every async function always returns a promise, so an error inside it moves that promise into the rejected state. From that follow the two main ways to handle it: try...catch inside the function itself, and .catch() outside, at the call site.
Theory
TL;DR
- An
asyncfunction always returns a promise; athrowinside it is aPromise.reject(). try...catchinside catches both an explicitthrowand rejected promises youawait..catch()outside catches the same error, but at the place where the function is called.- The two are combined: inside you log and rethrow a new error, outside you show it to the user.
- The
[err, data]pattern lets you avoid atry...catchat every step. - Anything caught nowhere is picked up by the global
unhandledrejectionhandler.
Quick example
async function getData() {
try {
const res = await fetch('https://api.github.com/404'); // network error
const data = await res.json();
console.log('Received:', data);
} catch (error) {
console.error('Error inside async:', error.message);
}
}
getData();Output:
Error inside async: Failed to fetchtry...catch inside the function
This is the most familiar and most readable option: the code looks exactly like synchronous code.
try...catch catches:
- errors thrown with
throw; - rejected promises that you
await.
It works because under the hood await unwraps a rejection into an ordinary exception:
const result = await Promise.reject('failure');
// throws an exception, control moves into the catch blockIn other words, a rejected promise inside an async function behaves exactly like a plain throw, and the same try...catch catches it.
.catch() outside and a manual throw
Since an async function returns a Promise, the error can be handled with the usual .catch():
async function getUser() {
const res = await fetch('https://api.example.com/missing');
return res.json();
}
getUser()
.then(console.log)
.catch(err => console.error('Error outside:', err.message));Output:
Error outside: Failed to fetchThe difference from try...catch is that here the error is handled at the call, not inside the function. A manual throw behaves the same way:
async function example() {
const data = await Promise.resolve('OK');
if (!data) throw new Error('No data!');
return data;
}
example()
.then(console.log)
.catch(err => console.error(err.message));Output:
OKIf the condition fires, throw turns into Promise.reject() and the external .catch() receives it.
Catching with a rethrow, and several levels of try...catch
Sometimes an error has to be handled only partly, for example logged, and then passed on:
async function processData() {
try {
const res = await fetch('https://api.example.com/missing');
return await res.json();
} catch (err) {
console.error('Request error:', err.message);
throw new Error('Error while processing data');
}
}
processData().catch(err => console.error('Final error:', err.message));Output:
Request error: Failed to fetch
Final error: Error while processing dataWhen different stages need different recovery, use several try...catch blocks:
async function pipeline() {
try {
const res = await fetch('/api/data');
try {
const data = await res.json();
console.log('Data:', data);
} catch (err) {
console.error('JSON parsing error:', err.message);
}
} catch (err) {
console.error('Request error:', err.message);
}
}This approach is useful when a network failure and invalid JSON have to be treated separately.
The [err, data] pattern without try...catch
Sometimes it is more convenient to use a helper that never rejects and returns a pair instead:
const to = (promise) => promise.then(data => [null, data]).catch(err => [err]);
async function run() {
const [err, data] = await to(fetch('/api/user').then(r => r.json()));
if (err) return console.error('Error:', err.message);
console.log(data);
}This pattern is common in Node.js, as a way to avoid writing try...catch at every step and to keep error handling flat.
The global unhandledrejection handler
If .catch() or try...catch was forgotten, the error can still be intercepted globally:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise:', event.reason);
});This is the equivalent of try...catch for every promise that has no .catch() of its own. In Node.js, process.on('unhandledRejection', handler) plays the same role. Such a handler is a last line of defence and a place to log, not a replacement for proper handling.
| Way | Where it catches the error | Example |
|---|---|---|
try...catch | inside the async function | try { await ... } catch (e) { ... } |
.catch() | at the function call | myAsync().catch(e => ...) |
| Global handler | for everything unhandled | window.addEventListener('unhandledrejection', ...) |
The [err, data] pattern | through a wrapper | const [err, res] = await to(promise) |
A simple analogy: an
asyncfunction is a quest.awaitis a task that may fail;try...catchis the safety net that lets you decide what to do next;.catch()on the outside is the game master who picks up the fatal failures the player could not handle.
Common mistakes
- Forgetting
awaitbeforeres.json()in areturn: withtry { return res.json(); }the parsing error escapes thetryblock, so the localcatchnever sees it. - Catching an error, doing nothing and not rethrowing it: the caller receives
undefinedand behaves as if everything succeeded. - Assuming
fetchthrows on HTTP 404 or 500. It rejects only on a network failure, so the status must be checked manually throughres.ok. - Wrapping a call to an async function in
try...catchwithoutawait: withoutawaitthe exception never reaches that block and becomes an unhandled rejection instead. - Relying on the global
unhandledrejectionalone: it fires too late and gives the user no meaningful message. - Rethrowing a string instead of an
Error: the call stack is lost anderr.messageisundefined.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.