If you’ve ever chased a vague “Something went wrong” in production at 2 a.m., you know why error handling matters. The goal isn’t to stop every failure (you can’t) but to make failures boring: predictable, contained, and recoverable. With Node.js 24.x improving async context performance and stability, now’s a great time to tighten your approach. This guide is a practical playbook; less theory, more patterns you can ship.
Why error handling matters in production
Things will fail: APIs time out, inputs go sideways, databases hiccup. Good error handling turns chaos into clear signals and graceful fallbacks:
- Application stays up (or shuts down safely when it must)
- Users get helpful messages, not stack traces
- Data remains consistent
- Security posture improves (no leaking internals)
- Incidents are debuggable with context and breadcrumbs
Understanding error types in Node.js
Synchronous vs. asynchronous errors
Synchronous throws bubble immediately; async errors surface on await or promise rejection. Treat both uniformly in your handlers.
See also: A Deep Dive into the Node.js Event Loop and Node.js Timers vs process.nextTick() for how async work is scheduled and why errors surface when they do.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Synchronous error
function validateUser(user) {
if (!user.email) {
throw new Error('Email is required'); // Synchronous throw
}
}
// Asynchronous error
async function fetchUserData(userId) {
const user = await User.findById(userId);
if (!user) {
throw new Error('User not found'); // Asynchronous throw
}
return user;
}
|
Operational vs. programmer errors
Operational errors are expected (timeouts, missing files). Programmer errors are bugs (null deref, wrong types). Handle the former, fix the latter.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// Operational error (expected, should be handled gracefully)
function readConfigFile(path) {
try {
return fs.readFileSync(path, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist - operational error
return createDefaultConfig();
}
throw error; // Re-throw unexpected errors
}
}
// Programmer error (bugs in code)
function calculateDiscount(price, discount) {
// Programmer forgot to validate input
return price - (price * discount); // Could return NaN if invalid inputs
}
|
Modern patterns that work
Async/await with try/catch and selective recovery
Only catch where you can add value: translate, enrich, or recover. Otherwise, let it propagate to your global handler.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
class UserService {
async createUser(userData) {
try {
const validatedData = this.validateUserData(userData);
const user = await UserModel.create(validatedData);
// Send welcome email (might fail)
await this.sendWelcomeEmail(user.email);
return user;
} catch (error) {
// Handle specific error types
if (error.name === 'ValidationError') {
throw new AppError('Invalid user data', 400);
}
if (error.code === 'EMAIL_FAILED') {
// Log but don't fail the user creation
logger.warn('Welcome email failed', { userId: user.id, error });
}
throw new AppError('User creation failed', 500);
}
}
async sendWelcomeEmail(email) {
try {
await emailService.send({
to: email,
subject: 'Welcome!',
template: 'welcome'
});
} catch (error) {
// Wrap the error with more context
const emailError = new Error('Welcome email delivery failed');
emailError.code = 'EMAIL_FAILED';
emailError.originalError = error;
throw emailError;
}
}
}
Related: When coordinating timeouts/cancellations across async boundaries, use [AbortController and AbortSignal in Node.js](/nodejs/nodejs-async-operations-abortcontroller-abortsignal/).
|
Promise-based flows without cascading failures
1
2
3
4
5
6
7
8
9
10
11
12
13
|
function processBatch(data) {
return Promise.allSettled(
data.map(item =>
apiCall(item)
.then(processResult)
.catch(error => {
// Handle individual item failure without breaking the batch
logger.error('Item processing failed', { item, error });
return { status: 'failed', error: error.message };
})
)
);
}
|
Custom error classes keep things tidy
Give your errors structure: HTTP-ish status, operational flag, and optional details. That’s enough for consistent responses and logs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.timestamp = new Date().toISOString();
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, details = []) {
super(message, 400);
this.name = 'ValidationError';
this.details = details;
}
}
class DatabaseError extends AppError {
constructor(message, originalError) {
super(message, 503);
this.name = 'DatabaseError';
this.originalError = originalError;
}
}
// Usage
function createProduct(productData) {
if (!productData.name) {
throw new ValidationError('Product name is required', [
{ field: 'name', message: 'Name is required' }
]);
}
try {
return db.products.create(productData);
} catch (error) {
if (error.code === 'SQLITE_CONSTRAINT') {
throw new DatabaseError('Database constraint violation', error);
}
throw error;
}
}
|
Global error handling middleware
Express.js example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
// errorHandler.js
function errorHandler(err, req, res, next) {
// Log error
logger.error('Unhandled error', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent')
});
// Check if response already sent
if (res.headersSent) {
return next(err);
}
// Development vs Production error response
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode || 500).json({
error: {
message: err.message,
stack: err.stack,
statusCode: err.statusCode,
timestamp: err.timestamp
}
});
}
// Production error response (don't expose stack traces)
if (err.isOperational) {
return res.status(err.statusCode).json({
error: {
message: err.message,
statusCode: err.statusCode,
timestamp: err.timestamp
}
});
}
// Unknown errors in production
res.status(500).json({
error: {
message: 'Something went wrong!',
statusCode: 500,
timestamp: new Date().toISOString()
}
});
}
// app.js
const express = require('express');
const app = express();
// Routes
app.use('/api', require('./routes'));
// 404 handler
app.use('*', (req, res) => {
throw new AppError(`Route ${req.originalUrl} not found`, 404);
});
// Global error handler (must be last)
app.use(errorHandler);
|
Handling uncaught exceptions and unhandled rejections
Crash fast on programmer errors; shut down gracefully on fatal states. Don’t keep a poisoned process alive.
For Kubernetes-style probes and graceful shutdown patterns, see Health Checks and Graceful Shutdown of Express.js with Lightship.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
// processHandlers.js
class ProcessHandlers {
static initialize() {
// Uncaught synchronous exceptions
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
// Close server and exit process
server.close(() => {
process.exit(1);
});
// Force exit after timeout
setTimeout(() => {
process.abort(); // Generate core dump
}, 1000).unref();
});
// Unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', { reason, promise });
// Close server and exit process
server.close(() => {
process.exit(1);
});
});
// SIGTERM graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received');
server.close(() => {
logger.info('Process terminated');
});
});
}
}
module.exports = ProcessHandlers;
|
Smarter context with AsyncLocalStorage (Node.js 24.x)
Node.js 24.x makes async context propagation cheaper and more reliable. Attach request IDs, user IDs, and breadcrumbs once; read them anywhere.
Pair this with end-to-end telemetry; see Monitoring Node.js Applications in Production.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
const async_hooks = require('async_hooks');
const fs = require('fs');
class RequestContext {
static storage = new async_hooks.AsyncLocalStorage();
static run(context, callback) {
this.storage.run(context, callback);
}
static get() {
return this.storage.getStore();
}
}
// Enhanced error handler with context
function contextualErrorHandler(error) {
const context = RequestContext.get();
logger.error('Request error', {
message: error.message,
stack: error.stack,
requestId: context?.requestId,
userId: context?.userId,
url: context?.url
});
throw error;
}
// Usage in middleware
app.use((req, res, next) => {
const context = {
requestId: generateRequestId(),
userId: req.user?.id,
url: req.url,
method: req.method
};
RequestContext.run(context, () => {
next();
});
});
// In your services
async function getUserProfile(userId) {
try {
const user = await User.findById(userId);
if (!user) {
throw new AppError('User not found', 404);
}
return user;
} catch (error) {
contextualErrorHandler(error);
}
}
|
Production monitoring and logging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
// logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'user-service' },
transports: [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
handleExceptions: true
}),
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
// Error tracking integration
class ErrorTracker {
static track(error, context = {}) {
logger.error(error.message, {
error: error.stack,
...context
});
// Send to external service (Sentry, DataDog, etc.)
if (process.env.SENTRY_DSN) {
Sentry.captureException(error, { extra: context });
}
}
}
module.exports = { logger, ErrorTracker };
|
Deep dive: Monitoring Node.js Applications.
Putting it together: a small e‑commerce flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
class OrderService {
async createOrder(orderData, userId) {
const context = RequestContext.get();
try {
// Validate order data
this.validateOrderData(orderData);
// Check inventory
await this.checkInventory(orderData.items);
// Process payment
const payment = await this.processPayment(orderData.payment);
// Create order
const order = await Order.create({
...orderData,
userId,
paymentId: payment.id,
status: 'confirmed'
});
// Update inventory
await this.updateInventory(orderData.items);
// Send confirmation
await this.sendOrderConfirmation(userId, order);
return order;
} catch (error) {
// Contextual error handling
ErrorTracker.track(error, {
userId,
orderData,
requestId: context?.requestId
});
// Handle specific business logic errors
if (error instanceof InventoryError) {
throw new AppError('Some items are out of stock', 400);
}
if (error instanceof PaymentError) {
// Rollback any partial operations
await this.rollbackOrderCreation(orderData);
throw new AppError('Payment processing failed', 402);
}
// Re-throw as operational error
throw new AppError('Order creation failed', 500);
}
}
async checkInventory(items) {
for (const item of items) {
const stock = await Inventory.get(item.productId);
if (stock.quantity < item.quantity) {
throw new InventoryError(`Insufficient stock for product ${item.productId}`);
}
}
}
}
|
Testing your error handling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// errorHandling.test.js
const request = require('supertest');
const app = require('../app');
describe('Error Handling', () => {
it('should return 404 for unknown routes', async () => {
const response = await request(app)
.get('/unknown-route')
.expect(404);
expect(response.body.error.message).toBe('Route /unknown-route not found');
});
it('should handle validation errors gracefully', async () => {
const response = await request(app)
.post('/api/users')
.send({}) // Empty payload
.expect(400);
expect(response.body.error.message).toContain('Validation failed');
});
it('should handle database errors', async () => {
// Mock database failure
jest.spyOn(UserModel, 'create').mockRejectedValue(
new DatabaseError('Connection failed')
);
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test User' })
.expect(503);
expect(response.body.error.message).toBe('Service temporarily unavailable');
});
});
|
Best practices (pinned)
- Use custom error classes for different error types
- Catch locally only when you add value; otherwise bubble to the global handler
- Don’t expose internals or stack traces to clients
- Centralize responses in a single error middleware
- Treat unhandled rejections and exceptions as fatal; exit gracefully
- Use structured logs with request/user context
- Test unhappy paths the same way you test happy paths
- Monitor error rates, saturation, and retry storms
- Use async context tracking for correlation
- Implement graceful shutdown procedures
Quick production checklist
- Error classes defined (
AppError, ValidationError, etc.)
- Global error middleware registered last
- Process handlers for
uncaughtException, unhandledRejection, SIGTERM
- Request ID (and user ID if available) injected via
AsyncLocalStorage
- Structured logger with JSON output in prod
- Health/readiness endpoints fail when dependencies are down
- Timeouts and retries set for all outbound calls
- Redaction in logs for secrets/PII
- Load tests include failure scenarios (timeouts, 500s, partial outages)
Common pitfalls to avoid
- Swallowing errors without logging or rethrowing
- Returning 200 with an error payload (use proper status codes)
- Mixing programmer and operational errors in the same handler
- Leaking stack traces and SQL messages in production responses
- Retrying non‑idempotent operations without safeguards
- Keeping the process alive after fatal errors
Conclusion
Effective error handling in Node.js 24.x is a deliberate, layered design: classify errors, isolate failure domains, use async-safe patterns, instrument richly, and fail gracefully. Treat every boundary; API, worker, queue, and process; as a containment line with clear recovery paths and actionable logs and metrics. Apply these practices consistently and your services will degrade predictably under stress, be simpler to debug, and earn your users’ trust.
Author
If you liked the above story, you can
to keep me energized for writing stories like this for you.