Error Handling & Production Patterns
Uncaught exceptions, unhandled rejections, centralized Express error middleware, and graceful shutdown.
Two kinds of errors
Production Node code needs to treat two categories of failure very differently. An operational error is an expected part of running software — a database connection timing out, a user submitting invalid input, a downstream API returning a 500. These are recoverable: catch them, respond appropriately, keep running. A programmer error is a bug — calling undefined.someMethod(), forgetting to await a Promise, a typo in a property name. These indicate the process is now in a state its own author didn't anticipate, and the safest response is usually to log it and let the process exit, rather than attempt to keep serving traffic from unknown ground.
| Operational error | Programmer error | |
|---|---|---|
| Example | DB timeout, invalid input, 3rd-party API failure | TypeError, undefined is not a function |
| Expected? | Yes — part of normal operation | No — a bug |
| Right response | Catch it, respond with an appropriate status/message | Log it, exit the process, let the process manager restart a clean instance |
| Where it's handled | try/catch, error-handling middleware |
uncaughtException / unhandledRejection — as a last resort, not a strategy |
uncaughtException and unhandledRejection
These two process-level events are Node's last line of defense — by the time either fires, something escaped every try/catch and every .catch() in the call chain:
process.on('uncaughtException', (err) => {
console.error('Uncaught exception — process is in an unknown state:', err);
// Log it, flush any pending telemetry, then exit — do NOT try to keep serving requests.
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
process.exit(1);
});
The reason both handlers exit rather than swallow the error and continue: once an exception has escaped every intended handler, the process's internal state is unverified — a half-updated in-memory cache, a lock that was never released, a connection left in a strange state. Continuing to serve requests from that point risks silent data corruption that's far harder to debug later than a clean restart is to recover from. In production, a process manager (PM2, Kubernetes restarting a crashed pod, a systemd unit) is expected to bring a fresh, known-good process back up immediately after — these handlers exist to log what happened and exit cleanly, not to keep the broken process alive.
Centralized error handling in Express
Building APIs with Express covered the basic shape of Express error-handling middleware. A production app needs it to distinguish operational errors it can describe to the client from unexpected ones it shouldn't leak details about, and to catch errors thrown inside async route handlers, which Express does not do automatically on its own.
// A custom error class that marks itself as operational and carries a status code
class ApiError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
// A wrapper that forwards a rejected Promise from an async route handler to next(err) —
// without this, a thrown error inside an `async` function is silently lost, not caught by Express
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await ordersRepository.findById(req.params.id);
if (!order) {
throw new ApiError(404, 'Order not found'); // caught by asyncHandler, forwarded to next()
}
res.json(order);
}));
// Centralized error-handling middleware — registered last, after every route
app.use((err, req, res, next) => {
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
// An unexpected, non-operational error — log the full detail internally,
// but never leak a stack trace or internal message to the client
console.error('Unexpected error:', err);
res.status(500).json({ error: 'Internal server error' });
});
This gives every route the same two options: throw an ApiError for anything the client should see a specific, safe message for, or let a genuine bug bubble up to be logged in full and answered with a generic 500 — no route handler needs its own bespoke try/catch just to get this behavior.
Graceful shutdown
A process manager sends SIGTERM before killing a container or replacing a pod during a deploy — ignoring it means in-flight requests get cut off mid-response instead of being allowed to finish:
const server = app.listen(3000, () => console.log('Listening on port 3000'));
let isShuttingDown = false;
async function shutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`${signal} received — starting graceful shutdown`);
// 1. Stop accepting NEW connections, but let in-flight requests finish
server.close(async (err) => {
if (err) {
console.error('Error during server close:', err);
process.exit(1);
}
// 2. Close resources only once existing requests are done being served
await db.pool.end();
await messageQueueConnection.close();
console.log('Graceful shutdown complete');
process.exit(0);
});
// 3. Safety net — if something hangs and never finishes, force-exit anyway
setTimeout(() => {
console.error('Forcing shutdown after timeout');
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C during local development
server.close() stops the HTTP server from accepting new connections but deliberately waits for requests already in progress to finish before its callback runs — closing the database pool and queue connection inside that callback, rather than immediately, is what prevents an in-flight request's final database query from failing because the pool vanished out from under it mid-request.
Common mistakes
- Catching
uncaughtExceptionand simply logging it without exiting, in an attempt to "keep the server up" — the process is now running with unverified internal state, and continuing to serve traffic risks silently corrupting data in ways that are much harder to trace later than a clean restart. - Writing an
asyncExpress route handler with noasyncHandler-style wrapper (or Express 5's native support for this) — a rejected Promise inside it is silently swallowed rather than reaching the error-handling middleware, so the request just hangs. - Leaking a raw error message or stack trace to the client for non-operational errors — it can expose internal implementation details, and a client has no reasonable way to act on "Cannot read properties of undefined" anyway.
- Killing the process immediately on
SIGTERMinstead of callingserver.close()first — in-flight requests get abruptly cut off mid-response during every single deploy, instead of being allowed to finish.
Interview questions
Q: Why should uncaughtException and unhandledRejection handlers exit the process instead of just logging and continuing?
Once an error escapes every intended try/catch or .catch() in the code, the process's internal state is no longer verified — an in-progress write, a lock, or a cache could be left inconsistent. Continuing to serve requests from that unknown state risks silent data corruption that's far more costly to debug than a clean restart, which is why the standard pattern is to log the error and exit, relying on a process manager to bring up a fresh instance immediately after.
Q: Why does an error thrown inside an async Express route handler need special handling to reach the error middleware?
Express's routing was built before async/await existed, and it has no built-in mechanism to catch a rejected Promise returned by an async handler — without wrapping it (or using Express 5, which added native support), the rejection is silently lost and the request simply hangs with no response and no error ever reaching next().