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:
| Style | How errors arrive | How you handle them |
|---|---|---|
| Callbacks | As the first argument (err) | if (err) { ... } |
| Promises | As a rejection | .catch() |
| async/await | As a thrown error at the await | try / catch |
| Event emitters | As 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
tryrunssetTimeout, which only registers the callback.- The
try / catchblock finishes and is popped off the call stack. - One second later, the callback runs on a fresh call stack. The
try / catchno 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
errfirst. - Always
returnafter handling it. - Don't
throwinside 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
.thenuntil it reaches a.catch. - A
throwinside.thenturns into a rejection automatically. .finallyruns 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 chaingetUser(1).then((user) => {getOrders(user);}).catch((err) => console.error(err)); // won't catch getOrders errors// RightgetUser(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 finishedasync function load() {try {return fetchData();} catch (err) {console.error("Never runs");}}// Rightasync 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 heretry {ids.forEach(async (id) => {await deleteUser(id);});} catch (err) {console.error(err); // never runs}// Right: one by onefor (const id of ids) {await deleteUser(id);}// Right: in parallelawait Promise.all(ids.map((id) => deleteUser(id)));
Errors in Parallel Tasks
// Promise.all: fails as soon as ONE task failstry {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 taskconst 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
| Version | Async errors |
|---|---|
| Express 5 | A rejected promise in an async handler is passed to the error middleware automatically |
| Express 4 | You must catch the error and call next(err) yourself |
// Express 4app.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 errors | Programmer errors | |
|---|---|---|
| What | Expected problems from the outside world | Bugs in your code |
| Examples | File not found, network timeout, invalid user input | Reading a property of undefined, wrong argument type |
| What to do | Handle them: retry, show a message, use a default | Fix the code. Log, exit, and restart |
Summary
try / catchonly catches errors thrown while it is on the call stack, so it misses errors inside callbacks that run later.- Callbacks: check
errfirst andreturn. Don'tthrowinside a callback. - Promises: use
.catch(), alwaysreturnpromises inside.then, and use.finally()for cleanup. - async/await: use
try / catch, and don't forgetawait, or the error escapes. - Don't use
asyncinsideforEach. Usefor...oforPromise.all. Promise.allfails on the first error.Promise.allSettledreports every result.- Always add an
"error"listener to event emitters and streams. - Unhandled rejections crash Node.js. Use
process.onhandlers 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.