Node.js Error Handling Patterns That Save Production Debugging
Poor error handling is the #1 cause of production outages. This guide covers patterns for synchronous errors, promise rejections, uncaught exceptions, and building resilient APIs with proper error responses.

Errors are inevitable. How you handle them separates production-grade code from prototypes. Node.js has unique error handling challenges: asynchronous operations, event emitters, and the fact that unhandled rejections can crash your process. This guide provides battle-tested patterns from high-scale applications.
The Three Types of Node.js Errors
Understanding error types is the first step:
- Operational errors: Failed database connections, invalid user input, network timeouts — predictable and recoverable.
- Programmer errors: Null reference, type mismatch — bugs that should crash and be fixed in code.
- System errors: Out of memory, file descriptor limit — require process restart.
Pattern #1: Centralized Error Handler Middleware (Express/Fastify)
// Custom error classes
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
this.name = ‘ValidationError’;
}
}
// Async wrapper to avoid try-catch in every route
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Route using asyncHandler
app.get(‘/users/:id’, asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new AppError(‘User not found’, 404);
res.json(user);
}));
// Central error handler (must be last middleware)
app.use((err, req, res, next) => {
const { statusCode = 500, message, isOperational } = err;
// Log full error internally
console.error(err);
// Send safe response to client
res.status(statusCode).json({
error: isOperational ? message : ‘Internal server error’,
…(process.env.NODE_ENV === ‘development’ && { stack: err.stack })
});
});
Pattern #2: Handling Async/Await Errors Without Try-Catch Spam
Use a Go-style error handling utility:
function to(promise) {
return promise
.then(data => [null, data])
.catch(err => [err, null]);
}
// Usage
const [err, user] = await to(db.findUser(id));
if (err) {
if (err.code === ‘NOT_FOUND’) {
return res.status(404).json({ error: ‘User not found’ });
}
throw err; // Let global handler catch
}
Pattern #3: Graceful Shutdown on Fatal Errors
Handle uncaughtException and unhandledRejection without letting the process hang:
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Perform cleanup (close servers, write logs)
gracefulShutdown(1);
});
process.on(‘unhandledRejection’, (reason, promise) => {
console.error(‘Unhandled Rejection at:’, promise, ‘reason:’, reason);
gracefulShutdown(1);
});
function gracefulShutdown(exitCode) {
server.close(() => {
console.log(‘HTTP server closed’);
process.exit(exitCode);
});
// Force exit after 10 seconds
setTimeout(() => process.exit(exitCode), 10000);
}
// You can also attach to signals
process.on(‘SIGTERM’, () => gracefulShutdown(0));
Pattern #4: Error Handling in Worker Threads
Worker threads don't share memory — errors must be propagated via message passing:
// Worker code
import { parentPort } from 'worker_threads';
try {
const result = riskyOperation();
parentPort.postMessage({ success: true, data: result });
} catch (err) {
parentPort.postMessage({ success: false, error: err.message });
}
// Main code
const worker = new Worker(‘./worker.js’);
worker.on(‘message’, (msg) => {
if (!msg.success) {
console.error(‘Worker error:’, msg.error);
// Decide to restart worker or fail operation
}
});
worker.on(‘error’, (err) => {
console.error(‘Worker thread error:’, err);
});
Pattern #5: Retry and Backoff for Transient Errors
Database connection drops, rate limits, and network blips deserve retries:
async function retryOperation(fn, maxRetries = 3, baseDelay = 100) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (err) {
const isTransient = [
'ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', '429'
].some(code => err.code === code || err.statusCode === code);
if (!isTransient || i === maxRetries - 1) throw err;
const delay = baseDelay * Math.pow(2, i) + Math.random() * 100;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const data = await retryOperation(() => fetchFromUnreliableAPI());
Pattern #6: Structured Error Logging
Always log errors with context for debugging. Use a logger like Pino:
import pino from 'pino';
const logger = pino({ level: 'info' });
try {
await processPayment(orderId);
} catch (err) {
logger.error({
err: { message: err.message, stack: err.stack, code: err.code },
orderId,
userId: req.user.id,
timestamp: new Date().toISOString()
}, ‘Payment processing failed’);
throw new AppError(‘Payment failed’, 500);
}
Common Anti-Patterns to Avoid
- Empty catch blocks:
catch(err) {}swallows errors silently. Always log or rethrow. - Throwing strings: Always throw
Errorobjects (or subclasses) to get stack traces. - Ignoring promise rejections: Always add
.catch()or useawaitinsideasyncfunctions. - Overly broad catch: Catching all errors and sending 500 prevents proper HTTP status codes.
Production Monitoring: Sentry or Rollbar Integration
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN });
// In error handler
app.use((err, req, res, next) => {
Sentry.captureException(err, {
extra: { userId: req.user?.id, url: req.url }
});
// … rest of error handler
});
Conclusion
Implement these patterns today: centralized error handling middleware, async wrapper to avoid repetitive try-catch, graceful shutdown for fatal errors, and structured logging with context. Your production debugging time will drop by 70%. Remember: operational errors are expected; programmer errors should crash and be fixed. Build with resilience from day one.
Comments
Join the conversation — sign in to leave a comment.