On this page

Error Handling in Async Code

Errors in async code don't behave like errors in normal code. A try / catch that works perfectly for sync code can completely miss an async error, and an unhandled one can crash your Node.js process.

Definition

Async error handling means catching errors that happen later, after the code that started the work has already finished running. Each async style has its own way:

StyleHow errors arriveHow you handle them
CallbacksAs the first argument (err)if (err) { ... }
PromisesAs a rejection.catch()
async/awaitAs a thrown error at the awaittry / catch
Event emittersAs an "error" event.on("error", ...)

Analogy: think of sending a parcel. A sync error is the counter clerk telling you right away that the address is wrong. An async error is the parcel being returned a week later. By then you've left the counter, so you need a way to receive the bad news (a return address), or it gets lost.

Why try / catch Misses Async Errors

try {
setTimeout(() => {
throw new Error("Boom");
}, 1000);
} catch (err) {
console.log("Caught:", err.message); // never runs
}
// The process crashes with: Error: Boom
  1. try runs setTimeout, which only registers the callback.
  2. The try / catch block finishes and is popped off the call stack.
  3. One second later, the callback runs on a fresh call stack. The try / catch no longer exists, so nothing catches the error.

Rule: try / catch only catches errors thrown while it is on the call stack. For async work, handle the error where the result arrives.

1. Callbacks: Error-First

const fs = require("fs");
fs.readFile("data.txt", "utf8", (err, data) => {
if (err) {
console.error("Could not read file:", err.message);
return; // stop here, or the code below runs with no data
}
console.log(data);
});
  • Always check err first.
  • Always return after handling it.
  • Don't throw inside a callback. Nothing is there to catch it, so it crashes the process. Pass the error to the next callback instead.

2. Promises: .catch()

getUser(1)
.then((user) => getOrders(user))
.then((orders) => {
if (orders.length === 0) {
throw new Error("No orders"); // becomes a rejection
}
return orders;
})
.then((orders) => console.log(orders))
.catch((err) => console.error("Failed:", err.message))
.finally(() => console.log("Done")); // runs either way
  • A rejection skips every .then until it reaches a .catch.
  • A throw inside .then turns into a rejection automatically.
  • .finally runs on success and failure, which is good for cleanup such as hiding a loader or closing a connection.

.catch in the Middle Recovers the Chain

getUser(1)
.catch(() => ({ id: 0, name: "Guest" })) // recover with a default user
.then((user) => console.log(user.name)); // runs even if getUser failed

After a .catch returns a value, the chain continues as a success.

Mistake: Forgetting to return

// Wrong: getOrders is not returned, so its error escapes the chain
getUser(1)
.then((user) => {
getOrders(user);
})
.catch((err) => console.error(err)); // won't catch getOrders errors
// Right
getUser(1)
.then((user) => {
return getOrders(user);
})
.catch((err) => console.error(err));

3. async/await: try / catch

Because await pauses the function until the promise settles, the function is still on the call stack when the error arrives. So try / catch works again.

async function showOrders() {
try {
const user = await getUser(1);
const orders = await getOrders(user);
console.log(orders);
} catch (err) {
console.error("Failed:", err.message);
} finally {
console.log("Done");
}
}

Mistake: Missing await

// Wrong: the promise is returned without waiting,
// so its rejection happens after try / catch has finished
async function load() {
try {
return fetchData();
} catch (err) {
console.error("Never runs");
}
}
// Right
async function load() {
try {
return await fetchData();
} catch (err) {
console.error("Caught:", err.message);
}
}

Mistake: async Inside forEach

// Wrong: forEach doesn't wait, and errors are not caught here
try {
ids.forEach(async (id) => {
await deleteUser(id);
});
} catch (err) {
console.error(err); // never runs
}
// Right: one by one
for (const id of ids) {
await deleteUser(id);
}
// Right: in parallel
await Promise.all(ids.map((id) => deleteUser(id)));

Errors in Parallel Tasks

// Promise.all: fails as soon as ONE task fails
try {
const [user, orders] = await Promise.all([getUser(1), getOrders(1)]);
} catch (err) {
console.error("One task failed:", err.message);
}
// Promise.allSettled: never fails, gives the result of EVERY task
const results = await Promise.allSettled([getUser(1), getOrders(1)]);
results.forEach((result) => {
if (result.status === "fulfilled") console.log(result.value);
else console.error(result.reason.message);
});

Use Promise.all when you need every result. Use Promise.allSettled when partial success is fine.

4. Event Emitters and Streams

Event emitters and streams report errors with an "error" event. If there is no "error" listener, Node.js throws the error and crashes the process.

const fs = require("fs");
const stream = fs.createReadStream("missing.txt");
stream.on("error", (err) => {
console.error("Stream failed:", err.message);
});

Unhandled Rejections and Uncaught Exceptions

If a promise rejects and nothing handles it, it is an unhandled rejection. Since Node.js 15, this crashes the process by default.

process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
process.exit(1);
});

These are a last safety net, used to log the error and exit. Don't use them to keep the app running: after an unexpected error, the app may be in a broken state. Let a process manager (like PM2, Docker, or Kubernetes) restart it.

Errors in Express

VersionAsync errors
Express 5A rejected promise in an async handler is passed to the error middleware automatically
Express 4You must catch the error and call next(err) yourself
// Express 4
app.get("/users/:id", async (req, res, next) => {
try {
const user = await getUser(req.params.id);
res.json(user);
} catch (err) {
next(err); // send to the error middleware
}
});
// Error middleware (4 arguments)
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: "Something went wrong" });
});

Operational vs Programmer Errors

Operational errorsProgrammer errors
WhatExpected problems from the outside worldBugs in your code
ExamplesFile not found, network timeout, invalid user inputReading a property of undefined, wrong argument type
What to doHandle them: retry, show a message, use a defaultFix the code. Log, exit, and restart

Summary

  • try / catch only catches errors thrown while it is on the call stack, so it misses errors inside callbacks that run later.
  • Callbacks: check err first and return. Don't throw inside a callback.
  • Promises: use .catch(), always return promises inside .then, and use .finally() for cleanup.
  • async/await: use try / catch, and don't forget await, or the error escapes.
  • Don't use async inside forEach. Use for...of or Promise.all.
  • Promise.all fails on the first error. Promise.allSettled reports every result.
  • Always add an "error" listener to event emitters and streams.
  • Unhandled rejections crash Node.js. Use process.on handlers only to log and exit.
  • Handle operational errors. Fix programmer errors.

Interview Answer: How Do You Handle Errors in Async Code?

A normal try / catch only catches errors thrown while it is on the call stack, so it can't catch errors from a callback that runs later. Each async style has its own approach. With callbacks, I use the error-first convention: check err, handle it, and return. With promises, I add a .catch() at the end of the chain and make sure every .then returns its promise. With async/await, I wrap await calls in try / catch, and I'm careful not to miss an await, or the rejection escapes.

For parallel tasks, Promise.all rejects on the first failure, so I use Promise.allSettled when partial results are fine. For streams and event emitters, I always add an "error" listener, or Node throws and crashes.

Since Node.js 15, an unhandled rejection crashes the process. I add unhandledRejection and uncaughtException handlers only as a last safety net to log and exit, and let a process manager restart the app. I also separate operational errors, which I handle, from programmer errors, which I fix.

Next TopicClosures, this, Hoisting, and Scope