Guides expert-level Node.js error handling: Express error middleware, unhandled rejection handling, domain errors, structured logging integration, error classification, and graceful degradation.
Use when the user asks about Node.js error handling, Express error middleware, unhandled rejections, structured logging, error propagation, graceful shutdown.
Do NOT use when the user asks about JavaScript idioms (use `javascript-idioms`), Node.js async patterns (use `nodejs-async-patterns`), general error handling concepts (use language-specific skills).
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Guides expert-level Node.js error handling: Express error middleware, unhandled rejection handling, domain errors, structured logging integration, error classification, and graceful degradation.
Use when the user asks about Node.js error handling, Express error middleware, unhandled rejections, structured logging, error propagation, graceful shutdown.
Do NOT use when the user asks about JavaScript idioms (use `javascript-idioms`), Node.js async patterns (use `nodejs-async-patterns`), general error handling concepts (use language-specific skills).
Designing or refactoring error handling architecture for a Node.js application or service, including Express, Fastify, Koa, or raw http module servers
Debugging unhandled promise rejections, uncaught exceptions, or silent error swallowing in production or development
Building Express (or Fastify/Koa) error middleware and needs to understand the four-argument (err, req, res, next) signature, ordering rules, and async pitfalls
Setting up process.on('unhandledRejection') and process.on('uncaughtException') handlers with correct shutdown sequencing
Integrating structured error logging (Pino, Winston, Bunyan) with error classification, correlation IDs, and severity levels
Implementing domain-specific error classes, error codes, and typed error hierarchies for a module or package boundary
Designing graceful shutdown logic that drains in-flight requests before terminating
Classifying errors by recoverability (operational vs. programmer errors) and choosing appropriate propagation strategies
Do NOT use this skill when the user is asking about:
General JavaScript async/await patterns, Promise chaining, or EventEmitter usage -- use nodejs-async-patterns instead
JavaScript language idioms, destructuring, closures, or prototype chains -- use javascript-idioms instead
General software error handling theory not specific to Node.js -- use a language-agnostic design skill
Python, Go, Java, or other runtime error handling -- use a language-specific skill for that runtime
Client-side browser JavaScript error handling (window.onerror, error boundaries) -- use a frontend-specific skill
Database query error handling specifics (PostgreSQL error codes, MongoDB write concern errors) -- use the relevant database skill and reference this skill only for the Node.js integration layer
Process
1. Classify the Error Before Writing Any Handler
Node.js errors fall into two fundamental categories defined by Joyent's original design philosophy. Every handling decision flows from this classification.
Operational errors are expected failures at runtime: network timeouts, DNS resolution failures, ENOENT file-not-found, ECONNRESET, EACCES, HTTP 4xx/5xx from downstream services, invalid user input, out-of-disk-space. These are conditions a correct program must anticipate and handle gracefully.
Programmer errors are bugs: calling a function with wrong argument types, reading a property on undefined, off-by-one array access, unresolved promises left floating. These should never be caught and silently swallowed -- they should crash the process or at minimum emit a loud alert so the bug is found and fixed.
Apply the litmus test: "Could this happen in production even with correct code?" -- yes means operational, no means programmer error.
Never use try/catch around code specifically to suppress programmer errors. A caught TypeError: Cannot read properties of undefined that logs "something went wrong" is the worst outcome -- the bug hides.
Mark operational errors explicitly in custom error classes with this.isOperational = true. Use this flag in top-level handlers to decide whether to restart the process.
Errors from third-party modules may not follow this convention -- check if the library exposes typed errors or error codes (e.g., Axios wraps responses in AxiosError with error.response.status).
2. Design a Typed Error Hierarchy
Raw Error objects carry only a message and a stack. Build a hierarchy that carries machine-readable context without leaking implementation details.
Create a BaseError class that extends Error and sets this.name, this.isOperational, an errorCode string (e.g., 'PAYMENT_GATEWAY_TIMEOUT'), and an HTTP statusCode (e.g., 503):
Export these from a dedicated errors/ directory. Application code imports named error types -- never constructs raw new Error('...') at boundaries.
Include a structured context payload for machine-readable detail: { userId, resourceId, attemptedOperation }. This populates log fields without leaking to API consumers.
Define error codes as constants in a shared enum or object: ERROR_CODES.USER_NOT_FOUND = 'USER_NOT_FOUND'. This prevents magic strings and enables exact matching in tests and monitoring dashboards.
3. Handle Errors at the Correct Layer
Each architectural layer has a specific responsibility. Getting this wrong creates either leaky abstractions or silent swallowing.
Data/repository layer: Translate database driver errors (pgDatabaseError, Mongoose ValidationError, ECONNREFUSED on pool exhaustion) into domain DatabaseError or ValidationError types. Never let pg.DatabaseError propagate above this layer. Wrap with cause: throw new DatabaseError('User lookup failed', { cause: pgError, context: { userId } }).
Service layer: Handle business rule violations here. Throw ConflictError for duplicate resource creation. Do not catch errors you cannot meaningfully recover from -- let them propagate.
Controller/route layer: This is the last catch point before the framework error handler. In async routes, all unhandled rejections must be caught. In Express 4.x, async route handlers do NOT automatically forward errors -- use next(err) explicitly or wrap handlers.
Framework error middleware: This is the centralized point for translating domain errors to HTTP responses and emitting structured log entries. It never throws.
Process level:uncaughtException and unhandledRejection -- last resort only. These are not substitutes for proper per-layer handling.
4. Wire Up Express Error Middleware Correctly
Express error handling has specific ordering and signature requirements that cause subtle bugs when violated.
Error middleware must have exactly four parameters (err, req, res, next). Express detects this by function.length. Arrow functions that destructure or rest-collect arguments may report incorrect length -- always use named function declarations or expressions for error middleware.
Register error middleware AFTER all route definitions and non-error middleware. Registration order in Express is the execution order.
Distinguish between 404 Not Found (no route matched) and actual errors. Insert a catch-all route handler just before the error middleware:
// After all routes:
app.use((req, res, next) => {
next(newNotFoundError(`Route ${req.method}${req.path} not found`, { errorCode: 'ROUTE_NOT_FOUND' }));
});
// Error middleware last:
app.use(errorHandler);
The central errorHandler function follows this pattern:
functionerrorHandler(err, req, res, next) {
if (res.headersSent) {
// Delegate to Express default handler which closes the connectionreturnnext(err);
}
const statusCode = err.statusCode ?? 500;
const isOperational = err.isOperational ?? false;
// Structured log -- logger is Pino/Winston instance on req.log or app.locals
req.log.error({
err, // Pino serializes err.message + err.stack automaticallyerrorCode: err.errorCode,
statusCode,
requestId: req.id, // Set by express-request-id or similaruserId: req.user?.id,
}, 'Request error');
// For non-operational (programmer) errors, initiate graceful shutdownif (!isOperational) {
triggerGracefulShutdown(err);
}
res.status(statusCode).json({
error: {
code: err.errorCode ?? 'INTERNAL_ERROR',
message: isOperational ? err.message : 'An unexpected error occurred',
requestId: req.id,
},
});
}
For async route handlers in Express 4.x, create a asyncHandler wrapper:
Express 5.x (currently in release candidate as of Node.js LTS compatibility) natively handles async route errors -- next is called automatically on rejected promises. If using Express 5, the asyncHandler wrapper is unnecessary but harmless.
Never call next(err) after res.json() or res.send() -- this causes "Cannot set headers after they are sent" errors. The if (res.headersSent) guard in the error middleware is the safety net, but always structure routes to avoid this.
5. Implement Process-Level Handlers with Correct Shutdown Sequencing
Process-level handlers are safety nets, not primary error handling. But they must be implemented correctly or they create worse problems than they solve.
Set up unhandledRejection and uncaughtException before any other application code runs -- in the very first lines of your entry point:
process.on('unhandledRejection', (reason, promise) => {
// reason is the rejection value -- may not be an Error instance
logger.fatal({
err: reason instanceofError ? reason : newError(String(reason)),
promise,
}, 'Unhandled promise rejection -- initiating shutdown');
triggerGracefulShutdown(reason);
});
process.on('uncaughtException', (err, origin) => {
// origin is 'uncaughtException' or 'unhandledRejection' (Node 12+)
logger.fatal({ err, origin }, 'Uncaught exception -- initiating shutdown');
triggerGracefulShutdown(err);
});
Critical: After an uncaughtException, the process is in an undefined state. The Node.js documentation explicitly warns that continuing after uncaughtException can cause memory leaks, file descriptor leaks, and unpredictable behavior. Always exit. Calling triggerGracefulShutdown is appropriate -- but ensure the shutdown itself cannot throw.
In Node.js 15+, an unhandled rejection causes process exit by default with exit code 1. In Node.js 14 and earlier, it only emits a deprecation warning. Ensure your version-aware handler matches the runtime behavior.
Use --unhandled-rejections=throw flag (available since Node 12.0) in Node.js 14 and earlier to opt into the Node.js 15+ behavior explicitly.
The graceful shutdown function:
let isShuttingDown = false;
asyncfunctiontriggerGracefulShutdown(err) {
if (isShuttingDown) return; // Prevent re-entrant shutdown
isShuttingDown = true;
logger.info('Graceful shutdown initiated');
// Stop accepting new connections
server.close(async () => {
try {
awaitPromise.all([
db.pool.end(), // Close database pool
redisClient.quit(), // Close Redis connection// ... other resource cleanup
]);
logger.info('Graceful shutdown complete');
process.exit(err ? 1 : 0);
} catch (cleanupErr) {
logger.error({ err: cleanupErr }, 'Error during cleanup -- forcing exit');
process.exit(1);
}
});
// Force exit if graceful shutdown takes too long (10 seconds is a common threshold)setTimeout(() => {
logger.error('Graceful shutdown timeout -- forcing exit');
process.exit(1);
}, 10_000).unref(); // .unref() prevents the timer from keeping the process alive
}
Wire shutdown to SIGTERM and SIGINT (for Ctrl-C in development and container orchestrator termination in production):
Kubernetes sends SIGTERM and then SIGKILL after a configurable grace period (default 30 seconds). Your shutdown timeout should be shorter than the pod's terminationGracePeriodSeconds.
6. Integrate Structured Logging with Error Context
Errors are only actionable if log records contain enough context to diagnose and reproduce.
Use Pino as the preferred logger -- it is 5-8x faster than Winston for high-throughput services due to its JSON serialization strategy and avoidance of synchronous string formatting. For teams that need Winston's transport ecosystem, it remains a valid choice.
Configure Pino's built-in err serializer, which automatically captures message, stack, type, and any enumerable properties on the error object:
Always log errors with logger.error({ err }, 'Descriptive message') -- not logger.error(err.message). The structured form preserves the full error object in the JSON payload.
Attach a requestId (also called correlationId or traceId) to every log record in the request lifecycle. Use express-request-id or set req.id = crypto.randomUUID() in a first-middleware. Create a child logger per request: req.log = logger.child({ requestId: req.id, userId: req.user?.id }).
Severity mapping for errors: warn for recoverable operational errors (rate limiting, cache miss with fallback), error for operational errors that failed a request, fatal for programmer errors or process-threatening conditions.
Never log sensitive data in error context: passwords, credit card numbers, session tokens, full PII. Redact these fields at the logger configuration level using Pino's redact option: redact: ['req.headers.authorization', 'req.body.password'].
In distributed systems, propagate the trace ID through downstream HTTP calls using headers (X-Request-ID, X-Trace-ID, or OpenTelemetry traceparent). This connects log records across services.
7. Design API Error Response Contracts
The shape of error responses must be consistent and agreed upon with API consumers. Inconsistency is a primary source of client-side bugs.
Adopt a consistent error response body schema. The following schema is production-proven and compatible with most API client expectations:
{"error":{"code":"USER_NOT_FOUND","message":"No user exists with the specified identifier","requestId":"a3f9c12b-8d4e-4f2a-b1c7-0e9d5a2f8b3c","details":[{"field":"userId","issue":"Resource does not exist"}]}}
The code field is machine-readable and stable across versions. The message field is human-readable and may change. Client code should branch on code, not message.
The details array carries field-level validation errors, modeled after Google's API error design guide. This is particularly useful for 400 responses with multiple invalid fields.
Map status codes with precision:
400 -- malformed request body, type coercion failure, missing required field
401 -- authentication required or token expired (include WWW-Authenticate header)
403 -- authenticated but lacks permission (do not reveal whether the resource exists)
404 -- resource not found OR route not found
409 -- conflict (duplicate email, optimistic lock version mismatch)
422 -- request is syntactically valid but semantically invalid (business rule violation)
429 -- rate limit exceeded (include Retry-After header in seconds)
500 -- programmer error or unclassified failure (never expose internals)
502/503/504 -- upstream dependency failure (safe to retry with backoff)
For validation libraries: Zod's ZodError can be converted to the details array format; Joi's ValidationError has a details array with path and message per field. Build adapters in the error middleware to normalize these into your schema.
8. Validate and Test the Error Handling System
Error handling code is frequently untested because errors are hard to trigger deliberately. This gap causes production surprises.
Test the errorHandler middleware directly: create a mock err, req, res, next and call it. Assert on res.status() and res.json() call arguments.
Test that async route handlers propagate to error middleware: use Supertest to make a request, mock the service to throw a specific error type, and assert on the HTTP response code and body error.code.
Test the custom error classes: instantiate each, assert on isOperational, statusCode, errorCode, stack presence, and cause chaining.
Test unhandledRejection and uncaughtException handlers using process.emit('unhandledRejection', new Error('test')) in a sandboxed test context. Assert that the shutdown function is called without actually calling process.exit (mock it).
Use fault injection to verify integration: in development, instrument a middleware that randomly throws or rejects based on an environment variable (CHAOS_ERROR_RATE=0.01). This stress-tests error paths before production exposes them.
Monitor error rates in production by exposing a /metrics endpoint (Prometheus format) with counters per errorCode. A sudden spike in DATABASE_CONNECTION_FAILED errors is immediately actionable; a spike in generic 500s is not.
Output Format
When helping a user with Node.js error handling, produce output organized as follows:
## Error Handling Architecture: [Context Summary]
### Error Classification
| Error Type | Class Name | statusCode | isOperational | errorCode Example |
|--------------------|-------------------------|------------|---------------|----------------------------|
| Validation failure | ValidationError | 400 | true | INVALID_EMAIL_FORMAT |
| Auth required | AuthenticationError | 401 | true | TOKEN_EXPIRED |
| Permission denied | AuthorizationError | 403 | true | INSUFFICIENT_PERMISSIONS |
| Resource missing | NotFoundError | 404 | true | USER_NOT_FOUND |
| Business conflict | ConflictError | 409 | true | EMAIL_ALREADY_REGISTERED |
| Upstream failure | ServiceUnavailableError | 503 | true | PAYMENT_GATEWAY_TIMEOUT |
| Programmer error | (native Error) | 500 | false | (none -- crash and fix) |
### Error Class Definitions
[Concrete TypeScript or JavaScript code for BaseError and all relevant subtypes]
### Express Middleware Stack Order
[Numbered list of middleware in registration order, with notes on what each handles]
### Central Error Handler Implementation
[Complete errorHandler function code]
### Process-Level Handler Setup
[process.on('unhandledRejection') and process.on('uncaughtException') with shutdown logic]
### Graceful Shutdown Function
[Complete triggerGracefulShutdown implementation]
### Structured Log Integration
[Logger setup and per-request child logger pattern]
### API Error Response Schema
[JSON schema and example response body]
### Testing Checklist
- [ ] BaseError and subclass unit tests (isOperational, statusCode, errorCode, cause)
- [ ] errorHandler middleware unit test (mocked req/res/next)
- [ ] Async route error propagation integration test (Supertest)
- [ ] unhandledRejection handler test (sandboxed process.emit)
- [ ] Shutdown sequencing test (mock process.exit)
- [ ] 404 catch-all route test
- [ ] Sensitive field redaction test (assert logs contain no password field)
Rules
Never swallow errors silently. An empty catch (err) {} block is always wrong. At minimum, throw err to re-propagate. Silently swallowed errors create ghost failures that appear as data corruption or inconsistency rather than errors, which are orders of magnitude harder to diagnose.
Never use uncaughtException as a general-purpose error handler. It exists solely to log and initiate shutdown. Recovering application state after an uncaught exception is undefined behavior -- process memory may be corrupted, file descriptors may be leaked, and ongoing transactions may be in unknown states.
Always use Error.captureStackTrace(this, this.constructor) in custom error constructors. Without it, the stack trace includes frames from within the error constructor itself, adding noise and obscuring the actual call site.
Always preserve the original error as cause when wrapping. Throwing new DatabaseError('Failed') and discarding the original pgError destroys the ability to diagnose root causes. Use new DatabaseError('Failed', { cause: pgError }).
Never let database-layer error types (pg DatabaseError, Mongoose MongoServerError) escape their layer. These expose implementation details (table names, constraint names, driver-specific codes) to layers that should not know about them. Map them to domain error types at the repository boundary.
Always guard against res.headersSent in Express error middleware. If a route handler partially writes a response before throwing, the error middleware must delegate to next(err) rather than attempt to write another response. Failing to check this causes "ERR_HTTP_HEADERS_SENT" crashes.
Always set a shutdown timeout and call .unref() on it. A shutdown that waits indefinitely for in-flight requests will block container orchestration (Kubernetes will SIGKILL after terminationGracePeriodSeconds, but this is configurable and often misconfigured). Setting a hard timeout -- typically 5-15 seconds -- ensures deterministic shutdown. .unref() prevents the timer itself from keeping the process alive if everything else exits cleanly first.
Never log the full error object with logger.error(err.message) as a string. This discards the stack, error code, and context fields. Always log { err } as a structured field so the logger's serializer captures all properties.
Always test error handler registration order. A common mistake is registering the 404 catch-all or error middleware before some routes, causing those routes to be unreachable. Add an integration test that hits a known route and asserts it does not return 404.
Never expose internal error details in production API responses. Stack traces, SQL queries, file paths, and internal service names in API responses are security vulnerabilities (information disclosure). The isOperational flag gates message exposure: operational errors may expose their user-facing message; programmer errors return a generic message regardless of environment. Debug details belong only in server-side logs.
Edge Cases
Middleware That Calls next() After Sending a Response
This occurs when a developer calls res.json() and then returns to later logic (or a callback) that also calls next(). The result is either a duplicate response attempt or ERR_HTTP_HEADERS_SENT. Handle by treating every response-writing operation as a function exit: use return res.json(...) always, never bare res.json(...). In the error handler, the if (res.headersSent) return next(err) guard is the last line of defense but should not be relied upon as the primary mechanism.
Errors Inside Error Middleware
If the central error handler itself throws (e.g., the logger is not initialized, or the errorCode lookup fails), Express will invoke its own built-in error handler, which returns a plain text response and may expose a stack trace in development. Protect against this by wrapping the entire error handler body in a try/catch and using a fallback response:
functionerrorHandler(err, req, res, next) {
try {
// ... all normal error handling logic
} catch (handlerErr) {
// Absolute fallback -- logger may be unavailableconsole.error('Error in error handler:', handlerErr);
if (!res.headersSent) {
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } });
}
}
}
Async Errors in Non-Route Middleware
Express 4.x only automatically propagates errors to the error handler when next(err) is called. In middleware that runs outside of the route dispatch cycle (e.g., a background job, a WebSocket handler, a setInterval callback), there is no next to call. These errors must use process.on('uncaughtException') as a catch point, which means they trigger shutdown. To avoid this, wrap every background operation's top-level function in a try/catch that logs and makes a recovery decision explicitly.
Rate-Limiting and Retry-After Headers
429 Too Many Requests responses require a Retry-After header to be useful to clients. When throwing a rate limit error, include the retry delay in seconds as part of the error's context: new RateLimitError('Rate limit exceeded', { retryAfterSeconds: 60 }). The error middleware should read this value and set the header: res.set('Retry-After', String(err.retryAfterSeconds ?? 60)). Without this header, clients implementing automatic retry logic will hammer the service immediately again.
Validation Errors with Multiple Field Failures
Throwing and catching one ValidationError per field produces poor user experience -- the user fixes one error, resubmits, and discovers the next. Accumulate all validation failures before throwing. If using Zod, call schema.safeParse(data) and collect error.issues. If using Joi, pass { abortEarly: false } to schema.validate(). The error middleware then maps the array of issues to the details[] field in the response body. Test that the error response for a multi-field invalid body contains all failures in a single response.
Errors in Database Transactions
When an error occurs inside a database transaction, the error must be re-thrown after rolling back -- not swallowed after rollback. A common pattern:
Failing to re-throw after rollback causes the calling service to believe the operation succeeded, creating data consistency bugs that are extremely difficult to trace.
TypeScript Projects: Narrowing unknown in Catch Blocks
In TypeScript with useUnknownInCatchVariables: true (the default in strict mode since TypeScript 4.4), caught errors are typed as unknown, not Error. Code that does catch (err) { logger.error(err.message) } fails to compile. Always narrow before accessing error properties:
For custom error type guards: function isValidationError(err: unknown): err is ValidationError { return err instanceof ValidationError; }. Use these in middleware to extract typed properties safely.
Worker Threads and Cluster Mode
In Node.js worker_threads, unhandled errors inside a worker do not propagate to the main thread automatically -- the worker emits an 'error' event on the Worker instance. The main thread must listen: worker.on('error', (err) => { ... }). In cluster mode, unhandled exceptions in a worker process cause that worker to exit, and the master process receives a 'exit' event -- implement auto-restart logic there, not inside the worker. Never use process.on('uncaughtException') in a worker as a substitute for proper error propagation to the master.
Example
Input: "I'm building an Express REST API in TypeScript for a fintech application. We've had silent failures in production where database errors aren't reaching the error handler, and our error responses are inconsistent -- sometimes plain text, sometimes JSON. We need to fix this properly."
Output:
Error Handling Architecture: Fintech Express REST API
This diagnosis is common and has a known root cause: async route handlers in Express 4.x do not automatically propagate rejected promises to the error handler. Combined with inconsistent response formatting, the fix requires three coordinated changes.
Root Cause Diagnosis
Problem 1: Silent database errors
Express 4.x route handlers (req, res) => {} do not wrap async functions. A rejected promise from an async handler is an unhandled rejection -- it never reaches the error middleware.
Problem 2: Inconsistent response format
Without a central error handler, different routes format errors differently -- some call res.status(500).send('Error'), others call res.json({ message: err.message }). The fix centralizes all error formatting in one place.