Key Takeaways
- Express error handling middleware uses the four-argument signature
(err, req, res, next), even when some parameters are unused. - Register error middleware after the routes and middleware whose errors it should handle—not necessarily after every route in your application.
- Express 5 forwards rejected promises returned by handlers automatically; Express 4 needs explicit forwarding.
- Validate error status codes, keep internal details out of responses, and delegate with
next(err)when headers have already been sent.
Express error handling middleware receives failures through next(err) and responds using the signature (err, req, res, next). Register it after the middleware and routes it should cover: Express searches forward through the middleware stack, not backward.
The core pattern is small. The important details are knowing which errors reach it, when asynchronous failures need help, and what information is safe to return.
A Runnable Express Error Handler
Use a supported Node.js release with npm installed. In a new directory, initialize the project and install Express 5; -y skips npm's initialization questionnaire.
mkdir express-errors
cd express-errors
npm init -y
npm install express@5Save the following as app.mjs. The .mjs extension explicitly identifies the file as an ECMAScript module, so you can use import without changing the package's module configuration.
import express from 'express';
const app = express();
app.get('/endpoint', (req, res, next) => {
const error = new Error('example error');
error.status = 400;
return next(error);
});
app.use((err, req, res, next) => {
console.error(err);
if (res.headersSent) {
return next(err);
}
const candidate = err?.status ?? err?.statusCode;
const status = Number.isInteger(candidate)
&& candidate >= 400
&& candidate <= 599
? candidate
: 500;
return res.status(status).json({
error: status >= 500
? 'Internal server error'
: 'Request failed.'
});
});
app.listen(3000, '127.0.0.1').on('error', (error) => {
console.error('Server failed to start:', error);
process.exitCode = 1;
});Start it with node app.mjs, then open http://127.0.0.1:3000/endpoint. This example returns status 400 and {"error":"Request failed."}; it logs the original error server-side rather than sending its message or stack to the client.
The response uses res.status() to set the status and res.json() to send JSON. The handler accepts only integer error statuses from 400 through 599, falling back to 500 otherwise. This is an application policy, not an automatic validation step Express adds to custom handlers.
It's Just Middleware: Keep All Four Parameters
Ordinary middleware typically accepts (req, res, next). Error middleware adds err as its first parameter, and Express uses the function's declared argument count to distinguish the two. Keep all four parameters even if your handler doesn't use every one.
During normal processing, Express skips error handlers. During error processing, it skips regular handlers and looks for matching error middleware. An error handler should either finish the response or pass control onward; doing neither leaves the request hanging.
If that execution model is unfamiliar, start with our guide to writing middleware for Express and Node.js.
Express Error Middleware Order: Forward, Not Backward
An error handler can handle failures from earlier middleware, even when later routes still exist. It cannot catch an error raised by a later route, because error propagation doesn't rewind the stack. Path matching and router mounting also determine which handlers are eligible.
A practical arrangement is:
request
|
v
parsers and other middleware
|
v
routes and mounted routers
| next(err)
v
error logger
| next(err)
v
error response handler
| next(err), if response already started
v
Express's built-in final handlerYou can put specialized error middleware inside a router after its routes, then register an application-wide handler after mounting that router. Multiple error handlers run in registration order only when control reaches them: a logger must call next(err) to reach the responder. Calling next() without the error instead resumes normal processing from that point.
The next Function: Normal Flow, Errors, and Route Skipping
These calls mean different things:
next() -> continue normal middleware
next(error) -> skip regular handlers; find error middleware
next('route') -> skip remaining callbacks in the current route
next('router') -> exit the current routernext('route') applies to callbacks registered through app.METHOD() or router.METHOD(), such as app.get(). It skips to the next matching route rather than reporting an application failure; next('router') is another reserved routing-control value.
Prefer an Error instance for failures. Express doesn't require one, but an Error gives you a standard message and a diagnostic stack. More precisely, error flow uses a truthy value other than the reserved routing-control strings; don't use falsy values to signal failure.
Synchronous and Async Errors in Express 4 and Express 5
Both versions catch synchronous exceptions thrown while Express is executing a route or middleware function. You can throw an Error there without manually wrapping the entire handler in try/catch.
| Where the failure happens | Express 4 | Express 5 |
|---|---|---|
| Synchronous handler execution | Thrown errors reach error middleware. | Thrown errors reach error middleware. |
| A promise returned by the handler | Catch the rejection and call next(err). | Rejected promises are forwarded automatically. |
| A later callback, timer, or event listener | Forward the failure explicitly. | Forward the failure explicitly. |
The important Express 5 change is automatic rejection forwarding for promises returned by route handlers and middleware. An async function returns a promise, so errors from its awaited work reach error handling automatically.
Express 5: Await the Work
This fragment belongs before the error middleware in app.mjs. It reads the project's package.json; file-read failures and parsing exceptions reject the handler's promise.
// Fragment: add this import and route to app.mjs.
import { readFile } from 'node:fs/promises';
app.get('/package', async (req, res) => {
const text = await readFile(
new URL('./package.json', import.meta.url),
'utf8'
);
res.json(JSON.parse(text));
});Express 4: Catch and Forward
For an Express 4 application, use this alternative. Catch the failure and call next(err), or use an equivalent promise wrapper that performs that forwarding.
// Fragment: Express 4 alternative; register before error middleware.
import { readFile } from 'node:fs/promises';
app.get('/package', async (req, res, next) => {
try {
const text = await readFile(
new URL('./package.json', import.meta.url),
'utf8'
);
res.json(JSON.parse(text));
} catch (error) {
next(error);
}
});Callbacks and Event Handlers Still Need Explicit Handling
A callback runs outside the original synchronous handler execution. Forward error-first callback failures to next, and catch exceptions thrown inside that callback locally. This pattern works in both Express versions.
// Fragment: register before error middleware in app.mjs.
import { readFile as readFileCallback } from 'node:fs';
app.get('/callback-package', (req, res, next) => {
readFileCallback(
new URL('./package.json', import.meta.url),
'utf8',
(error, text) => {
if (error) return next(error);
try {
res.json(JSON.parse(text));
} catch (parseError) {
next(parseError);
}
}
);
});The same boundary applies to timers and event listeners. A surrounding try/catch won't catch an exception thrown later in a callback; catch it where it occurs. Event-based APIs may instead emit an 'error' event that needs a listener.
Return Safe Errors Without Losing Diagnostics
Separate the public response from the diagnostic record. Unexpected failures should produce a generic response while diagnostic details stay in server-side logs—not in err.message copied into a response.
- Choose statuses deliberately. Use
err.statusorerr.statusCodeas metadata, then validate the selected value before responding. Don't return 500 for every known client failure. - Approve public messages explicitly. For actionable responses, map application-owned error codes to reviewed messages. A 4xx status alone isn't permission to expose an arbitrary library error.
- Keep the response shape consistent. The example uses a single
errorfield. Larger APIs can add stable application codes without exposing implementation details. - Log carefully. Replace the example's console logging with your application's logging policy. Include useful correlation information, but redact credentials, tokens, and sensitive data from diagnostic output.
For the broader security context, see our Express application security guide.
When Headers Have Already Been Sent
res.headersSent tells you whether response headers have been sent. Once they're sent, don't attempt to replace the response with a new JSON error: delegate using return next(err). Later error handlers must respect the same guard; Express's built-in final handler closes the connection if it receives an error after response writing has started.
What Express's Default Error Handler Actually Does
Express includes a final error handler even when you don't register one. Outside production, its response body includes err.stack; with NODE_ENV=production, it omits the stack and returns HTML containing the status message. It uses err.status or err.statusCode, falling back to 500 outside the 4xx–5xx range.
Configure NODE_ENV=production in your deployment environment. Keep a custom handler when you need a consistent JSON contract and explicit disclosure rules; production mode doesn't rewrite your custom response code for you.
Common Mistakes
- Continuing after forwarding.
next(err)doesn't stop JavaScript execution. Usereturn next(err)when the current branch must end. - Sending and forwarding the same failure. Either finish the response or delegate; don't intentionally do both.
- Expecting unmatched routes to throw. An unmatched route isn't automatically an error. Add ordinary 404 middleware after your routes, or explicitly create and forward a 404 error if you want the shared error responder to format it.
- Testing only the happy path. Exercise synchronous throws, rejected promises, callback failures, invalid status metadata, and failures after headers are sent.
FAQ
What is the correct signature for Express error-handling middleware?
The signature is (err, req, res, next), with all four parameters declared even when some are unused. Register the handler after the middleware and routes whose errors it should handle.
What is the difference between next() and next(err)?
next() continues normal middleware; next(err), with an Error instance, skips regular middleware and enters error handling. next('route') skips the remaining callbacks for the current route when used in app.METHOD() or router.METHOD() callbacks; next('router') exits the current router.
Can I have multiple error-handling middleware?
Yes. Express reaches eligible error handlers in registration order, but a handler that only logs an error must call next(err) to reach the next one. A handler that finishes the response normally shouldn't delegate that same error afterward.
How do Express 4 and Express 5 handle async errors?
Express 5 forwards rejections from promises returned by route handlers and middleware, including async functions. In Express 4, catch those failures and call next(err), or use an equivalent wrapper. Neither version automatically catches exceptions thrown later in detached callbacks.
What happens without custom error-handling middleware?
Express's built-in final handler processes unhandled request errors. Outside production it includes the error stack; with NODE_ENV=production it returns an HTML status-message response without the stack. If response writing has already started, the default handler closes the connection instead.
Further Reading
- Express 5 error handling guide — promise forwarding, custom handlers, and the built-in fallback.
- Express 4 error handling guide — explicit forwarding for asynchronous failures.
- Express middleware guide — application middleware, routers, and routing-control values.
- Express 5 migration guide — changes to rejected-promise handling.
- Express response API — headersSent, status, and JSON responses.
- Node.js error documentation — exceptions, callback errors, and error events.
- OWASP error handling guidance — generic public responses and private diagnostics.
- OWASP logging guidance — useful event context and sensitive-data exclusions.
