(err, req, res, next) as the last middleware to catch and process all errors from routes and upstream middleware. Errors are forwarded to it by calling next(err) from any route or middleware, keeping individual handlers clean and focused on business logic. Never expose stack traces or detailed error messages to clients in production.
// Route that throws
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err); // forward to error handler
}
});
// Centralized error handler
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({ error: err.message });
});
Why it matters: A centralized error handler is the most important error pattern in any Express app; without it, uncaught async errors return empty responses or crash the server.
Real applications: REST APIs, e-commerce back-ends, and SaaS services all use centralized error middleware to return standardized error JSON and log exceptions to monitoring systems.
Common mistakes: Placing the error handler before routes means it never runs; it must be the last app.use() call. Also, forgetting the fourth parameter (err) makes Express treat it as regular middleware and skip it entirely.
isOperational flag on custom error classes.
// Operational — handle it
if (!user) return res.status(404).json({ error: 'User not found' });
// Programmer — bug, should not happen
const name = user.profile.name; // TypeError if user is null
Why it matters: The operational vs programmer error distinction is a foundational Node.js concept from the Node.js Error Handling Best Practices guide and a frequent interview topic.
Real applications: Production Node.js services use this distinction to decide between returning a 4xx/5xx response (operational) vs. logging a fatal error and restarting via PM2 (programmer).
Common mistakes: Catching all errors indiscriminately with try/catch and returning 500 masks programmer errors, hiding bugs that should be fixed; only operational errors should be caught and recovered from.
Error class to create custom error types that carry additional properties like statusCode and isOperational. Custom errors let the centralized handler distinguish error types and respond with appropriate HTTP status codes, and Error.captureStackTrace ensures the stack trace starts at the throw site rather than inside the Error constructor.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404);
}
}
// Usage
throw new NotFoundError('User');
Why it matters: Custom error classes make the error handler's branching logic clean and explicit, replacing fragile string-matching on error messages with class-based instanceof checks.
Real applications: REST APIs define NotFoundError, ValidationError, UnauthorizedError, and ForbiddenError classes that map directly to 404, 400, 401, and 403 responses in the central handler.
Common mistakes: Forgetting to call super(message) in the constructor means err.message is empty; and not setting this.name = this.constructor.name makes all custom errors appear as generic "Error" in logs.
uncaughtException and unhandledRejection — as a last-resort safety net for errors that escape all try/catch blocks. An uncaughtException means synchronous code threw without a handler; an unhandledRejection means a Promise rejected without a .catch(). Both indicate potentially corrupted application state, so the handler should log the error and exit; a process manager like PM2 will restart the app.
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1); // Must exit — state may be corrupt
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// Optionally exit or log and continue
});
// Graceful shutdown
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});
Why it matters: Without these handlers, unhandled errors either crash the process silently or, in older Node.js versions, leave the process in a corrupted state that produces unpredictable behavior.
Real applications: Production Node.js servers register both handlers to ensure all unexpected errors are logged to Sentry/Datadog before the process exits and PM2 restarts it.
Common mistakes: Attempting to recover and continue after an uncaughtException is dangerous — the Node.js docs explicitly warn that the process might be in an inconsistent state; always exit and restart.
Promise.resolve().catch(next), automatically forwarding any thrown error to the Express error handler without repeating try/catch in every route. This eliminates boilerplate and ensures — critically — that no async error is accidentally swallowed. Alternatively, require('express-async-errors') patches Express to do this globally.
// Async error wrapper
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Use it on routes — no try/catch needed
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
Why it matters: Forgetting try/catch in an async route in Express 4 means thrown errors are never forwarded to the error handler — the request just hangs with no response; asyncHandler prevents this entirely.
Real applications: Any Express 4 API with async route handlers needs either asyncHandler, express-async-errors, or Express 5 (which handles async natively) to reliably forward errors.
Common mistakes: Using the asyncHandler pattern but forgetting to wrap one route means that route's errors are silently swallowed; require('express-async-errors') at the top of app.js is a simpler all-in-one solution.
SIGTERM and SIGINT signals, call server.close() to stop accepting new connections, then close databases and flush logs. A force-exit timeout (e.g., 10 seconds) prevents hanging indefinitely if a request takes too long.
const server = app.listen(3000);
function shutdown(signal) {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
// Close DB connections, flush logs, etc.
mongoose.connection.close(false, () => {
process.exit(0);
});
});
// Force exit after 10s
setTimeout(() => process.exit(1), 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Why it matters: Container orchestrators (Kubernetes, ECS) send SIGTERM before killing a pod during rolling deployments; without a handler, in-flight requests get dropped and clients see 502 errors.
Real applications: Production Node.js API deployments, serverless container environments, and PM2-managed processes all rely on graceful shutdown to achieve zero-downtime updates.
Common mistakes: Not setting a force-exit timeout means a hung request (e.g., a long-running DB query) keeps the process alive indefinitely, blocking the deployment pipeline.
express-async-errors patches Express to automatically catch errors thrown inside async route handlers and forward them to the error-handling middleware, without needing a wrapper function. It works by monkey-patching Express's internal Layer.handle to wrap async functions with error catching. Just require('express-async-errors') once before defining routes and all async throws are handled.
require('express-async-errors'); // Just require it — no setup
app.get('/users', async (req, res) => {
const users = await User.find(); // If this throws,
res.json(users); // error goes to handler
});
// Errors are automatically forwarded here
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Why it matters: It's one of the most practical Express packages for eliminating async error boilerplate; understanding what it does under the hood shows mastery of Express's middleware internals.
Real applications: Any Express 4 API codebase that uses async/await in routes benefits from express-async-errors or the asyncHandler pattern to avoid silent error swallowing.
Common mistakes: Requiring express-async-errors after defining routes means already-defined routes are not patched; always require it immediately after const app = express() and before any route definitions.
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Error middleware
app.use((err, req, res, next) => {
logger.error({ message: err.message, stack: err.stack, url: req.url });
res.status(500).json({ error: 'Internal server error' });
});
Why it matters: Unstructured console.error logs are hard to search and aggregate in production; structured JSON logging with severity levels is a baseline production requirement.
Real applications: SaaS back-ends, financial APIs, and healthcare platforms pipe structured Winston/Pino logs to Datadog, Sentry, or the ELK stack for alerting and incident response.
Common mistakes: Logging the full error object (including err.stack) in the HTTP response leaks internal paths and library versions to attackers; always send a sanitized message and log details server-side only.
NotFoundError (or directly sends the response) and is placed between the last route and the main error handler. Including the requested URL and method in the response aids client-side debugging.
// All routes defined above...
app.use('/api/users', userRouter);
app.use('/api/products', productRouter);
// 404 handler — placed after all routes
app.use((req, res, next) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.method} ${req.url} not found`,
status: 404
});
});
// Error handler — placed last
app.use((err, req, res, next) => { ... });
Why it matters: Without a 404 handler, incorrect route paths either match no route and hang without a response, or fall through to the error handler and return a confusing 500 Internal Server Error.
Real applications: Well-designed REST APIs return a 404 with the attempted method and path to help API consumers quickly identify typos in endpoint URLs.
Common mistakes: Placing the 404 handler before some routes causes those routes to never be reached; the 404 middleware must come after all route and router definitions.
ValidationError, duplicate key errors (code 11000), and CastError for invalid ObjectIds — that should each be mapped to meaningful HTTP status codes in the centralized error handler. This gives API clients actionable error messages instead of generic 500 responses. Each Mongoose error type maps to a specific HTTP status: 400 for validation/cast errors and 409 for duplicates.
app.use((err, req, res, next) => {
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(400).json({ errors });
}
// Duplicate key error
if (err.code === 11000) {
return res.status(409).json({ error: 'Duplicate entry' });
}
// Invalid ObjectId
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID format' });
}
res.status(500).json({ error: 'Server error' });
});
Why it matters: Properly mapping Mongoose errors to HTTP status codes is essential for building an API that clients can reliably interact with and handle errors programmatically.
Real applications: User registration endpoints need to distinguish 400 (invalid email format) from 409 (email already exists) — both are Mongoose errors with different root causes and client-facing messages.
Common mistakes: Returning 500 for all Mongoose errors exposes that the server uses MongoDB and loses useful client feedback; always inspect err.name and err.code to return the correct 4xx code.
// API router with its own error handler
const apiRouter = express.Router();
apiRouter.get('/users', asyncHandler(getUsers));
apiRouter.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
// Web router with its own error handler
const webRouter = express.Router();
webRouter.get('/', renderHome);
webRouter.use((err, req, res, next) => {
res.status(500).render('error', { message: err.message });
});
app.use('/api', apiRouter);
app.use('/', webRouter);
Why it matters: Separating API and web error handling prevents API errors from rendering HTML error pages to JSON clients, and vice versa, ensuring each consumer gets the correct error format.
Real applications: Hybrid Node.js apps that serve both a JSON API and a server-rendered web UI use router-scoped error handlers to return JSON for /api/* and HTML pages for /*.
Common mistakes: Putting a single global error handler on app that returns JSON means any browser navigation error renders raw JSON to the user instead of a styled 404/500 page.
async function withRetry(fn, options = {}) {
const { retries = 3, delay = 1000, backoff = 2 } = options;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries) throw err;
const isTransient = err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT' ||
err.status === 503;
if (!isTransient) throw err;
const waitTime = delay * Math.pow(backoff, attempt - 1);
console.log(`Retry ${attempt}/${retries} after ${waitTime}ms`);
await new Promise(r => setTimeout(r, waitTime));
}
}
}
// Usage
const data = await withRetry(() => fetchFromAPI('/data'));
Why it matters: Transient network failures are unavoidable in distributed systems; retry logic with exponential backoff is a standard resilience pattern for any external service call.
Real applications: Payment processors, third-party email services, and external REST APIs all benefit from retry logic to handle occasional 503 responses or network timeouts without failing the user's request.
Common mistakes: Retrying without rate-limiting or jitter causes a thundering herd when many clients simultaneously retry after a service recovers; always add a small random jitter to spread retries over time.
error event listener — if an error event is emitted without a listener, Node.js throws an uncaught exception and crashes the process. This affects all EventEmitter-based objects including streams, sockets, HTTP servers, and database connections. Node.js 12+ supports the captureRejections option to handle rejected promises inside async event handlers.
const EventEmitter = require('events');
const emitter = new EventEmitter();
// MUST add error handler — without it, error events crash the process
emitter.on('error', (err) => {
console.error('Emitter error:', err.message);
});
emitter.emit('error', new Error('Something failed'));
// Streams are EventEmitters — handle their errors too
const stream = fs.createReadStream('missing-file.txt');
stream.on('error', (err) => {
console.error('Stream error:', err.message);
});
// Using captureRejections for async event handlers
const emitter2 = new EventEmitter({ captureRejections: true });
emitter2[Symbol.for('nodejs.rejection')] = (err) => {
console.error('Async rejection:', err);
};
Why it matters: Forgetting an error listener on a stream or socket is a common source of production crashes; it's a critical pattern to understand for any Node.js developer.
Real applications: File read/write streams, TCP sockets, HTTP response streams, and Redis/MongoDB connections all extend EventEmitter and require error listeners in production code.
Common mistakes: Piping streams without an error handler on the source stream — if the source emits an error, it crashes the process; always attach .on('error', handler) to every stream in a pipeline.
class CircuitBreaker {
constructor(fn, options = {}) {
this.fn = fn;
this.failures = 0;
this.threshold = options.threshold || 5;
this.timeout = options.timeout || 30000;
this.state = 'CLOSED';
this.nextAttempt = 0;
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF-OPEN';
}
try {
const result = await this.fn(...args);
this.reset();
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
throw err;
}
}
reset() { this.failures = 0; this.state = 'CLOSED'; }
}
const apiBreaker = new CircuitBreaker(fetchExternalAPI);
const data = await apiBreaker.call('/endpoint');
Why it matters: Without circuit breakers, a slow or failing downstream service causes timeouts to cascade and exhaust the thread pool of the caller, leading to cascading failures across the entire system.
Real applications: Microservice architectures, payment gateway integrations, and third-party API consumers use circuit breakers to fail fast and return a fallback response instead of waiting for timeouts.
Common mistakes: Setting the failure threshold too low triggers the circuit on normal traffic spike retries; tune the threshold and timeout based on baseline error rates and acceptable recovery windows.
error event (process failed to start), the exit event (process exited with a non-zero code), and the stderr stream (runtime output to standard error). Always handle both error and exit to prevent unhandled errors and detect failed executions. Exit code 0 means success; any non-zero code signals failure.
const { exec, spawn } = require('child_process');
// exec — captures error in callback
exec('npm test', (error, stdout, stderr) => {
if (error) {
console.error('Exit code:', error.code);
console.error('stderr:', stderr);
return;
}
console.log('stdout:', stdout);
});
// spawn — event-based error handling
const child = spawn('node', ['worker.js']);
child.on('error', (err) => {
console.error('Failed to start process:', err.message);
});
child.stderr.on('data', (data) => {
console.error('Worker error:', data.toString());
});
child.on('exit', (code, signal) => {
if (code !== 0) console.error(`Process exited with code ${code}`);
});
Why it matters: Not handling child process errors causes silent failures in background tasks like build scripts, report generators, and file converters that run as child processes.
Real applications: CI/CD runners, PDF generators, and shell command wrappers in Node.js all use child process error handling to detect failures and notify the triggering user or system.
Common mistakes: Listening to only exit gives you the code but misses the case where the process never started (e.g., command not found); always also listen to error which fires when spawn itself fails.