π Middleware Architecture and Flow
Middleware is the beating heart of every Express application. Once you understand how a request threads its way through a chain of small, focused functions β and how the humble next() call steers that journey β the rest of Express suddenly makes sense. This lesson builds that mental model piece by piece.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what middleware is and describe the
(req, res, next)function signature - Trace how a request flows through the middleware chain and back out as a response
- Distinguish the three behaviours of
next():next(),next('route'), andnext(err) - Predict middleware execution order from the order of
app.use()calls and mount paths - Write error-handling middleware using the four-argument signature
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a small Express app whose middleware chain logs, tags, and conditionally short-circuits requests.
In This Lesson
What Is Middleware?
Middleware is simply a function that sits in the middle of the journey between an incoming HTTP request and the response Express sends back. Each middleware function gets a chance to inspect the request, modify it, respond to it, or hand it along to the next function in line. That's the whole idea β Express is, at its core, little more than a pipeline of middleware functions run in order.
π‘ Assembly-line analogy: Picture a request as a product moving down a factory conveyor belt. Each middleware is a specialised station: one inspects the label (logging), one attaches a part (parses the body), one checks the product is authorised to continue (authentication). The product moves from station to station until it is finished (a response is sent) or pulled off the line for repair (an error handler).
Because every middleware sees the same req and res objects, they can cooperate: an early one can attach req.user and a later one can read it. In Express, a middleware function can do any of four things:
- Execute arbitrary code (log, time, measure)
- Modify the
reqandresobjects (attach data, set headers) - End the requestβresponse cycle by sending a response
- Call
next()to pass control to the next middleware in the stack
π Key Terms
Middleware: a function with the signature (req, res, next) that runs during the requestβresponse cycle.
The stack / chain: the ordered list of middleware Express runs for a given request.
Route handler: the final middleware in a route's chain β usually the one that actually sends the response.
The Middleware Signature
Every Express middleware function shares the same shape. It receives three parameters that give it full access to the requestβresponse cycle:
// The canonical middleware signature
function logger(req, res, next) {
// 1. Do work β here we log the method and URL
console.log(`${req.method} ${req.originalUrl}`);
// 2. Hand control to the next middleware in the chain
next();
}
reqβ the request object: method, URL, headers, params, query, and (after parsing) body.resβ the response object: used to set status, headers, and send data back.nextβ a function you call to pass control onward. Forget to call it (and don't send a response) and the request hangs forever.
There is a second, special signature reserved for error-handling middleware. It takes four arguments, and Express recognises it purely by that arity:
// Error-handling middleware β note the leading `err`
function errorHandler(err, req, res, next) {
console.error(err.stack);
res.status(500).json({ error: 'Something broke!' });
}
β οΈ The four-argument rule
Express decides a function is an error handler by counting its parameters. A function declared with four parameters is treated as error-handling middleware; three or fewer is treated as regular middleware. This means you must keep the unused next in the signature even if you never call it β dropping it turns your error handler back into ordinary middleware.
The RequestβResponse Flow
Let's trace an actual request through a small stack. A logger runs first, then a body parser, then an authentication check, and finally the route handler. If authentication fails, control jumps straight to the error handler instead.
The same story told as a sequence of hand-offs between the participants:
Reading that flow in plain English:
- The client sends an HTTP request; Express hands it to the first middleware (the logger).
- The logger records the request details and calls
next(). - The body parser turns the raw body into
req.bodyand callsnext(). - The auth middleware checks credentials. On success it calls
next()to reach the route handler; on failure it callsnext(error)to jump to the error handler. - Whichever handler runs last sends a response, ending the cycle.
π‘ Real-world flow
When you open a page on a service like Netflix, your request passes through a stack that validates your session, loads your profile, checks your subscription, and only then reaches the handler that assembles your homepage. Each concern is one small middleware β easy to test, reorder, or reuse.
The Three Faces of next()
The next() function is the steering wheel of the middleware chain. Calling it with different arguments sends the request in different directions:
| Call | Effect |
|---|---|
next() | Run the next middleware in the current stack. |
next('route') | Skip the rest of this route's middleware and try the next matching route. (Only meaningful inside app.METHOD() / router handler stacks.) |
next(err) | Skip all remaining regular middleware and jump to the error-handling middleware. |
next() advances one step, next('route') skips to the next matching route, and next(err) diverts to the error handler.Here is all three in a single route so you can see them side by side:
app.get('/example',
(req, res, next) => {
console.log('First middleware');
if (req.query.skipRoute) return next('route'); // jump to the next /example route
if (req.query.error) return next(new Error('Boom')); // jump to the error handler
next(); // continue to the next middleware
},
(req, res, next) => {
console.log('Second middleware β skipped when skipRoute or error is set');
next();
},
(req, res) => {
res.send('Regular response');
}
);
// A second handler for the same path β reached only via next('route')
app.get('/example', (req, res) => {
res.send('Alternative response');
});
// Error handler β reached only via next(err)
app.use((err, req, res, next) => {
res.status(500).send(`Error response: ${err.message}`);
});
π‘ Conveyor-belt analogy: A plainnext()moves the product to the next station.next('route')is a diversion onto a different belt.next(err)is the emergency stop that routes the product to quality control.
Execution Order & Mount Paths
Middleware runs in the order it is registered. This is the single most important rule to internalise, because a body parser registered after a route can't help that route, and an auth check registered after a protected handler protects nothing.
A middleware's mount path decides which requests it applies to. With no path it runs for every request; with a path prefix it runs only for URLs beginning with that prefix:
const express = require('express');
const app = express();
// (1) No path β runs for EVERY request, first
app.use((req, res, next) => {
req.startedAt = Date.now();
next();
});
// (2) Path-scoped β runs only for URLs beginning with /api
app.use('/api', (req, res, next) => {
req.isApiRequest = true;
next();
});
// (3) No path again β runs after (1) for every request
app.use((req, res, next) => {
console.log(`So far: ${Date.now() - req.startedAt}ms`);
next();
});
app.get('/api/data', (req, res) => {
res.json({ isApiRequest: req.isApiRequest, ms: Date.now() - req.startedAt });
});
app.get('/home', (req, res) => {
res.json({ isApiRequest: req.isApiRequest || false });
});
app.listen(3000, () => console.log('http://localhost:3000'));
The stack a request runs through depends on its URL:
GET /api/dataβ (1) β (2) β (3) β handler (matches the/apimount)GET /homeβ (1) β (3) β handler (skips the/api-scoped middleware)
global timer] M1 --> P{Path starts
with /api ?} P -->|yes| M2[Middleware 2
tag as API] P -->|no| M3[Middleware 3
log elapsed] M2 --> M3 M3 --> RH{Route match} RH -->|/api/data| H1[API handler] RH -->|/home| H2[Home handler] H1 --> Res[HTTP Response] H2 --> Res
Mount paths can be more than a simple string β Express accepts a path prefix, a regular expression, or an array:
app.use('/api', apiMiddleware); // string prefix
app.use(/user/, userMiddleware); // regular expression
app.use(['/api', '/admin'], secureMiddleware); // array of paths
π‘ Application-level vs router-level
The same rules apply whether you attach middleware to the app object or to an express.Router() instance. A router is just a mini-app you can mount under a prefix β app.use('/admin', adminRouter) β so its middleware only runs for that branch of your URL space. You'll lean on routers heavily as apps grow.
Error-Handling Middleware
Errors need their own lane. When any middleware calls next(err) β or throws inside an async handler that you forward β Express skips every remaining regular middleware and looks for the first error-handling middleware (the four-argument kind). Register it last, after all routes.
// A route that forwards its error instead of crashing the process
app.get('/data', (req, res, next) => {
if (!req.query.id) {
return next(new Error('id query parameter is required'));
}
res.json({ ok: true });
});
// Central error handler β LAST, and note the four parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({ error: err.message });
});
With modern async/await handlers, wrap the body in try/catch and forward with next(err) so the central handler stays in charge:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.statusCode = 404;
throw err;
}
res.json(user);
} catch (err) {
next(err); // hand off to the central error handler
}
});
β οΈ Express 4 vs Express 5
In Express 4, a rejected promise or a synchronous throw inside an async handler is not caught automatically β you must catch and call next(err) yourself (or use a helper like express-async-errors). Express 5 improves this: it forwards rejected promises from route handlers to your error middleware automatically. Writing the explicit try/catch works correctly on both, so it's the safe habit to build.
Hands-on Exercise
ποΈ Build a Three-Stage Middleware Chain
Objective: Wire up an Express app whose middleware demonstrates logging, request tagging, and a conditional short-circuit β proving to yourself that order and next() behave exactly as described.
Requirements:
- A global logger that prints
METHOD URLand attachesreq.receivedAt = Date.now(). - A tagger mounted only on
/apithat setsreq.isApi = true. - A route
GET /api/secretthat responds with403unless the headerx-key: let-me-inis present; otherwise it returns JSON including how many milliseconds elapsed sincereceivedAt. - A central error handler as the last middleware.
π‘ Hint
Register the global logger first, then the /api tagger, then your routes, then the error handler. Read the header with req.get('x-key'). To exercise the error lane, have one branch call next(new Error('...')) and confirm it reaches the handler.
β Sample solution
const express = require('express');
const app = express();
// (1) Global logger
app.use((req, res, next) => {
req.receivedAt = Date.now();
console.log(`${req.method} ${req.originalUrl}`);
next();
});
// (2) API tagger β only for /api/*
app.use('/api', (req, res, next) => {
req.isApi = true;
next();
});
// (3) Protected route
app.get('/api/secret', (req, res, next) => {
if (req.get('x-key') !== 'let-me-in') {
return res.status(403).json({ error: 'Forbidden' });
}
res.json({
message: 'Welcome in',
isApi: req.isApi,
elapsedMs: Date.now() - req.receivedAt
});
});
// (4) Central error handler β LAST
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({ error: err.message });
});
app.listen(3000, () => console.log('http://localhost:3000'));
Test it: curl -H "x-key: let-me-in" localhost:3000/api/secret returns the JSON; without the header you get 403.
Best Practices
β Do
- Keep each middleware small and single-purpose β one concern per function.
- Always call
next()or send a response. Never both, never neither. - Register body parsers and security middleware before the routes that need them.
- Put your error handler last, and forward errors with
next(err). return next(...)so execution stops after you hand off.
β Don't
- Don't call
next()after you've already sent a response β you'll get "Cannot set headers after they are sent". - Don't do heavy synchronous work in a global middleware; it blocks every request.
- Don't rely on middleware that mutates
reqif it's registered after the handler that reads it. - Don't drop the fourth parameter from an error handler β Express will stop treating it as one.
Summary & Quiz
π Key Takeaways
- Middleware is a
(req, res, next)function; Express is a pipeline of them run in order. - A middleware can inspect/modify
req/res, end the cycle, or callnext(). next()advances,next('route')skips to the next route,next(err)diverts to the error handler.- Order and mount path determine which middleware runs and when.
- Error-handling middleware has four parameters and must be registered last.
π― Quick Quiz
Question 1: What happens if a middleware neither calls next() nor sends a response?
Question 2: How does Express recognise a function as error-handling middleware?
Question 3: A request to GET /home hits an app with a global logger, then app.use('/api', tagger), then the handler. Which run?
π Further Reading
π What's Next?
Now that you understand how the chain works, the next lesson surveys the middleware you'll reach for constantly β Express's built-in parsers and static server, plus the essential third-party packages for logging, security, and CORS.