Error Handling in Node.js: The Missing Guide
Most Node.js tutorials skip error handling. Here's everything you need to know to stop your app from crashing silently.
The Golden Rule
Never let an error go unhandled. Unhandled errors in Node.js crash your process. In production, that means downtime.
Level 1: Synchronous Errors
// try/catch works for synchronous code
try {
const data = JSON.parse(input); // Can throw SyntaxError
const result = data.items.map(i => i.id); // Can throw TypeError
} catch (err) {
console.error('Parse failed:', err.message);
// Recover gracefully
}
Simple. Boring. But what about async code?
Level 2: Promise Errors
// ❌ This error is SILENTLY lost
fetch('/api/data')
.then(res => res.json())
.then(data => process(data));
// If process() throws, nobody catches it
// ✅ Always add .catch()
fetch('/api/data')
.then(res => res.json())
.then(data => process(data))
.catch(err => {
console.error('Fetch failed:', err);
// Notify monitoring, show user error, etc.
});
The async/await Version
async function loadData() {
try {
const res = await fetch('/api/data');
const data = await res.json();
return process(data);
} catch (err) {
console.error('Load failed:', err);
throw err; // Re-throw if caller needs to know
}
}
Key difference: await lets you use regular try/catch with async code. Prefer it over .then()/.catch() chains.
Level 3: Express.js Error Handling
const express = require('express');
const app = express();
// Regular middleware: errors are caught by Express
app.get('/users', async (req, res, next) => {
try {
const users = await db.query('SELECT * FROM users');
res.json(users);
} catch (err) {
next(err); // Pass to error handler
}
});
// Async wrapper (eliminates try/catch boilerplate)
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/posts', asyncHandler(async (req, res) => {
const posts = await db.query('SELECT * FROM posts');
res.json(posts);
// If this throws, asyncHandler catches and calls next(err)
}));
// Global error handler — MUST have 4 parameters
// MUST be registered AFTER all routes
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] ${err.stack}`);
const status = err.statusCode || 500;
const message = err.expose ? err.message : 'Internal server error';
res.status(status).json({
error: {
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
}
});
});
Level 4: Process-Level Safety Nets
// These catch errors that escape everything else
// They're your LAST line of defense
// 1. Unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Log to monitoring service
// Don't crash in development, do crash in production
if (process.env.NODE_ENV === 'production') {
process.exit(1);
}
});
// 2. Uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Log the error
// Then crash — state is corrupted, don't continue
// (Your process manager will restart)
process.exit(1);
});
// 3. Graceful shutdown
const server = app.listen(3000);
function shutdown(signal) {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
db.close().then(() => {
console.log('Database connections closed');
process.exit(0);
});
});
// Force exit after 10 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Level 5: Custom Error Classes
// Base application error
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(details) {
super('Validation failed', 400);
this.details = details;
}
}
class AuthError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401);
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403);
}
}
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('Too many requests', 429);
this.retryAfter = retryAfter;
}
}
// Usage:
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);
}));
Level 6: Retry Pattern
async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
delay = 1000,
backoff = 2,
retryOn = (err) => true, // Which errors to retry
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxRetries || !retryOn(err)) {
throw err;
}
const waitTime = delay * Math.pow(backoff, attempt);
console.warn(`Attempt ${attempt + 1} failed: ${err.message}. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
throw lastError;
}
// Usage:
const data = await withRetry(
() => fetch('https://api.example.com/data').then(r => r.json()),
{
maxRetries: 5,
retryOn: (err) => err.name === 'TypeError', // Only retry network errors
}
);
Level 7: Circuit Breaker
class CircuitBreaker {
constructor(fn, options = {}) {
this.fn = fn;
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000;
this.state = 'CLOSED'; // CLOSED = normal, OPEN = failing, HALF = testing
this.failures = 0;
this.nextRetry = 0;
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextRetry) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF'; // Try one request
}
try {
const result = await this.fn(...args);
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextRetry = Date.now() + this.resetTimeout;
console.error(`Circuit breaker OPEN for ${this.resetTimeout}ms`);
}
}
}
// Usage:
const breaker = new CircuitBreaker(
() => fetch('https://flaky-api.example.com/data').then(r => r.json()),
{ failureThreshold: 3, resetTimeout: 60000 }
);
try {
const data = await breaker.call();
} catch (err) {
// Return cached data or show "service unavailable"
}
The Quick Reference
| Pattern | When | Complexity |
|---|---|---|
| try/catch | Sync code | Basic |
| .catch() / await + try | Async code | Basic |
| Express error handler | HTTP APIs | Medium |
| Process event handlers | Last resort safety net | Medium |
| Custom error classes | Typed error handling | Low |
| Retry | Transient failures | Medium |
| Circuit breaker | Unstable dependencies | Medium |
What Most Tutorials Don't Tell You
process.on('uncaughtException')should crash your app. Don't try to continue — your state is corrupted. Let your process manager restart.Error objects should have
statusCodeandisOperational. This lets your error handler decide whether to alert on-call or just log.Never swallow errors.
catch (err) {}without logging is the worst thing you can do. At minimum:console.error(err).Test your error paths. Happy path tests pass easily. Error path tests find real bugs.
Errors should be actionable. "Something went wrong" helps nobody. "User 'abc' not found in database 'users'" helps everyone.
Follow @armorbreak for more production-ready Node.js patterns.
Top comments (0)