How Middleware Works in Node.js
You’ve seen app.use() scattered throughout Express codebases. You know middleware sits between requests and responses. But when something breaks in that chain—a request hangs, an error vanishes, or handlers fire in the wrong order—the mental model falls apart.
This article explains how Node.js middleware actually works: what it is, how control flows through the chain, and what changed in Express 5.
Key Takeaways
- Middleware is a framework pattern, not a Node.js feature—Express implements it on top of the core
httpmodule - Execution order matters: middleware runs in registration order, and error handlers must come last
- The
next()function controls flow; skipping it without sending a response causes requests to hang - Express 5 natively catches rejected promises from async middleware and route handlers, eliminating the need for manual wrappers
Middleware Is a Framework Pattern, Not a Node.js Feature
Node.js itself has no middleware concept. The core http module gives you a request and response object—nothing more. Middleware is a pattern that frameworks like Express implement on top of Node.js.
Express middleware follows a specific structure: functions that receive req, res, and next. The framework maintains a stack of these functions and calls them sequentially for each incoming request. This request lifecycle in Node.js frameworks is what gives middleware its power.
Other frameworks implement similar patterns differently. Koa uses an “onion model” where middleware wraps around subsequent handlers. Fastify uses hooks at specific lifecycle points. The concept transfers, but the semantics don’t.
The Request-Response Lifecycle
When a request hits an Express application, it enters a pipeline:
- Express matches the request against registered middleware and routes
- Each middleware function executes in registration order
- Control passes forward via
next()or stops when a response is sent - Error-handling middleware catches errors passed to
next(err)and errors thrown or rejected within Express-managed middleware and route handlers
The next() function is the mechanism that moves control through the chain. Call it, and Express invokes the next matching middleware. Skip it without sending a response, and the request hangs indefinitely.
Middleware Ordering Matters
Express middleware executes in the order you register it. This isn’t just a detail—it’s the foundation of middleware patterns in Node.js.
app.use(parseBody)
app.use(authenticate)
app.use(authorize)
app.get('/data', handler)
Here, parseBody runs first, making request data available to authenticate. Swap their order, and authentication breaks because the body hasn’t been parsed yet.
This ordering also affects error handling. Error-handling middleware must come after the routes it protects.
Short-Circuiting the Response
Middleware can end the request-response cycle early by sending a response without calling next(). This is how authentication middleware rejects unauthorized requests:
function requireAuth(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}
Once res.send(), res.json(), or similar methods execute, subsequent middleware won’t run for that request. This short-circuiting is intentional and useful.
Discover how at OpenReplay.com.
App-Level, Router-Level, and Route-Level Middleware
Express middleware attaches at different scopes:
App-level middleware runs for every request to the application. Use app.use() for cross-cutting concerns like logging or body parsing.
Router-level middleware runs for requests matching a specific router. This scopes middleware to route groups without affecting the entire application.
Route-level middleware runs for specific routes only. Pass middleware functions directly to route definitions for targeted behavior like validation.
The distinction is about scope, not capability. All three use the same function signature.
Error Handling and Express 5 Middleware
Express identifies error-handling middleware by its four-parameter signature: (err, req, res, next). When any middleware calls next(err) or throws an error, Express skips regular middleware and jumps to error handlers.
Express 5 changes how async errors work. In Express 4, rejected promises and async exceptions required manual handling or third-party wrappers. Express 5 natively catches rejected promises from async functions and routes them to error handlers automatically.
// Express 5: no wrapper needed
app.get('/data', async (req, res) => {
const data = await fetchData() // rejection triggers error middleware
res.json(data)
})
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message })
})
Error-handling middleware still needs to be registered last, after routes and other middleware.
Conclusion
Middleware is a framework-level pattern for processing requests through a chain of functions. Understanding the execution order, the role of next(), and where to place error handlers eliminates most middleware debugging headaches.
Express 5’s native async error handling removes a common pain point, but the fundamentals remain: register middleware in the right order, call next() or send a response, and place error handlers at the end.
FAQs
If you forget to call next() and don't send a response, the request will hang indefinitely. Express waits for either next() to be called to continue the chain, or for a response to be sent to the client. Always ensure your middleware either calls next() to pass control forward or sends a response to end the cycle.
Yes, but you must handle errors manually. Express 4 doesn't catch rejected promises automatically, so unhandled rejections won't trigger your error-handling middleware. Wrap async code in try-catch blocks or use a wrapper function that forwards errors to next(). Express 5 eliminates this requirement by catching rejections natively.
Error-handling middleware requires exactly four parameters: err, req, res, and next. If you omit any parameter, Express treats it as regular middleware and skips it during error handling. Also verify that your error handler is registered after all routes and other middleware in your application.
Both register middleware with the same function signature, but they differ in scope. app.use() attaches middleware at the application level, running for all requests. router.use() attaches middleware to a specific router instance, running only for requests that match that router's mounted path.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.