Handling Async Errors with Try Catch in JS Guide
Learn how to catch and recover from errors in async JavaScript. Master try...catch with await, handle nested async errors, use finally for cleanup, and avoid swallowed rejections.
Handling async errors in JavaScript comes down to one rule: when you await a promise that rejects, JavaScript throws the rejection reason just like a synchronous throw statement. A try...catch block catches it the same way. This is the biggest ergonomic win of async/await: error handling looks identical to synchronous error handling.
async function loadUser(id) {
try {
const user = await fetchUser(id);
console.log("User loaded:", user.name);
return user;
} catch (error) {
console.error("Failed to load user:", error.message);
return null;
}
}If fetchUser rejects, execution jumps from the await line straight to catch. The console.log line is skipped, and the function returns null instead of a user object. Nothing crashes, and the caller of loadUser never sees a raw exception, only a value it can check.
How await turns rejections into throws
Under the hood, await unwraps a resolved promise and throws whatever a rejected one carries. The function below awaits a rejected promise inside a try block.
async function example() {
try {
await Promise.reject(new Error("Something went wrong"));
} catch (err) {
console.error(err.message);
}
}This behaves exactly like throwing that same error directly, since await converts a rejection into a thrown value the moment it happens.
async function equivalentExample() {
try {
throw new Error("Something went wrong");
} catch (err) {
console.error(err.message);
}
}In both cases, the error propagates to the nearest catch. There is no difference in how you handle the error once it arrives, whether it came from a rejected await or a plain throw.
This equivalence is why async error handling never needs its own separate mental model. Every rule you already know about try...catch, including how errors skip the rest of the try block or how a catch can re-throw, applies exactly the same way once a rejected await is treated as just another kind of throw.
Protecting multiple await calls with one try...catch
A single try...catch can wrap an entire sequence of async operations. The first rejection jumps to catch, and any remaining await calls are skipped.
async function createOrder(userId, productId, quantity) {
try {
const user = await fetchUser(userId);
const product = await fetchProduct(productId);
const inventory = await checkInventory(product.sku, quantity);
const order = await placeOrder(user.id, product.id, quantity);
await sendConfirmationEmail(user.email, order.id);
return order;
} catch (error) {
console.error("Order creation failed:", error.message);
return { error: error.message };
}
}If checkInventory throws because stock is zero, the placeOrder and sendConfirmationEmail calls never run. The entire five-step operation is protected by one try...catch block, which is cleaner than the promise equivalent that would need a catch at the end of a long chain or individual error handlers on each step.
Notice that the catch block here cannot tell which of the five steps actually failed, only that something did. When the caller needs to know exactly which step broke, whether it was the user lookup, the inventory check, or the email send, wrap the risky step in its own nested try...catch instead of relying on one shared catch for everything.
Catching specific error types
Not all errors are the same. A network timeout needs different handling than a validation error, and treating them identically usually means showing the user a message that does not actually match what went wrong. Use instanceof or error properties to branch inside the catch block, starting with a couple of custom error classes to tell failure modes apart.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}A second subclass follows the same shape but carries an HTTP status code instead of a field name, since network failures need different context than validation failures.
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "NetworkError";
this.statusCode = statusCode;
}
}Each subclass carries extra context beyond a plain message. A small helper turns any caught error into a response shape the caller can display.
function describeSubmitError(error) {
if (error instanceof ValidationError) {
return { success: false, field: error.field, message: error.message };
}
if (error instanceof NetworkError && error.statusCode === 429) {
return { success: false, message: "Too many requests. Try again in a minute." };
}
console.error("Unexpected error:", error);
return { success: false, message: "Something went wrong. Please try again." };
}The submit function itself stays short because all the branching logic now lives in that one helper, called from a single catch block.
async function submitForm(data) {
try {
await validateInput(data);
await saveToServer(data);
return { success: true };
} catch (error) {
return describeSubmitError(error);
}
}Custom error classes let you handle known failure modes precisely while still having a catch-all for unexpected errors at the bottom of the chain. The order of the checks matters too: put the more specific conditions first, since a broad catch-all at the top would swallow every case below it before they ever get a chance to run. For more on creating custom errors, see Creating Custom Errors in JS: Complete Tutorial.
Nested try...catch for granular control
Not every error should abort the entire operation. Nest try...catch blocks when you want to recover from a specific step and continue, starting with the one piece that the rest of the function cannot function without.
async function loadRequiredProfile(userId) {
try {
return await fetchProfile(userId);
} catch (err) {
console.error("Cannot load dashboard without profile:", err.message);
throw err; // Re-throw to let the caller handle it
}
}Notifications and analytics are soft dependencies. A small helper fetches one of them and falls back to an empty value instead of re-throwing, so one missing feature never blocks the rest of the page.
async function loadOptional(label, fetcher, fallback) {
try {
return await fetcher();
} catch (err) {
console.warn(`${label} unavailable:`, err.message);
return fallback;
}
}The dashboard function combines both patterns: one required step that can abort everything, and two optional steps that degrade quietly on their own.
async function loadUserDashboard(userId) {
const profile = await loadRequiredProfile(userId);
const notifications = await loadOptional("Notifications", () => fetchNotifications(userId), []);
const analytics = await loadOptional("Analytics", () => fetchAnalytics(userId), {});
return { profile, notifications, analytics };
}The profile is critical, so its failure re-throws to the caller through loadRequiredProfile. Notifications and analytics reuse the same loadOptional helper, which gives every soft dependency the same fallback behavior without repeating a try...catch block for each one.
This is the general shape worth remembering: wrap anything the rest of the function truly cannot proceed without in its own try...catch that re-throws, and wrap anything merely nice to have in a helper that swallows the error and returns a safe default instead.
Cleanup with finally
A finally block runs after try (if successful) and after catch (if an error was caught). It does not matter whether the operation succeeded, failed, or recovered. Finally always runs.
async function loadPageData() {
const spinner = showSpinner();
try {
const data = await fetch("/api/page-data");
render(data);
} catch (error) {
showErrorBanner("Could not load page data");
console.error(error);
} finally {
hideSpinner(spinner);
}
}The spinner hides no matter what. Without finally, you would need to call hideSpinner in both the try path, after render, and the catch path, after showErrorBanner. Finally eliminates that duplication.
Common finally use cases include hiding loading UI, closing open connections, stopping timers, resetting form submission flags, and logging that an operation finished, regardless of its outcome. Finally does not receive the result or the error. Its job is cleanup, not data inspection.
Handling errors from parallel promises
When you use Promise.all with await, a rejection from any promise rejects the whole batch, and a surrounding try...catch catches it just like any other rejection.
async function loadBatch(ids) {
try {
const users = await Promise.all(ids.map((id) => fetchUser(id)));
return users;
} catch (error) {
console.error("Batch load failed:", error.message);
return [];
}
}If any fetchUser rejects, Promise.all rejects immediately with that error, and the catch block returns an empty array. The other fetches that have not completed are not cancelled, but their results are discarded once the batch has already rejected.
This all-or-nothing behavior is often exactly what you want when the results only make sense together, such as loading a record and the related rows it depends on.
It becomes a problem when one slow or flaky endpoint should not be allowed to sink an otherwise successful batch. For partial success, use Promise.allSettled instead, which reports every outcome individually rather than rejecting the whole call. See Using Promise allSettled for Reliable JS APIs.
The unhandled rejection pitfall
An unhandled promise rejection is a promise that rejects but has no catch and is not awaited inside a try...catch. In Node.js, this prints a warning and can crash the process. In browsers, it fires the unhandledrejection event.
// Dangerous: rejection is not handled
async function risky() {
const promise = fetch("/data"); // No await, no .catch()
doOtherWork();
// If fetch rejects before the function ends, it is an unhandled rejection
}The safe version simply awaits that same call inside a try block instead of firing it off and moving on.
// Safe: every promise has a handler
async function safe() {
try {
const data = await fetch("/data");
doOtherWork(data);
} catch (err) {
console.error("Fetch failed:", err.message);
}
}Every async operation should have a clear error path. If a function calls an async operation and does not await it or chain a catch, that is a leak waiting to happen.
The tricky part is that unhandled rejections are easy to miss during development, because the function that started the risky call often keeps running and looks like it worked. The failure only becomes visible later, as a console warning, a crashed process, or a bug report from a user who saw a blank screen with no error message at all.
Retry patterns with try...catch
Some failures are temporary: a flaky network, a server that is briefly overloaded, a request that times out under load. Combine try...catch with a loop to retry those failed operations, waiting a little longer between each attempt so a struggling server gets some breathing room instead of being hit again immediately.
async function attemptFetch(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}The retry loop below calls that helper, catches whatever it throws, and waits a little longer before each new attempt.
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try {
return await attemptFetch(url, options);
} catch (error) {
if (attempt === maxRetries) {
throw new Error(`All ${maxRetries} attempts failed. Last error: ${error.message}`);
}
console.warn(`Attempt ${attempt} failed: ${error.message}`);
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
}
}Each failed attempt is caught, logged, and retried after a growing delay, since the wait time scales with the attempt number instead of staying fixed. If all attempts fail, the final error propagates to the caller instead of the loop silently giving up. This is a foundation for more robust patterns covered in API Retry Patterns in JavaScript: Full Tutorial.
Common error-handling mistakes
Empty catch blocks. A catch that does nothing hides bugs. At minimum, log the error.
// Mistake: silent failure
try {
await riskyOp();
} catch {}Adding one log line inside the catch block is often enough to turn a silent failure into something you can actually debug later.
// Better: at least log it
try {
await riskyOp();
} catch (err) {
console.error("riskyOp failed:", err.message);
}Catching errors too early. If a low-level function catches and swallows an error that the caller needs to know about, the caller proceeds with bad data instead of finding out something failed.
// Mistake: catching and returning null hides the problem from callers
async function fetchUser(id) {
try {
return await db.getUser(id);
} catch {
return null; // Caller thinks user does not exist, but the database was down
}
}Letting the error bubble up instead gives the caller the real failure to react to, rather than a null value that looks the same as a normal missing user.
// Better: let the caller decide
async function fetchUser(id) {
return db.getUser(id); // Let the error propagate
}Forgetting that async functions return promises. If you call an async function without await or a catch, its rejection is unhandled.
// Mistake: rejection is unhandled
async function main() {
fetchUser(1); // No await, no .catch()
console.log("Done");
}Awaiting the call inside a try block gives the rejection somewhere to go, the same fix used throughout this article.
// Correct: handle the promise
async function main() {
try {
const user = await fetchUser(1);
console.log(user.name);
} catch (err) {
console.error(err.message);
}
}Every one of these mistakes comes back to the same root cause: a rejected promise with no try...catch or catch handler waiting for it. Checking that every await sits inside a try block, or that every call you do not await is chained with a catch, catches almost all of them before they reach production.
Rune AI
Key Insights
- await turns a rejected promise into a thrown error that try...catch can handle.
- A single try...catch can protect multiple sequential await calls.
- Nested try...catch lets you handle errors at different levels of granularity.
- finally runs for cleanup whether the operation succeeded, failed, or recovered.
- Unhandled promise rejections are a serious bug. Always have a catch path.
Frequently Asked Questions
What happens if I do not catch an error in an async function?
Can I use try...catch around multiple await calls?
Does finally run after a catch block?
Conclusion
Async error handling in JavaScript follows the same try...catch...finally pattern as synchronous code. The difference is that await throws rejections instead of synchronous throw statements. Handle errors at the right level, clean up in finally, and never let a rejected promise go unhandled.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.