A payment processing API crashes at 2 AM because a database connection timed out. A user uploads malformed JSON, and your server returns a generic 500 error without logging details. These scenarios aren't just hypothetical—they're system vulnerabilities caused by incomplete error handling strategies. In Node.js applications, where asynchronous operations dominate, error management requires deliberate design rather than afterthought exception wraps.
Operational Errors vs Programmer Errors: Know Your Enemy
Not all errors are created equal. Operational errors represent runtime failures your code anticipates: failed API calls, invalid user input, or exhausted database connections. These require graceful recovery. Programmer errors—like undefined variables or incorrect function parameters—signal code flaws. They should crash the process immediately.
// Operational error: Handle programmatically
try {
await connectToDatabase();
} catch (error) {
logger.error('DB connection failed', error);
retryWithBackoff();
}
// Programmer error: Crash and restart
if (typeof userId !== 'string') {
process.exit(1); // Let process manager restart the app
}
Mixing these categories leads to unstable systems. Handle operational errors, but let programmer errors terminate the process to avoid undefined states.
Async/Await: Beyond Basic Try/Catch
While try/catch
works with async functions, its misuse causes subtle failures. Consider a route handler fetching user data:
// Broken: The await is outside the try block
app.post('/user', async (req, res) => {
try {
const data = fetchData(); // Missing await!
res.json(data);
} catch (error) {
res.status(500).send('Failed');
}
});
This catches exceptions from fetchData()
only if it's synchronous. For async operations, ensure await
resides inside try
:
app.post('/user', async (req, res) => {
try {
const data = await fetchData();
res.json(data);
} catch (error) {
res.status(500).send('Failed');
}
});
Centralized Error Handling in Express
Scattering error responses across route handlers creates maintenance nightmares. Express middleware enables centralized error processing:
// Error-first middleware (note four arguments)
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
message: err.message,
code: err.code,
details: process.env.NODE_ENV === 'development' ? err.stack : undefined
}
});
});
// Route handler propagates errors via next()
app.get('/product/:id', async (req, res, next) => {
try {
const product = await Product.find(req.params.id);
if (!product) throw new AppError('Product not found', 404);
res.json(product);
} catch (error) {
next(error); // Delegates to error middleware
}
});
For async routes, avoid manual try/catch blocks by wrapping handlers with a utility like express-async-errors
:
require('express-async-errors');
app.get('/order/:id', async (req, res) => {
const order = await Order.find(req.params.id);
if (!order) throw new AppError('Order missing', 404);
res.json(order);
});
Unhandled Promise Rejections: The Silent Killers
Uncaught promise rejections terminate Node.js processes since v15. Monitor them globally:
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'Reason:', reason);
// Optionally: Exit after logging
process.exit(1);
});
In production, pair this with a process manager like PM2 or Kubernetes to auto-restart crashed instances.
Strategic Logging with Context
Basic console.error
calls lack actionable details. Enhance logs with:
- Timestamps
- Error codes
- Request IDs
- Stack traces (in non-production environments)
// Winston logger configuration example
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [new winston.transports.File({ filename: 'errors.log' })]
});
// Usage with request context
app.use((err, req, res, next) => {
logger.error({
message: err.message,
path: req.path,
method: req.method,
user: req.user?.id,
stack: err.stack
});
next(err);
});
Custom Error Classes for Richer Context
Generic Error
instances force middleware to parse messages. Extend Error
to classify issues:
class AppError extends Error {
constructor(message, statusCode, code, details) {
super(message);
this.statusCode = statusCode;
this.code = code; // Machine-readable identifier
this.details = details; // Additional context
Error.captureStackTrace(this, this.constructor);
}
}
// Usage
throw new AppError(
'Invalid subscription tier',
400,
'E_INVALID_SUBSCRIPTION',
{ userId: 123, plan: 'free' }
);
Middleware can then tailor responses based on error type and code.
Testing Error Paths: Don't Wait for Production
Use testing frameworks to validate error handling logic:
describe('User API', () => {
it('returns 404 for missing users', async () => {
const res = await request(app)
.get('/user/non-existent-id')
.expect(404);
expect(res.body.error.code).to.equal('E_NOT_FOUND');
});
});
Tools like Sinon help simulate operational errors (e.g., mocked database failures) to verify recovery paths.
Final Recommendations
- Fail fast: Crash on programmer errors; recover from operational ones
- Centralize: Use middleware or equivalent abstraction layers
- Decorate: Enrich errors with context for debuggability
- Monitor: Tools like Sentry or Datadog need structured error data
- Test: Treat error cases as first-class test scenarios
Error handling isn't about preventing failures—they're inevitable. The goal is to manage failures predictably, maintain observability, and minimize user impact. Invest in error infrastructure with the same rigor as feature development. Your midnight self will thank you.