This article is published in English.
Building Production-Grade Error Handling in Node.js Apps
Learn how to classify Node.js errors, design a custom error hierarchy, centralize async error handling, and safeguard stack traces for production resilience.
In an earlier stage of building a resilient Node.js application, you likely organized your codebase into domain-driven modules and kept transport concerns separate from business logic. That structural discipline matters, but it won't save you if an uncaught exception or a silently rejected promise brings the process down. Because Node.js runs your application code on a single-threaded event loop, one unhandled failure can crash the entire process or leave it running in a corrupted, unpredictable state. This part focuses on building error handling and async resilience suitable for production systems.
1. Operational vs. Programmer Errors: The Fundamental Distinction
Before you write any error-handling logic, you need a clear mental model that splits errors into two categories:
┌────────────────────────┐
│ Application │
│ Encountered Error │
└───────────┬────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Operational Error │ │ Programmer Error │
├───────────────────────┤ ├───────────────────────┤
│ • Invalid Input │ │ • Syntax/Logic Bugs │
│ • Resource Not Found │ │ • Cannot read null │
│ • DB Connection Timeout│ │ • Out of memory │
│ • External API Down │ │ • Broken invariants │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
Handle Gracefully Log Stack Trace, Stop
(Return HTTP 4xx/5xx) Process & Let Orchestrator
(PM2/K8s) Restart Node
Operational Errors
Operational errors are the failures a healthy application expects to encounter from time to time. They're non-fatal events triggered by things like bad user input, a downstream API going offline, or a record that simply isn't in the database.
- What to do: catch them, translate them into appropriate status codes or domain-level responses, log them with the right severity, and let the process keep serving traffic.
Programmer Errors
Programmer errors are genuine bugs — accessing a property on undefined, passing the wrong data type into a function, or leaking memory over time.
- What to do: capture the full stack trace, alert your monitoring stack (Sentry, Datadog, etc.), shut the process down cleanly, and rely on an orchestrator such as Kubernetes, Docker Swarm, or PM2 to spin up a replacement instance. You should never try to keep serving requests after this class of error, since the in-memory state of the process can no longer be trusted.
2. Build a Standardized Custom Error Hierarchy
Plain JavaScript Error instances don't carry the metadata you need — no HTTP status code, no operational flag, no domain-specific error code. Throwing raw strings or generic new Error('Something failed') calls makes your error handling brittle and hard to reason about.
The Solution: Base AppError & Specialized Subclasses
Instead, define one extensible AppError base class that records execution context and preserves the original V8 stack trace via Error.captureStackTrace.
// shared/errors/AppError.js
/**
* Base Application Error
* All custom domain errors extend this class.
*/
class AppError extends Error {
constructor(message, statusCode = 500, errorCode = 'INTERNAL_ERROR', isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
// Retain clean stack trace in V8 engine (Node.js)
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message = 'Invalid request payload', details = []) {
super(message, 400, 'VALIDATION_ERROR', true);
this.details = details;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} was not found`, 404, 'NOT_FOUND', true);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED', true);
}
}
class SystemBugError extends AppError {
constructor(message = 'Critical system error encountered') {
// Programmer errors are marked as non-operational (isOperational = false)
super(message, 500, 'CRITICAL_BUG', false);
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
SystemBugError
};
Why This Matters in Domain Logic
With this hierarchy in place, your business services can throw clean, semantically meaningful errors without needing any awareness of HTTP or web frameworks:
// components/orders/orders.service.js
const { NotFoundError, ValidationError } = require('../../shared/errors/AppError');
async function cancelOrder({ orderId, userId }) {
const order = await orderRepo.findById(orderId);
if (!order) {
throw new NotFoundError('Order');
}
if (order.userId !== userId) {
throw new ValidationError('You do not have permission to cancel this order.');
}
if (order.status === 'SHIPPED') {
throw new ValidationError('Cannot cancel an order that has already shipped.');
}
return await orderRepo.updateStatus(orderId, 'CANCELLED');
}
3. Centralized Controller Error Wrapping & Middleware
Writing try { ... } catch (err) { next(err); } inside every single route handler produces a lot of repetitive noise, and it's easy to forget a catch block somewhere.
The Bad Way: Verbose Try-Catch Boilerplate
// orders.controller.js
async function getOrder(req, res, next) {
try {
const order = await orderService.getOrder(req.params.id);
return res.json(order);
} catch (err) {
// Repeated in every single handler!
next(err);
}
}
The Right Way: Higher-Order Async Handler
Instead, wrap your controllers in a small async utility, or lean on the native async route support available in Express 5 and later:
// shared/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// orders.controller.js
const asyncHandler = require('../../shared/utils/asyncHandler');
const orderService = require('./orders.service');
// Clean, zero try/catch boilerplate
const getOrder = asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.status(200).json({ status: 'success', data: order });
});
module.exports = { getOrder };
Central Global Error Middleware
Send every uncaught operational error through a single, centralized error-handling middleware that converts errors into a consistent JSON response format for your API.
// shared/middleware/errorHandler.js
const { logger } = require('../logger');
const { AppError } = require('../errors/AppError');
function globalErrorHandler(err, req, res, next) {
err.statusCode = err.statusCode || 500;
err.errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
// Log all errors internally
if (err.isOperational) {
logger.warn(`[Operational Error] ${err.name} (${err.errorCode}): ${err.message}`);
} else {
logger.error(`[CRITICAL PROGRAMMER ERROR] ${err.stack}`);
}
// Response for Operational Errors
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
code: err.errorCode,
message: err.message,
...(err.details && { details: err.details })
});
}
// Generic Response for Programmer/System Failures (Hide internal stacks in production)
return res.status(500).json({
status: 'error',
code: 'INTERNAL_SERVER_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred on our server.'
: err.message
});
}
module.exports = globalErrorHandler;
4. Preserving Stack Traces across Async Boundaries
One of the more subtle bugs affecting both debugging and performance in Node.js is losing the stack trace because a promise is returned inside a try/catch block or async function without being awaited first.
The Stack Trace Trap: return vs return await
In plain JavaScript, returning a promise directly from inside an async function—without awaiting it—skips over that function's async call context if the promise eventually rejects further down the chain.
Dangerous: Returning Un-awaited Promises in Try/Catch
// order.repository.js
async function findOrderById(id) {
try {
// ❌ BAD: Returning promise directly inside try block.
// If db.query fails, the catch block in THIS function will NOT execute!
return db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
Why does this cause problems? Because db.query hands back a pending promise, findOrderById returns right away, passing that pending promise up to whoever called it. By the time the promise actually rejects, the catch block defined inside findOrderById is no longer in the call stack and never runs.
Correct: Explicitly Awaiting Before Return
// order.repository.js
async function findOrderById(id) {
try {
// ✅ GOOD: Awaiting resolves or rejects WITHIN this async frame.
return await db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
As a rule of thumb: inside any async function that uses try/catch, always write return await when calling a nested asynchronous operation. This keeps the stack frame intact and guarantees that local cleanup or logging code actually executes when something fails.
5. Graceful Process Termination and Safety Nets at the Runtime Level
Node.js exposes two process-level hooks that let you react when something escapes every other safeguard: uncaughtException and unhandledRejection.
Handling Process-Level Failures
// server.js
const app = require('./app');
const { logger } = require('./shared/logger');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// 1. Intercept Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', reason);
// Trigger graceful shutdown
gracefulShutdown(1);
});
// 2. Intercept Uncaught Exceptions (Programmer Errors)
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
// Trigger graceful shutdown immediately
gracefulShutdown(1);
});
// 3. Graceful Shutdown Flow
function gracefulShutdown(exitCode = 0) {
logger.info('Closing HTTP server and cleaning up active connections...');
server.close(async () => {
try {
// Close Database Connections, Redis clients, Message Consumers
await db.disconnect();
await redis.quit();
logger.info('All database connections closed cleanly.');
process.exit(exitCode);
} catch (err) {
logger.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force shutdown after 10 seconds if connections refuse to close
setTimeout(() => {
logger.error('Forced shutdown due to timeout.');
process.exit(1);
}, 10000);
}
Resilience Audit Checklist
Before moving to testing strategy, verify your codebase against these points:
- Error classification: do you clearly separate operational failures from programmer bugs?
- Structured errors: do you throw typed
AppErrorinstances carrying status codes and an operational flag, rather than plain strings? - Centralized handling: are controllers wrapped in an async handler that forwards failures to one shared error middleware?
- Stack integrity: do you use
return awaitinsidetry/catchblocks so async stack frames stay intact? - Shutdown discipline: does your app listen for
uncaughtExceptionandunhandledRejection, close database connections, and exit cleanly so the process manager can restart it?