Problem Solved

JavaScript · async error handling

Home › Courses › JavaScript › Problems › Async error handling
🟨 Concept · 4 min read

Async/await error handling.
try/catch, not try/catch.

try/catch catches sync errors and awaited promises. It does not catch errors in callbacks, event handlers, or un-awaited promises. That distinction is the source of most async bugs. This page covers what try/catch actually catches, what it doesn't, how Promise.all handles errors, and the pattern for unhandled rejections.

Short answer: Wrap await in try/catch. It catches synchronous errors and rejected promises you await. It does not catch errors in setTimeout, event handlers, or promises you don't await. Promise.all fails fast — one rejection fails everything. Use Promise.allSettled for partial results. For unhandled rejections, add a global handler: process.on('unhandledRejection') in Node, window.addEventListener('unhandledrejection') in browsers.

✅ What try/catch catches

Synchronous errors and awaited promises. Not callbacks.

try-catch-scope.js
// ✅ Caught: synchronous errors
try {
JSON.parse('not json');
} catch (e) {
console.log('caught sync');
}

// ✅ Caught: awaited rejection
try {
await fetch('/api/missing'); // 404 → rejects
} catch (e) {
console.log('caught await');
}

// ❌ NOT caught: setTimeout callback
try {
setTimeout(() => {
throw new Error('oops');
}, 100);
} catch (e) {
console.log('not reached');
}

// ❌ NOT caught: un-awaited promise
try {
fetch('/api/missing'); // no await
} catch (e) {
console.log('not reached');
}

// ❌ NOT caught: event handler
try {
button.addEventListener('click', () => {
throw new Error('boom');
});
} catch (e) {
console.log('not reached');
}
Output:
caught sync
caught await
(Uncaught Error: oops) ← setTimeout
(UnhandledPromiseRejection) ← un-awaited fetch
(Uncaught Error: boom) ← event handler

Why:

try/catch is a synchronous construct.
It catches errors that occur while the try
block is running.

setTimeout callback runs AFTER the try block
finishes. The try is long gone.

Un-awaited promise: the promise is created
and the try block finishes immediately.
The rejection happens later.

Event handler: same — the handler runs when
the event fires, not inside the try.

Rule:
try/catch around await → works
try/catch around sync code → works
try/catch around callbacks → does NOT work
try/catch around un-awaited promises → does NOT work

✅ The fixes

How to catch each case.

fixes.js
// Fix 1: try/catch INSIDE the callback
setTimeout(() => {
try {
throw new Error('caught');
} catch (e) {
console.log('caught inside');
}
}, 100);

// Fix 2: await the promise
try {
await fetch('/api/missing');
} catch (e) {
console.log('caught');
}

// Fix 3: .catch() on the promise
fetch('/api/missing').catch(e => {
console.log('caught via .catch');
});

// Fix 4: convert callback to promise
function wait(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
// callback logic
resolve('done');
} catch (e) {
reject(e);
}
}, ms);
});
}

try {
await wait(100);
} catch (e) {
console.log('caught');
}
Rule of thumb:

For sync errors → try/catch around the code.
For awaited promises → try/catch around the await.
For un-awaited promises → .catch() on the promise.
For callbacks → try/catch inside the callback.
For event handlers → try/catch inside the handler.

The common mistake: assume try/catch covers
all errors in the block. It doesn't.

Common bug in React:

❌ useEffect(() => {
try {
fetchData().then(setData); // not awaited
} catch (e) {
setError(e); // never runs
}
}, []);

✅ useEffect(() => {
fetchData()
.then(setData)
.catch(setError); // catches
}, []);

Or:

✅ useEffect(() => {
(async () => {
try {
const data = await fetchData();
setData(data);
} catch (e) {
setError(e);
}
})();
}, []);

The second version is more common in modern
code because it handles cleanup naturally.

✅ Promise.all error handling

Fast-fail vs settled.

promise-all.js
// Promise.all — fails fast
try {
const [user, posts] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json())
]);
console.log(user, posts);
} catch (e) {
// If either request fails, this runs.
// The other request may still be in flight.
console.log('one failed:', e);
}

// Promise.allSettled — waits for all
const results = await Promise.allSettled([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json())
]);

for (const result of results) {
if (result.status === 'fulfilled') {
console.log('success:', result.value);
} else {
console.log('failure:', result.reason);
}
}
Promise.all behavior:

1. Starts all promises simultaneously.
2. If any rejects, the whole Promise.all
rejects immediately with that error.
3. Other promises continue running — but
their results are discarded.
4. Total time = time of the slowest promise
OR the time of the first rejection.

Promise.allSettled behavior:

1. Starts all promises simultaneously.
2. Waits for ALL of them to settle (resolve or reject).
3. Returns an array of { status, value } or
{ status, reason } objects.
4. Never rejects — you always get the array.
5. You decide how to handle each result.

When to use which:

✅ Promise.all — when any failure means
the whole operation should fail. Loading
dependent data, saving multiple records
atomically.

✅ Promise.allSettled — when you can handle
partial success. Loading a dashboard where
some widgets might fail, bulk operations
where you want a report of what succeeded
and what failed.

⚠️ Never use Promise.all for operations
where the failures are independent.

⚠️ Promise.all rejects with the FIRST error.
If multiple promises fail, you only see one.
For error aggregation, use allSettled.

The Promise.race and Promise.any variants:
Promise.race — first to settle (success or fail)
Promise.any — first to fulfill (ignores rejections)

Don't let async errors go unhandled. In Node.js 15+, an unhandled promise rejection crashes the process by default. In browsers, it shows a console error and fires the unhandledrejection event, which you can listen for. But by then, the error has already escaped your code. The fix: add a global handler at startup. In Node: process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled:', reason); }). In browsers: window.addEventListener('unhandledrejection', event => { console.error(event.reason); }). This catches errors you missed — but it's a safety net, not a substitute for proper error handling. Log the errors, alert on them, fix the code that caused them.

⚠️ Common mistakes

The async error handling mistakes that cost real time.

1. try/catch around a callback

The callback runs later — not caught. Fix: try/catch inside the callback.

2. Forgetting to await a promise

Rejection escapes the try/catch. Fix: await it, or add .catch().

3. Promise.all when one failure shouldn't fail all

All-or-nothing behavior. Fix: Promise.allSettled.

4. No global unhandled rejection handler

Silent failures in Node, console noise in browsers. Fix: add a global handler.

5. Catching and swallowing errors

Logging without acting hides bugs. Fix: only catch what you can handle.

6. Catching everything in every function

Makes debugging harder. Fix: catch at the boundary, let errors propagate inside.

7. Not handling partial failures

One failed request crashes the whole dashboard. Fix: allSettled + fallback UI.

8. Forgetting cleanup in error paths

Loading spinners that never stop. Fix: use finally for cleanup.

❓ Frequently asked

How do I handle errors in async/await?

Use try/catch, same as synchronous code. try { const data = await fetchData(); } catch (error) { console.error(error); }. This catches any rejected promise that you await inside the try block. For parallel operations, wrap Promise.all in try/catch — if any promise rejects, the whole Promise.all rejects.

What is an unhandled promise rejection?

An unhandled rejection happens when a promise rejects and nothing handles the error. In Node.js, unhandled rejections can crash the process. In browsers, they show as console errors and fire the unhandledrejection event. The most common cause: an async function that awaits a rejecting promise without try/catch.

How does Promise.all handle errors?

Promise.all fails fast. If any promise in the array rejects, the entire Promise.all rejects with that error — the other promises continue running but their results are discarded. For cases where you want all results regardless of individual failures, use Promise.allSettled.

Can I use try/catch around setTimeout?

No — try/catch only catches synchronous errors and errors from awaited promises. A setTimeout callback runs later, outside the try block, so an error inside the callback is not caught. To catch errors inside a callback, use try/catch inside the callback itself, or convert the callback to a promise and await it.

How do I retry a failed async call?

Wrap the await in a loop with try/catch. This retries with exponential backoff. For production, use a library like p-retry or async-retry, which handle edge cases. For simple cases, the loop pattern is fine.

Should I catch every error in every async function?

No — that's over-engineering. The rule: catch errors where you can do something useful. If the caller can handle the error, let it propagate. If the error is expected and recoverable (network timeout, retry), catch it. If the error is unexpected (programming bug), let it propagate to a top-level handler.

🔗 Continue learning

🗺️ JavaScript learning path
The full roadmap from variables to async — every concept in order

Next in this series:

📊 map vs filter vs reduce
Which one transforms, which one selects, which one aggregates
← All JavaScript problems
Problem solved · JavaScript