Node.js Error Handling Best Practices 2026: Complete Guide
Node.js Error Handling Best Practices 2026: Complete Guide
Node.js applications fail silently more often than developers realize. Unhandled promise rejections, swallowed callback errors, and missing global safety nets crash production servers without a single log line — and this guide shows you exactly how to stop that from happening.
Understanding Node.js Error Types and Stack Traces

Operational vs. Programmer Errors: When to Recover vs. When to Crash
Most error handling mistakes come from treating all errors the same. There are two distinct categories, and confusing them leads to wrong recovery strategies.
Operational errors are expected runtime failures: network timeouts, invalid user input, database connection drops. Recover from these gracefully. Programmer errors are bugs: null dereferences, wrong argument types, logic failures. Don't try to recover — crash and fix the code.
// WRONG: Treating a programmer error like an operational one
function getUserById(id) {
try {
return users[id].profile; // TypeError if users[id] is undefined
} catch (err) {
return null; // Silently swallowing a bug
}
}
// RIGHT: Validate input (operational), let programmer errors surface
function getUserById(id) {
if (!id || typeof id !== 'string') {
throw new ValidationError('Invalid user ID format', { id });
}
const user = users[id];
if (!user) {
throw new NotFoundError('User not found', { id });
}
return user.profile;
}
Reading Node.js Error Objects and Stack Traces
The built-in Error object gives you message, stack, name, and for system errors, code and errno. The stack trace is your fastest path to the bug — but only if you enrich it with context.
class AppError extends Error {
constructor(message, options = {}) {
super(message);
this.name = this.constructor.name;
this.code = options.code || 'INTERNAL_ERROR';
this.statusCode = options.statusCode || 500;
this.context = options.context || {};
this.isOperational = options.isOperational ?? true;
Error.captureStackTrace(this, this.constructor);
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
context: this.context,
};
}
}
class ValidationError extends AppError {
constructor(message, context) {
super(message, { code: 'VALIDATION_ERROR', statusCode: 400, context });
}
}
class NotFoundError extends AppError {
constructor(message, context) {
super(message, { code: 'NOT_FOUND', statusCode: 404, context });
}
}
Global Safety Nets: Uncaught Exceptions and Unhandled Rejections
These handlers are your last line of defense, not your primary strategy. Use them to log the error, notify monitoring, and shut down cleanly — never to continue running a corrupted process.
// WRONG: Trying to keep the server alive after an uncaught exception
process.on('uncaughtException', (err) => {
console.error('Something went wrong, continuing...', err);
});
// RIGHT: Log, notify, degrade health check, then exit
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception — shutting down');
healthCheck.setUnhealthy();
// Give logger time to flush, then exit
setTimeout(() => process.exit(1), 500);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error({ reason, promise }, 'Unhandled promise rejection');
// In Node.js 18+, this exits automatically — match that behavior
process.exit(1);
});
Modern Async Error Handling Patterns
Try-Catch with Async/Await: Gotchas That Bite in Production
The most common mistake is wrapping everything in one giant try-catch and losing the ability to differentiate error types.
// WRONG: One catch block, no discrimination
async function createOrder(userId, items) {
try {
const user = await db.findUser(userId);
const inventory = await db.checkInventory(items);
const order = await db.createOrder(user, inventory);
await emailService.sendConfirmation(order);
return order;
} catch (err) {
// Is this a DB error? Email error? Validation error? No idea.
res.status(500).json({ error: 'Something failed' });
}
}
// RIGHT: Granular handling with finally for cleanup
async function createOrder(userId, items) {
let order = null;
try {
const user = await db.findUser(userId);
const inventory = await db.checkInventory(items);
order = await db.createOrder(user, inventory);
} catch (err) {
if (err instanceof NotFoundError) throw err;
if (err.code === 'INSUFFICIENT_INVENTORY') {
throw new AppError('Items unavailable', { statusCode: 409, code: 'INVENTORY_ERROR' });
}
throw new AppError('Order creation failed', { statusCode: 500, context: { userId } });
} finally {
// Cleanup runs whether or not an error occurred
await db.releaseConnection();
}
// Email failure shouldn't fail the order — handle separately
emailService.sendConfirmation(order).catch(err =>
logger.warn({ err, orderId: order.id }, 'Confirmation email failed')
);
return order;
}
Promise Chains and Promise.allSettled() for Partial Failures
Promise.all() fails fast — one rejection kills the whole batch. Promise.allSettled() is the 2026 standard for operations where partial success is acceptable.
// WRONG: Promise.all() — one failure loses all results
const results = await Promise.all(userIds.map(id => fetchUser(id)));
// RIGHT: Promise.allSettled() — inspect each result individually
const settlements = await Promise.allSettled(userIds.map(id => fetchUser(id)));
const users = [];
const failures = [];
for (const [index, result] of settlements.entries()) {
if (result.status === 'fulfilled') {
users.push(result.value);
} else {
failures.push({ userId: userIds[index], error: result.reason.message });
logger.warn({ userId: userIds[index], err: result.reason }, 'User fetch failed');
}
}
return { users, failures };
Modernizing Callback-Based APIs
Legacy Node.js code uses error-first callbacks. Don't rewrite everything — use util.promisify to wrap them and handle errors in one consistent style.
const { promisify } = require('util');
const fs = require('fs');
// Legacy callback style
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) return handleError(err);
processData(data);
});
// Promisified — now works with async/await
const readFile = promisify(fs.readFile);
async function loadConfig() {
try {
const data = await readFile('config.json', 'utf8');
return JSON.parse(data);
} catch (err) {
if (err.code === 'ENOENT') {
logger.warn('Config file missing, using defaults');
return DEFAULT_CONFIG;
}
throw err;
}
}
For modern code, prefer fs.promises directly. See the Node.js official fs.promises docs for the full API.
Structured Logging and Error Monitoring

