Handling Async Errors With try/catch in JavaScript
Master async error handling in JavaScript with try/catch and async/await. Learn how await transforms rejections into throws, handle errors in parallel operations, build reliable async error boundaries, and avoid common pitfalls.
Synchronous try/catch works beautifully with async/await because await converts Promise rejections into thrown exceptions at the exact await line. This means the same try/catch syntax you use for synchronous code works for async code with minimal adjustment. However, there are important differences in how errors propagate in async contexts that can catch developers off-guard.
How await Transforms Rejections Into Throws
The fundamental mechanism:
// This:
async function example() {
try {
const data = await fetchData(); // If fetchData() rejects...
process(data);
} catch (err) {
// ...the rejection reason lands here as 'err'
console.error(err.message);
}
}
// Is equivalent to:
function example() {
return fetchData()
.then((data) => process(data))
.catch((err) => {
console.error(err.message);
});
}When an awaited Promise rejects, JavaScript throws at the await expression, transferring control to the nearest enclosing catch block — exactly as if it had been a throw statement.
Basic Pattern: Single Operation
async function loadUser(id) {
try {
const user = await fetchUser(id);
return user;
} catch (error) {
console.error("Failed to load user:", error.message);
return null;
}
}Multiple Operations in One try Block
One try/catch can cover multiple await expressions. The first rejection stops execution and jumps to catch:
async function loadProfile(userId) {
try {
const user = await fetchUser(userId); // If this rejects...
const profile = await fetchProfile(user.id); // ...this never runs
const settings = await fetchSettings(userId); // ...nor does this
return { user, profile, settings };
} catch (error) {
// We get here from whichever await rejected
// But we don't know WHICH one without extra context
console.error("Profile load failed:", error.message);
return null;
}
}Important: Without additional context on the error object (or separate try/catch blocks), you cannot determine which await caused the rejection from inside the catch block. If different operations need different recovery strategies, use nested try/catch:
async function loadProfileRobust(userId) {
// Critical: must succeed
let user;
try {
user = await fetchUser(userId);
} catch (error) {
throw new Error("User not found", { cause: error });
}
// Optional: fail gracefully with defaults
let profile = {};
try {
profile = await fetchProfile(user.id);
} catch {
console.warn("Profile unavailable, using defaults");
}
return { user, profile };
}Async Errors That try/catch CANNOT Catch
A common source of confusion: try/catch cannot catch errors in callbacks that are not awaited:
// WRONG: try/catch does NOT catch this
async function example() {
try {
setTimeout(() => {
throw new Error("Inside setTimeout"); // Uncaught! Not caught by try/catch
}, 100);
} catch (err) {
console.log("Caught:", err); // Never runs
}
}
// WRONG: try/catch does NOT catch Promise rejections without await
async function example2() {
try {
fetchData(); // No await — rejection is unhandled
doOtherStuff();
} catch (err) {
console.log("Caught:", err); // Never runs for fetchData rejection
}
}Rule: try/catch only catches what is synchronously thrown OR what is await-ed and rejects. For anything else, use .catch() directly on the Promise or a global error handler.
Handling Errors in Parallel Operations
With Promise.all, a single rejection rejects the whole group:
// Promise.all: try/catch catches the first rejection
async function loadAll(ids) {
try {
const users = await Promise.all(
ids.map((id) => fetchUser(id))
);
return users;
} catch (err) {
// Caught the FIRST rejection from any fetchUser call
console.error("Batch load failed:", err.message);
return [];
}
}
// Promise.allSettled: no rejection reaches try/catch — all outcomes are in the array
async function loadAllSafe(ids) {
const results = await Promise.allSettled( // Never rejects
ids.map((id) => fetchUser(id))
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
console.warn(`${failed.length} users failed to load`);
}
return results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
}Error Bubbling in async Functions
An unhandled rejection in an async function causes the returned Promise to reject, which bubbles up to the caller:
async function step1() {
throw new Error("step1 failed");
}
async function step2() {
await step1(); // step1 rejects → throws here
// Never reaches here
}
async function main() {
try {
await step2(); // step2 rejects → throws here
} catch (err) {
console.error("Caught at top level:", err.message); // "step1 failed"
}
}The error message, type, and stack trace are preserved through the entire chain. The original new Error("step1 failed") is what you catch at the top level.
async Error Boundaries (Framework Pattern)
In UI frameworks, an "error boundary" is a component that catches errors from its children. The async equivalent is a wrapper function that catches all errors from an async operation and handles them centrally:
// Generic async error boundary
async function safeRun(asyncFn, fallback = null, errorHandler = console.error) {
try {
return await asyncFn();
} catch (err) {
errorHandler(err);
return typeof fallback === "function" ? fallback(err) : fallback;
}
}
// Usage
const user = await safeRun(
() => fetchUser(id),
null,
(err) => Sentry.captureException(err)
);
// If fetchUser rejects: user = null, error is sent to Sentry
// If fetchUser resolves: user = the actual user objectReturn await vs Return for Error Capture
A subtle but important difference in try/catch with async returns:
// Version A: return (no await)
async function getDataA() {
try {
return fetchData(); // Returns the Promise directly
// If fetchData rejects AFTER this function returns, try/catch does NOT catch it
} catch (err) {
console.log("Caught:", err); // May NOT catch fetchData rejection
}
}
// Version B: return await
async function getDataB() {
try {
return await fetchData(); // Awaits the Promise inside the try block
// If fetchData rejects, it throws HERE → caught by CATCH
} catch (err) {
console.log("Caught:", err); // WILL catch fetchData rejection
}
}When you have a try/catch in an async function and want to catch rejections from the return expression, use return await.
Pattern: Async-safe Error Logging
async function withErrorLogging(label, asyncFn) {
const start = performance.now();
try {
const result = await asyncFn();
const duration = performance.now() - start;
console.log(`[${label}] completed in ${duration.toFixed(1)}ms`);
return result;
} catch (err) {
const duration = performance.now() - start;
console.error(`[${label}] failed after ${duration.toFixed(1)}ms:`, err.message);
throw err; // Always rethrow — this is logging, not recovery
}
}
// Usage
const user = await withErrorLogging("fetchUser", () => fetchUser(id));Rune AI
Key Insights
- await converts rejections to throws: An awaited rejected Promise throws at the await line — exactly as if it were
throw error, enabling standard try/catch to work - Non-awaited rejections bypass try/catch: Only what is awaited (or synchronously thrown) is catchable; unhandled Promises, callbacks, and setTimeout errors are not
- return await matters inside try/catch:
return promiseescapes the try block before rejection is observable;return await promiseawaits it inside, enabling local catch - Use allSettled for partial-failure tolerance:
Promise.all+ try/catch catches the first failure;Promise.allSettledhandles all outcomes without needing try/catch at the allSettled level - Error bubbling is predictable: Unhandled async function rejections propagate to the caller's await/catch — the error type and stack trace are preserved through the entire async call chain
Frequently Asked Questions
Why doesn't try/catch work with setTimeout callbacks?
Can I use try/catch in a class with async methods?
Is there a way to avoid deeply nested try/catch blocks?
What happens if the catch block itself throws?
Conclusion
Async error handling with try/catch works naturally because await translates rejections into throws. The key rules are: always await Promises you want try/catch to cover, use return await (not return) inside try blocks when you want the rejection caught locally, and use Promise.allSettled instead of Promise.all when partial failure is acceptable. For comprehensive error handling, combine try/catch with custom error classes and advanced try/catch patterns.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.