Async Error Handling Patterns
Node has been through three generations of error handling and you will meet all of them in real code. The oldest is the error-first callback: you pass a function that receives (err, result), and you check err before touching result. Then came promises, with .then() for success and .catch() for failure. Then async/await, which lets you write asynchronous code that reads like synchronous code and, crucially, makes ordinary try/catch work again.
That last point is the whole appeal. With await, a rejected promise behaves like a thrown exception, so one try/catch can cover several operations in a row instead of a callback nested inside a callback inside a callback. Nearly all new Node code is written this way, and older callback APIs can be brought across with util.promisify or by using the promise version of the module — node:fs/promises rather than node:fs.
In Express, catching the error is only half the job; the other half is getting it to the error handler. next(err) is the mechanism — passing anything to next tells Express to abandon the normal chain and go straight to the four-argument handler. So the shape of every async route is the same three lines: try, do the work, catch (err) { next(err); }.
Writing that in forty routes is tedious and you will forget it once, which is enough. The usual solution is a small wrapper — asyncHandler in the example below — that takes your handler, runs it, and attaches a .catch(next) to whatever it returns. Wrap each async route in it and forgetting the try/catch stops being possible at all.
Whether you need the wrapper depends on your Express version: newer major versions forward a rejected promise from a route handler to the error handler by themselves, and older ones do not. Check which your project uses rather than assuming, because the failure mode when you assume wrongly is a request that hangs forever while an unhandled rejection is logged somewhere you were not looking.
const express = require('express');
const app = express();
// Wrapper function to catch async errors automatically
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Without wrapper — manual try/catch (repetitive)
app.get('/api/users', async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (err) {
next(err);
}
});
// With wrapper — cleaner code
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
})); (err, result)— the error-first callback convention, still everywhere in older codeawaitmakes a rejected promise behave like a thrown exceptiontry { ... } catch (err) { next(err); }— the shape of every async route handlernext(err)skips the remaining middleware and jumps to the error handlerasyncHandler(fn)— one wrapper, so a forgottentry/catchcannot happennode:fs/promisesandutil.promisify— bringing callback APIs intoawait
- Whether Express forwards async errors on your behalf depends on the major version you are using, so check
package.jsonrather than trusting a blog post. Using the wrapper is harmless either way — it costs one small function and makes the behaviour explicit to whoever reads the code next, including the version of you that has forgotten this paragraph.
The Errors try/catch Will Not Catch
A try/catch catches what is thrown while its block is running. That sentence sounds too obvious to be useful, and it is the source of most surprising error behaviour in Node — because a great deal of your code does not run while the block is running. It runs later, from a queue, after the block has finished and its catch has ceased to exist.
Wrap the callback form of fs.readFile in a try/catch and the catch will never fire, whatever happens inside that callback. The block completed the instant readFile was called; the callback runs on a later turn of the event loop with no relationship to it whatsoever. Error-first callbacks exist precisely because of this — the error has to be handed to you as an argument, because there is nowhere useful for it to be thrown.
The most common version of this is a forgotten await. Call an async function without one and you get a promise nobody is watching. If the operation fails, the rejection is unhandled: your try/catch never saw it, the request may already have answered successfully, and Node logs an unhandled rejection naming a line that is not where the mistake lives. Whenever an error seems to arrive from nowhere, go looking for an async call with no await in front of it.
Streams, servers and database connections are event emitters, and they report failure by emitting an 'error' event rather than throwing. An emitter that emits 'error' with no listener attached takes the whole process down. So a file stream, a socket or a connection pool needs its own .on('error', ...), and no amount of try/catch around the code that created it is a substitute.
Finally, work that you deliberately do not await — sending a welcome email after responding, writing an audit record — still needs a .catch(). This is the one place where an explicit .catch(err => logger.error(err)) is better style than await, because you have decided the user should not wait for it. Deciding not to wait does not excuse you from handling the failure.
const fs = require('node:fs');
// 1. try/catch cannot see inside a callback
try {
fs.readFile('data.json', 'utf8', (err, data) => {
if (err) throw err; // thrown on a LATER turn — nothing catches it
JSON.parse(data); // a parse error here is uncaught too
});
} catch (err) {
console.log('never reached');
}
// The promise API makes try/catch meaningful again
const fsp = require('node:fs/promises');
try {
const data = await fsp.readFile('data.json', 'utf8');
JSON.parse(data);
} catch (err) {
console.error('handled properly:', err.message);
}
// 2. A forgotten await — the classic
async function handler(req, res) {
try {
saveOrder(req.body); // <- no await: the rejection escapes this try
res.json({ ok: true }); // ...and we have already told the client "ok"
} catch (err) {
res.status(500).json({ error: 'failed' }); // never runs
}
}
// 3. Emitters report failure by EVENT, not by throwing
const stream = fs.createReadStream('big.csv');
stream.on('error', err => console.error('read failed:', err.message));
// without that listener, an emitted 'error' takes the process down
// 4. Deliberately not awaited? Then catch it explicitly.
sendWelcomeEmail(user).catch(err => logger.error({ err }, 'welcome email failed')); try/catchonly catches what throws while its own block is running- A throw inside a callback happens later, when no enclosing
catchexists - A missing
awaitturns a failure into an unhandled rejection you will not see - Emitters signal failure with an
'error'event; no listener means the process dies - Work you intentionally do not await still needs an explicit
.catch() - Prefer the promise version of a module so that
try/catchmeans something
- When an error turns up in your logs with a stack trace pointing somewhere impossible, suspect a missing
awaitbefore anything else. The stack of a rejected promise nobody awaited belongs to the moment the promise settled, not to the code that created it, which is exactly why the line it names is so often irrelevant to the bug.
Custom Error Classes and a Global Handler
A route handler should describe what went wrong, not decide how to report it. A custom error class is what makes that separation possible: throw new AppError('Task not found', 404, 'TASK_NOT_FOUND') says everything the handler knows, and the global error handler decides what the response should look like.
The class itself is small — extend Error, take a message, a status code and a stable string code, and set a flag such as isOperational marking this as an expected failure rather than a bug. Extending the built-in Error matters: stack traces, instanceof checks, logging libraries and your debugger all rely on that base class.
The global handler then becomes the single place in your application that knows about HTTP status codes. It translates the errors it recognises — your own AppError, Mongoose's ValidationError and CastError, a duplicate key from the database — into codes and bodies, and turns everything it does not recognise into a 500. Keeping that translation in one file is what stops the same three if statements from appearing in forty routes.
Register it last, after the routes and after the 404 handler, with four parameters. Both points came up in lesson 10 and both are worth repeating here, because an error handler in the wrong position silently never runs, and one written with three parameters silently is not an error handler at all.
One habit that pays off the first time something goes wrong in front of a real user: give each request an id, log it alongside every error, and include it in the response body. When somebody says "it failed", that single identifier takes you to the exact stack trace instead of a search through a day of logs for something that happened "this afternoon, I think".
// utils/AppError.js — Custom error class
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code; // a stable string the frontend can branch on
this.isOperational = true; // an expected failure, not a bug
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// Usage in routes
const AppError = require('./utils/AppError');
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new AppError('User not found', 404);
}
res.json(user);
}));
app.post('/api/users', asyncHandler(async (req, res) => {
if (!req.body.email) {
throw new AppError('Email is required', 400);
}
const user = await User.create(req.body);
res.status(201).json(user);
}));
// Global error handler (in app.js, after all routes)
app.use((err, req, res, next) => {
console.error(`[ERROR] ${err.message}`);
// Mongoose validation error
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation Error',
details: Object.values(err.errors).map(e => e.message)
});
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
// Our custom AppError
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
// Anything else is a bug: log it fully, tell the client almost nothing
console.error({ requestId: req.id, stack: err.stack });
res.status(500).json({
error: 'Something went wrong',
requestId: req.id // the user can quote this when reporting it
});
}); class AppError extends Error— message, status code, and a stablecodeisOperational— marks an expected failure as opposed to a bug- Handlers throw; the global handler decides the status code and the body
- Translate library errors —
ValidationError,CastError, duplicate keys — in one place - Four parameters, registered last, after the routes and after the 404 handler
- Attach a request id, log it with the error, and return it to the client
- Extend the built-in
Errorrather than inventing a plain object with amessageproperty. Stack traces,instanceofchecks, logging libraries and your debugger all depend on that base class, and an object merely pretending to be an error loses every one of those at precisely the moment you need them.
Operational Errors, Bugs, and What to Send
Errors come in two kinds and they deserve opposite treatment. An operational error is the expected outcome of a normal situation: a task that does not exist, an email already registered, a database briefly unreachable. A programmer error is a bug: reading a property of undefined, calling something that is not a function, a typo in a field name.
Operational errors are part of your API's design. Give each one a specific status code and a message the user can act on. Bugs are not part of any design — they mean your code did something you did not anticipate, and the only honest response is a 500, a log entry with a full stack trace, and a fix.
The distinction decides what you send. For an operational error the message is both safe and useful: "That email is already registered". For a bug the message very often contains internal detail — a column name, a file path, a fragment of a query — and forwarding it hands a stranger a free description of how your system is built. Log everything; return the minimum, plus a request id.
It also decides how you log. Operational errors are ordinary events, and at any volume they are noise — log them at a lower level, or simply count them. A bug deserves a full stack trace and, in a real deployment, an alert. If every error is logged identically, the log stops being something anybody reads, which is worse than not logging at all.
One more part of error handling that is routinely forgotten: think about the person receiving it. An error the user can fix should say what to do about it. An error they cannot fix should say so plainly instead of implying that trying again will help. "Something went wrong" is acceptable for a bug and lazy for a validation failure.
const AppError = require('./utils/AppError');
// Operational: expected, and safe to explain
if (!task) throw new AppError('Task not found', 404, 'TASK_NOT_FOUND');
if (emailTaken) throw new AppError('That email is already registered', 409, 'EMAIL_TAKEN');
// A bug: nobody threw this on purpose
// const name = user.profile.name; // TypeError when profile is undefined
// One handler, two behaviours
app.use((err, req, res, next) => {
if (err.isOperational) {
logger.warn({ requestId: req.id, code: err.code }, err.message);
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message }
});
}
// A bug: full detail to the log, almost nothing to the client
logger.error({ requestId: req.id, stack: err.stack }, 'unhandled error');
return res.status(500).json({
error: { code: 'INTERNAL', message: 'Something went wrong' },
requestId: req.id
});
});
// What NOT to send:
// res.status(500).json({ error: err.message });
// -> "ER_NO_SUCH_TABLE: Table 'shop.orders_v2' doesn't exist"
// -> a stranger now knows your database name, your table names
// and roughly where you are in your migrations - Operational error — an expected situation; give it a status code and a clear message
- Programmer error — a bug; 500, full stack in the log, nothing useful to the client
err.isOperationalis what lets one handler treat the two differently- Never return
err.messagefor a 500 — driver messages describe your system - Log bugs loudly and operational errors quietly; identical logging means nobody reads it
- Return a request id so a user's report maps to one exact stack trace
- A quick test for whether an error is operational: could you have written its message in advance? "That email is already registered" — yes, you designed for it. "Cannot read properties of undefined (reading 'name')" — no, and no message you invent for it would help the user, which is exactly why it belongs behind a generic 500.
Crashing Well
Some errors escape everything. A rejected promise nobody awaited, an exception thrown from inside a timer, an emitter with no 'error' listener — none of these pass through Express, so no route handler and no error middleware will ever see them.
Node offers two process-level events for exactly these: unhandledRejection and uncaughtException. Their purpose is to log and exit, not to keep the process alive. That advice feels backwards the first time you read it and it is correct: once an exception has escaped, you no longer know what state your application is in — a half-written file, a transaction never committed, a variable left inconsistent — and serving further requests from a process in an unknown state is how one bug becomes corrupted data.
Exiting is safe because something restarts you. In development that is --watch; in a deployment it is a process manager or the platform's own supervisor. A clean crash and a fresh process takes about a second and begins from a known state. That is why "let it crash" is standard advice in Node rather than a sign of having given up.
Exiting well means finishing what you had already started. When a platform stops your application it sends SIGTERM first: stop accepting new connections, let the requests already in flight finish, close the database pool, then exit. Without that, a routine deployment cuts live requests off in the middle of a response, and users see errors caused by nothing except your deploy.
Put a timeout on the graceful path. If something refuses to close you do not want the shutdown itself to hang forever — wait a few seconds, then exit anyway. And keep process.exit(1) for failures at startup, such as a missing environment variable or a database that will not connect. A process that cannot possibly do its job should not be alive to receive traffic.
// Errors that never reach Express
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled rejection — exiting');
process.exit(1); // log and leave; do not try to "keep going"
});
process.on('uncaughtException', (err) => {
logger.error({ stack: err.stack }, 'uncaught exception — exiting');
process.exit(1);
});
// Startup failures: refuse to run at all
if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET is not set');
process.exit(1);
}
// Graceful shutdown: finish what you already started
const server = app.listen(process.env.PORT || 3000);
function shutdown(signal) {
console.log(`${signal} received — closing`);
server.close(async () => { // stop accepting; let in-flight requests end
await pool.end(); // or mongoose.connection.close()
process.exit(0);
});
// If something refuses to close, do not hang forever
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); unhandledRejectionanduncaughtException— log, then exit- Do not try to carry on: the state of the process after an escaped error is unknown
- Something restarts you — a process manager, the platform, or
--watch SIGTERMmeans shut down: stop accepting, finish in-flight, close the pool, exit- Put a timeout on the shutdown path so it cannot hang indefinitely
process.exit(1)for startup failures — never serve traffic you cannot handle
- "Let it crash" surprises people who expect a server to be resilient, but the resilience lives at the process level rather than inside the process. One request that hits a bug should end in a 500 and, if the process is compromised, a fresh worker. The same bug swallowed and ignored can quietly corrupt the next hundred requests. Crash quickly, restart quickly, and read the log.