Structured Logging with Pino and Correlation IDs
Plain-text logs are useless at scale. JSON structured logs let you query, filter, and correlate across distributed services. Correlation IDs are non-negotiable for microservices debugging.
const pino = require('pino');
const { randomUUID } = require('crypto');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label }),
},
});
// Express middleware: inject correlation ID into every request
function correlationMiddleware(req, res, next) {
req.correlationId = req.headers['x-correlation-id'] || randomUUID();
req.log = logger.child({ correlationId: req.correlationId, path: req.path });
res.setHeader('x-correlation-id', req.correlationId);
next();
}
// Log errors with full context
function logError(err, req) {
const logData = {
err: { message: err.message, code: err.code, stack: err.stack },
correlationId: req?.correlationId,
userId: req?.user?.id,
context: err.context || {},
};
if (err.isOperational) {
logger.warn(logData, 'Operational error');
} else {
logger.error(logData, 'Unexpected error');
}
}
Error Monitoring with Sentry and Error Budgets
Logging tells you what happened. Monitoring tells you how often and whether it's getting worse. Error budgets — borrowed from SRE practice — define how much failure is acceptable before paging someone at 2am.
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.2, // Sample 20% of transactions
beforeSend(event, hint) {
const err = hint.originalException;
// Don't report expected operational errors to reduce noise
if (err?.isOperational && err?.statusCode < 500) return null;
return event;
},
});
// Enrich Sentry events with business context
function captureError(err, context = {}) {
Sentry.withScope((scope) => {
scope.setTag('error_code', err.code);
scope.setContext('business_context', context);
if (context.userId) scope.setUser({ id: context.userId });
Sentry.captureException(err);
});
}
Resilience Patterns and Express Middleware

