Error Handling in Node.js: The Missing Guide
Most Node.js error handling is wrong. Here's how to do it right.
The Problem
// ❌ The way most people handle errors
app.get('/users/:id', async (req, res) => {
const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(user); // What if query fails? 500 with no context.
});
// ❌ The slightly better way
app.get('/users/:id', async (req, res) => {
try {
const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Something went wrong' }); // Not helpful
}
});
Layer 1: Custom Error Classes
// Base error
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Distinguish from programming errors
Error.captureStackTrace(this, this.constructor);
}
}
// Specific errors
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403, 'FORBIDDEN');
}
}
class ValidationError extends AppError {
constructor(fields) {
const message = 'Validation failed';
super(message, 422, 'VALIDATION_ERROR');
this.fields = fields; // { email: 'Invalid email', name: 'Required' }
}
}
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409, 'CONFLICT');
}
}
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('Too many requests', 429, 'RATE_LIMITED');
this.retryAfter = retryAfter;
}
}
// Usage
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // Clean and descriptive
res.json(user);
});
Layer 2: Async Error Handler
// Without this, unhandled promise rejections crash your app
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Usage — no try/catch needed in routes!
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);
}));
app.post('/users', asyncHandler(async (req, res) => {
const { error, value } = validateUser(req.body);
if (error) throw new ValidationError(error.details);
try {
const user = await User.create(value);
res.status(201).json(user);
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT') {
throw new ConflictError('Email already exists');
}
throw err; // Re-throw unexpected errors
}
}));
Layer 3: Central Error Handler
// This is the LAST middleware — catches ALL errors
app.use((err, req, res, next) => {
// Default values
err.statusCode = err.statusCode || 500;
err.code = err.code || 'INTERNAL_ERROR';
// Development: full error details
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
stack: err.stack,
...(err.fields && { fields: err.fields }),
});
}
// Production: hide internal details
if (err.isOperational) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
...(err.fields && { fields: err.fields }),
...(err.retryAfter && { retryAfter: err.retryAfter }),
});
}
// Programming errors — log but don't expose
console.error('UNEXPECTED ERROR:', err);
return res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
});
Layer 4: Unhandled Rejection Handler
// Prevent process crash from unhandled promises
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Log to your error tracking service
Sentry.captureException(reason);
// Don't crash — but log it
});
// Handle uncaught exceptions (last resort)
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
Sentry.captureException(err);
// In production: graceful shutdown
// In development: keep running for debugging
if (process.env.NODE_ENV === 'production') {
gracefulShutdown(err);
}
});
// Graceful shutdown function
function gracefulShutdown(err) {
console.error('Shutting down due to error:', err.message);
// Close database connections
if (db) db.close();
// Close server
server.close(() => {
console.log('Server closed');
process.exit(1);
});
// Force exit after 10 seconds
setTimeout(() => {
console.error('Forcing shutdown after timeout');
process.exit(1);
}, 10_000);
}
Layer 5: Logging
// Structured error logging
const logError = (err, context = {}) => {
const log = {
timestamp: new Date().toISOString(),
level: err.statusCode >= 500 ? 'error' : 'warn',
message: err.message,
code: err.code,
statusCode: err.statusCode,
path: context.path || '',
method: context.method || '',
userId: context.userId || null,
isOperational: err.isOperational || false,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
};
// Console
console.error(JSON.stringify(log));
// File
fs.appendFileSync('logs/errors.log', JSON.stringify(log) + '\n');
// External service (Sentry, DataDog, etc.)
if (err.statusCode >= 500) {
Sentry.captureException(err, { extra: context });
}
};
// Use in error middleware
app.use((err, req, res, next) => {
logError(err, {
path: req.originalUrl,
method: req.method,
userId: req.user?.id,
});
// ... send response
});
Common Mistakes
// ❌ 1. Swallowing errors silently
try {
await riskyOperation();
} catch (err) {
// empty catch block — error disappears!
}
// ✅ At minimum, log it
try {
await riskyOperation();
} catch (err) {
console.error('riskyOperation failed:', err.message);
throw err; // Or throw a wrapped error
}
// ❌ 2. Using catch only for expected errors
fetch('/api/users')
.then(r => r.json())
.then(data => processUsers(data))
.catch(err => console.log('Error!')); // Network error? Parse error? Who knows!
// ✅ Handle specific error types
fetch('/api/users')
.then(r => {
if (!r.ok) throw new AppError(`HTTP ${r.status}`, r.status);
return r.json();
})
.then(data => processUsers(data))
.catch(err => {
if (err instanceof AppError) {
showError(err.message);
} else {
showError('Network error — check your connection');
}
});
// ❌ 3. Not handling process-level errors
// Your app crashes on first unhandled rejection!
// ❌ 4. Exposing stack traces in production
res.status(500).json({ error: err.stack }); // Security risk!
// ❌ 5. Generic error messages everywhere
res.status(500).json({ error: 'Something went wrong' }); // Not actionable!
Quick Checklist
□ Custom error classes for different error types
□ asyncHandler wrapping all async routes
□ Central error handler as last middleware
□ unhandledRejection listener
□ uncaughtException listener (with graceful shutdown)
□ Structured error logging
□ No stack traces in production responses
□ No swallowed errors (empty catch blocks)
□ Different messages for operational vs programming errors
How do you handle errors in your Node.js apps? Any patterns I'm missing?
Follow @armorbreak for more Node.js content.
Top comments (0)