Retry Logic with Exponential Backoff
Transient failures — database blips, third-party API hiccups — are operational errors you should retry. Retry immediately once, then back off exponentially with jitter to avoid thundering herds.
async function withRetry(fn, options = {}) {
const { maxAttempts = 3, baseDelayMs = 100, shouldRetry } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const isLastAttempt = attempt === maxAttempts;
const retryable = shouldRetry ? shouldRetry(err) : err.isOperational;
if (isLastAttempt || !retryable) throw err;
const jitter = Math.random() * 100;
const delay = baseDelayMs * Math.pow(2, attempt - 1) + jitter;
logger.warn({ attempt, delay, err: err.message }, 'Retrying after failure');
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const user = await withRetry(() => db.findUser(userId), {
maxAttempts: 3,
shouldRetry: (err) => err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT',
});
Express Error Handling Middleware
Express error middleware must have exactly four parameters. One centralized handler beats scattered res.status(500) calls across every route. See our guide on Express middleware patterns for more on structuring middleware stacks.
// Must be registered AFTER all routes
function globalErrorHandler(err, req, res, next) {
logError(err, req);
if (!err.isOperational) {
captureError(err, { userId: req.user?.id, correlationId: req.correlationId });
}
const statusCode = err.statusCode || 500;
// Never expose stack traces or internal details to clients
const response = {
error: {
code: err.isOperational ? err.code : 'INTERNAL_ERROR',
message: err.isOperational ? err.message : 'An unexpected error occurred',
correlationId: req.correlationId,
},
};
res.status(statusCode).json(response);
}
// Async route wrapper — eliminates try-catch boilerplate in every route
const asyncRoute = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Usage
router.get('/users/:id', asyncRoute(async (req, res) => {
const user = await getUserById(req.params.id);
res.json(user);
}));
app.use(globalErrorHandler);
For deep dives into production patterns, the Express.js official error handling guide covers the middleware execution order in detail.
| Pattern | Best For | Avoid When |
|---|---|---|
| try-catch + async/await | Sequential async operations, granular error types | Fire-and-forget side effects |
| Promise.allSettled() | Batch operations, partial failure acceptable | All-or-nothing transactions |
| Retry + backoff | Transient network/DB failures | Validation errors, auth failures |
| Global Express middleware | Centralized HTTP error responses | Internal service-to-service calls |
| process.on uncaughtException | Last-resort logging before crash | Recovering and continuing execution |
Also check out our Node.js performance patterns post — error handling overhead is a real consideration at scale.
Frequently Asked Questions
Q: Should I use try-catch or .catch() with async/await?
A: Use try-catch for async/await — mixing .catch() on awaited calls creates confusing control flow. The exception is fire-and-forget side effects like sending analytics, where you want to catch errors without blocking: trackEvent().catch(logger.warn).
Q: When should process.exit(1) be called after an uncaught exception?
A: Always, after a short flush delay. Running a Node.js process with corrupted state is worse than restarting it. In container environments, your orchestrator (Kubernetes, ECS) will restart the container automatically — that's the correct recovery mechanism.
Q: How do I handle errors in Express middleware that calls async code?
A: Wrap every async route handler with the asyncRoute utility shown above, or use a library like express-async-errors that patches Express internally. Without it, rejected promises in route handlers are silently swallowed and never reach your error middleware.
Wrap-up
Good error handling in Node.js is a stack: custom error classes give you structure, async patterns prevent silent failures, structured logging makes incidents debuggable, and resilience patterns keep transient failures from becoming outages. Start by adding the asyncRoute wrapper and the global Express error handler to your existing codebase — those two changes eliminate the most common production failure modes immediately.
References
- A comprehensive guide to error handling In Node.js - Honeybadger Developer Blog
- Node.js Error Handling Best Practices: Ship With Confidence - Stackify
- Node.js Error Handling - W3Schools
- What are the best practices for error handling and logging in a Node ...
- Checklist: Best Practices of Node.JS Error Handling - Reddit
- Node.js Error Handling Best Practices: Preventing Common Mistakes
Comments
Post a Comment