At the end of the previous lesson one loose end was left: POST /api/orders reads req.body, but req.body does not exist unless something created it first. That "something" is a middleware, and middleware is not just another Express feature: it is Express. Everything the framework does —routing, reading bodies, serving static files, handling errors— is built on the same mechanism: a list of (req, res, next) functions walked in order, passing the baton along. In this lesson we take that mechanism apart to the bottom, write Escena Viva's own middleware and compare Express's built-ins with the body.js and static.js you programmed by hand in Module 4.
Contents
- What a middleware is
- The journey of a request
next(),next(error)and not callingnext()- Types of middleware
- Registration order is execution order
- Middleware with a mount path
- Escena Viva's own middleware
- Built-in middleware:
json,urlencoded,static - Asynchronous middleware
- Middleware factories
- What a middleware is
A middleware is a function with this signature:
function myMiddleware(req, res, next) {
// It can do four things:
// 1. READ the request and the response.
// 2. MODIFY both (add properties, headers...).
// 3. SHORT-CIRCUIT: respond and end the chain.
// 4. PASS THE BATON with next(), or divert with next(error).
next();
}
// And it is registered in three ways:
app.use(myMiddleware); // for every request
app.use('/api', myMiddleware); // only under /api
app.get('/api/events', myMiddleware, list); // only on this routeThe conceptual key: a route is a middleware too. app.get('/health', handler) adds to the same stack an entry that, besides the method and the pattern, holds a function with the same signature; when Express processes a request it walks a single list where middleware and routes live together. You already did this in Module 4 without calling it that: you chained readBody before the POST /orders handler and checked the content type, all before the logic. Express turns that pattern into the framework's central mechanism.
- The journey of a request
flowchart TD
A["Incoming HTTP request"] --> B["requestId<br/>(X-Request-Id header)"]
B --> C["requestLogger<br/>(measures with res.on finish)"]
C --> D["express.json()<br/>(fills req.body)"]
D --> E{"Does any route match?"}
E -- "yes" --> F["Route middleware<br/>(validate, rate limit...)"]
F --> G["Controller"]
G --> H["res.json() → response"]
E -- "no" --> I["404 middleware"]
I --> J["Error handler<br/>(err, req, res, next)"]
F -- "next(error)" --> J
G -- "throw / rejection" --> J
D -- "malformed JSON" --> J
J --> K["Error response<br/>{ error: { code, message, status } }"]
Two paths, a normal one and an error one. A middleware can jump from the normal path to the error path at any point, and there is no going back: once inside the error handler, the remaining normal middleware no longer run.
next(), next(error) and not calling next()
next(), next(error) and not calling next()These options are the entire grammar of the mechanism.
| Action | Effect | When to use it |
|---|---|---|
next() |
Continues with the next matching entry in the stack | The middleware has done its job and the request goes on |
next(error) |
Jumps to the first error middleware | Something failed and you cannot continue |
next('router') |
Leaves the current router and continues in the parent | Rare: "this router does not handle this" |
Responding without next() |
Ends the chain there | It is the right thing in a controller or when short-circuiting |
Neither responding nor next() |
The request hangs | Never. It is always a bug |
The framework's most frustrating bug
// BAD: there is a path that neither responds nor continues.
app.use((req, res, next) => {
if (req.get('X-Api-Key')) next();
// If there is NO header, nothing happens. No response, no error, no next.
// The client waits until its timeout expires. No logs. No clues.
});
// GOOD: every path ends.
app.use((req, res, next) => {
if (!req.get('X-Api-Key')) {
next(Object.assign(new Error('The API key is missing'), { appCode: 'INVALID_PARAMETER' }));
return; // the return prevents accidentally carrying on
}
next();
});Symptoms of the bad version: curl sits there, the browser spins forever, no error shows up in the server console and the process is perfectly healthy. It is baffling precisely because there is no error: nobody has simply finished the response. To diagnose it:
// hangDetector: register it FIRST in development.
app.use((req, res, next) => {
const timer = setTimeout(() => {
if (!res.headersSent) {
console.error(`[hang] ${req.method} ${req.originalUrl} has gone 5s without responding`);
}
}, 5000);
const clear = () => clearTimeout(timer);
res.on('finish', clear);
res.on('close', clear);
next();
});That way a hung request leaves a trace on stderr with its method and its path, and all you have left to do is look at which middleware on that route has an if with no exit.
Rule: after
next(...), writereturnunless it is the last line. Callingnext()twice triggers theCannot set headers after they are sent to the clientwarning, another classic that is hard to track down.
- Types of middleware
| Type | How it is registered | Example in Escena Viva |
|---|---|---|
| Application-level / router-level | app.use(fn), app.get(path, fn, ...), router.use(fn) |
requestId, requestLogger; a rate limiter only on orderRoutes |
| Error-handling | app.use((err, req, res, next) => ...) |
The central handler in 06-07 |
| Built-in / third-party | Ships with Express or comes from an npm package | express.json, express.static; helmet, cors, morgan (06-05) |
They are all the same thing: functions in the stack. The only one distinguished by its shape is the error one, which has four parameters; Express counts the arguments (fn.length) to decide whether it is a normal or an error middleware, and that is why forgetting the fourth parameter means your handler never runs (06-07).
- Registration order is execution order
There are no priorities and no clever resolution: Express walks the stack top to bottom, in the exact order in which you registered each entry.
// BAD: the route is registered BEFORE the body reader.
app.post('/api/orders', (req, res) => {
// req.body is undefined: express.json() has not run yet.
res.json({ received: req.body.sessionId });
// TypeError: Cannot read properties of undefined (reading 'sessionId') -> 500
});
app.use(express.json());
// GOOD: cross-cutting concerns first, routes afterwards.
app.use(express.json()); // 1. fills req.body
app.post('/api/orders', (req, res) => {
res.json({ received: req.body.sessionId }); // 2. it already exists
});In the bad version the middleware is registered, yes, but after the route: since the route responds, the chain ends before reaching it.
The canonical order in createApplication(), which we will keep completing and will close in 06-05: settings (disable('x-powered-by'), set('trust proxy')), security and headers (helmet, cors), observability (requestId, requestLogger), body parsing (express.json), static files (express.static), API routes, the 404 middleware and, always last, the error handler.
- Middleware with a mount path
app.use(fn) always runs; app.use('/api', fn) only if the path starts with /api. Mounting with a prefix has a surprising effect: inside the mounted middleware, req.url loses the prefix.
Inside a middleware mounted at /api, for GET /api/events/evt-001?x=1:
| Property | Content | Use it for |
|---|---|---|
req.originalUrl |
/api/events/evt-001?x=1 (always complete) |
Logs and traces |
req.baseUrl |
/api (the mount point) |
Building absolute URLs |
req.url |
/events/evt-001?x=1 (relative to the mount) |
The router's internal logic |
req.path |
/events/evt-001 (no query) |
Path comparisons |
That rewriting is what lets src/routes/events.js declare router.get('/:id') without knowing it will be mounted at /api/events. It is the same technique express.static uses: mounted at /downloads, it looks on disk only for the relative part.
Logging tip: in
request-logger.jsalways usereq.originalUrl. Withreq.urlinside a mount, your logs will show incomplete paths and you will not be able to correlate them with what the client sees.
- Escena Viva's own middleware
src/middleware/request-id.js
It generates a unique identifier per request: the foundation of traceability, which will show up in the logs, in the error responses (06-07) and in the structured logging of Module 11.
// src/middleware/request-id.js
const { randomUUID } = require('node:crypto');
const HEADER = 'X-Request-Id';
// Assigns a unique identifier to each request. If the client (or a proxy)
// already sent a valid one it is respected, so the trace survives several services.
function requestId(req, res, next) {
const received = req.get(HEADER);
const isValid = typeof received === 'string' && /^[\w-]{8,64}$/.test(received);
const identifier = isValid ? received : randomUUID();
req.requestId = identifier;
res.locals.requestId = identifier;
res.setHeader(HEADER, identifier);
next();
}
module.exports = { requestId, REQUEST_ID_HEADER: HEADER };An important detail: the received identifier is validated. Blindly accepting a client header would allow injecting line breaks into the logs or enormous values; the regex bounds length and characters.
src/middleware/request-logger.js
It measures the real duration of every request and writes it to stderr, following the course convention.
// src/middleware/request-logger.js
// Logs method, path, status, size and duration of every request.
function createRequestLogger({ skipPaths = ['/api/health'] } = {}) {
return function requestLogger(req, res, next) {
if (skipPaths.includes(req.originalUrl)) {
next();
return;
}
// hrtime.bigint gives monotonic nanoseconds: clock changes do not affect it.
const start = process.hrtime.bigint();
const label = `${req.requestId ?? '-'} ${req.method} ${req.originalUrl}`;
// 'finish' fires when the response has been delivered in full.
res.on('finish', () => {
const ms = (Number(process.hrtime.bigint() - start) / 1e6).toFixed(1);
const bytes = res.getHeader('Content-Length') ?? 0;
console.error(`[request] ${label} ${res.statusCode} ${bytes}b ${ms}ms`);
});
// 'close' without 'finish' means the client aborted early.
res.on('close', () => {
if (!res.writableFinished) console.error(`[request] ${label} ABORTED`);
});
next();
};
}Why res.on('finish') and not measuring after next(): because next() returns control as soon as the next middleware suspends on an asynchronous operation, not when the response has been sent. The finish event of ServerResponse —the same stream object from Module 3— is the only reliable point.
src/middleware/no-cache.js
// src/middleware/no-cache.js
// Marks as non-cacheable whatever changes constantly: capacity and orders.
function noCache(req, res, next) {
const headers = { 'Cache-Control': 'no-store, no-cache, must-revalidate', Pragma: 'no-cache' };
res.set({ ...headers, Expires: '0' });
next();
}
module.exports = { noCache };
// It is mounted only where it is needed, not globally (src/routes/index.js):
api.use('/orders', noCache, orderRoutes);
api.use('/sessions', noCache, sessionRoutes); // capacity changes with every sale
api.use('/events', eventRoutes); // the catalog can indeed be cachedServing the availability of Sala Bóveda from a thirty-second cache is exactly how you sell a ticket that no longer exists.
In src/app.js they end up registered in this order: app.use(requestId) first, because everything else uses it; app.use(createRequestLogger()) second, to measure from as early as possible; and then express.json({ limit }).
- Built-in middleware
express.json() versus your body.js
It is configured with express.json({ limit: '100kb', type: 'application/json', strict: true }): limit is the equivalent of your BYTE_LIMIT, type bounds which Content-Type it parses and strict accepts only objects and arrays at the root. An honest comparison with what you wrote in Module 4:
| Aspect | Your readBody |
express.json() |
|---|---|---|
| Accumulating the stream chunks | req.on('data') and concatenating Buffers |
The same, internally |
| Size limit and the response when it is exceeded | BYTE_LIMIT and BODY_TOO_LARGE → 413 |
The limit option; an error with status: 413 and type: 'entity.too.large' |
| Malformed JSON | INVALID_JSON → 400 |
An error with status: 400 and type: 'entity.parse.failed' |
Wrong Content-Type |
UNSUPPORTED_TYPE → 415 |
It does not fail: it leaves req.body as {} |
| Encodings and the raw body | You did not cover it | charset and gzip included; a verify option for webhook signatures |
It is your own design with more edge cases covered. A body that exceeds the limit returns 413 and broken JSON returns 400; those errors reach the central handler like any other, and in 06-07 we will translate them into our format by looking at their type property. One surprising detail: if the Content-Type is not JSON, express.json() does not complain, it simply does nothing and req.body stays as {}. If you want the 415 from Module 4, you have to demand it explicitly:
// src/middleware/require-json.js
function requireJson(req, res, next) {
if (['GET', 'HEAD', 'DELETE'].includes(req.method) || req.is('application/json')) {
next();
return;
}
const message = 'Content-Type: application/json was expected';
next(Object.assign(new Error(message), { appCode: 'UNSUPPORTED_TYPE' }));
}express.urlencoded()
It parses classic HTML forms with app.use(express.urlencoded({ extended: true, limit: '20kb' })). With extended: true it uses the qs library and accepts nested structures (filter[venue]=Sala+Boveda); with false it uses the core querystring and everything stays flat. Escena Viva is a JSON API and does not need it, but it is worth knowing about in case you add a contact form.
express.static() versus your static.js + mime-types.js
app.use(express.static(configuration.publicDir, {
index: 'index.html', // what to serve at the root directory
maxAge: '1h', // Cache-Control: public, max-age=3600
etag: true, // ETag and automatic 304 (with lastModified, If-Modified-Since)
dotfiles: 'ignore', // does not serve .env or .git
fallthrough: true, // if the file does not exist, the chain continues (our own 404)
extensions: ['html'], // /contact finds contact.html
}));| What you did by hand | What express.static does |
|---|---|
resolveWithin against path traversal |
An equivalent check built in |
An extension-to-MIME map (mime-types.js) |
mime-types, with hundreds of types |
createReadStream + pipeline |
The same, with range support (Range) for video |
Hashing the content for the ETag and comparing If-None-Match |
A weak ETag from size and date (cheaper) and an automatic 304 |
| Compressing with gzip by hand | It does not do it: that is compression's job (06-05) |
What it does better than your version: HTTP ranges, dotfiles, fallthrough (which lets a nonexistent file carry on to your 404 instead of responding right there) and a list of MIME types you will never want to maintain by hand. What your version did and this one does not: compression, delegated to a dedicated middleware. It is a better separation of responsibilities than yours.
- Asynchronous middleware
A middleware can be async and in Express 5 this simply works: if getCatalog rejects (unreadable file, corrupt JSON) in async function loadCatalog(req, res, next) { req.catalog = await getCatalog(); next(); }, Express catches the rejected promise and calls next(error) for you.
In Express 4 that code left the request hanging if the promise rejected, and you had to wrap every handler with const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). If you run into asyncHandler, express-async-handler or wrapAsync in a project, you now know what they are: Express 4 leftovers that are unnecessary in Express 5. We will go deeper in 06-07.
The exception that is still yours: callbacks inside a handler. Express can only catch what the promise you return rejects with; it does not see a throw inside a setTimeout or inside a stream.on('data').
// BAD: Express cannot catch this. It takes the process down.
app.get('/bad', (req, res) => {
setTimeout(() => { throw new Error('nobody is going to catch me'); }, 100);
});
// GOOD: promisify (sleep.js from module 2) and let the rejection arrive.
app.get('/good', async (req, res) => {
await sleep(100);
throw new Error('this one does reach the error handler');
});
- Middleware factories
A fixed middleware serves one case. A factory —a function that returns a middleware— serves them all, and it is the pattern used by express.json({ limit }), helmet({ ... }) and cors({ ... }).
// src/middleware/require-header.js
// Returns a middleware that demands a header, optionally with a pattern.
function requireHeader(name, { pattern } = {}) {
// Everything expensive is computed ONCE, here, not on every request.
const normalizedName = name.toLowerCase();
return function checkHeader(req, res, next) {
const value = req.headers[normalizedName];
if (!value || (pattern && !pattern.test(value))) {
const message = `Header ${name} missing or invalid`;
next(Object.assign(new Error(message), { appCode: 'INVALID_PARAMETER' }));
return;
}
next();
};
}
module.exports = { requireHeader };
// Usage: the same middleware, configured in two different ways.
const pattern = /^(web|box-office|phone)$/;
orderRoutes.post('/', requireHeader('X-Sale-Channel', { pattern }), createOrder);
sessionRoutes.use(requireHeader('Accept'));Three concrete advantages: it is configurable without duplicating code; it does the expensive work only once (compiling regexes, reading configuration, opening resources), so the returned middleware does only the bare minimum per request; and it is testable, because in Module 9 you will be able to call requireHeader('X', {...}) and test the returned function with fake objects, without bringing up a server.
It is exactly the same pattern as the createRequestLogger(options) you already wrote in section 7. When you hesitate between exporting a middleware or a factory, export the factory: it costs one extra line and saves you a refactor.
Common Mistakes and Tips
- Not calling
next()on some path. The request hangs with no errors. Use the hang detector in development and writereturnafter everynext(...). - Calling
next()and responding as well. It triggersCannot set headers after they are sent; it usually comes from a forgottenreturn. - Registering
express.json()after the routes.req.bodywill beundefined: the number one error in your firstPOSTs. - Registering the error handler in the middle. It only catches what was registered before it; it always goes last.
- Using
req.urlin logs inside a mount. You will see paths without the prefix: usereq.originalUrl. - Global middleware that is only needed on one route.
noCacheacross the whole application destroys the static files' cache; mount each one in the narrowest scope. - Expensive work inside the middleware instead of in the factory. Compiling a regex on every request is pure waste.
- Trusting client headers without validating.
X-Request-Idcomes from outside: bound its length and characters. - Tip: a middleware must do one thing. If yours authenticates, logs and compresses, that is three, and three is the number of places where you will go looking for the bug.
Exercises
Exercise 1: a response-time header
Write src/middleware/response-time.js with a createResponseTime() factory that adds an X-Response-Time header with the milliseconds the request took. Hint: you cannot set headers in res.on('finish') because they have already been sent; you have to intercept the exact moment before by wrapping res.end.
Exercise 2: hunting the hung request
Create an application with three middleware, where the second one has a path that does not call next(). Check with curl that the request hangs, add the detector from section 3 and prove that the problem shows up on stderr with method and path.
Exercise 3: compare express.static with your version
Serve public/ with express.static configured with maxAge: '1h' and etag: true. With curl -si check: that the first request returns 200 with ETag and Cache-Control, that repeating it with -H 'If-None-Match: <etag>' returns 304 with no body, and that requesting /public/../.env does not escape the directory. Contrast the result with what your static.js did.
Solutions
Solution 1
// src/middleware/response-time.js
function createResponseTime(header = 'X-Response-Time') {
return function responseTime(req, res, next) {
const start = process.hrtime.bigint();
// We wrap res.end: it is the last instant when headers can still
// be set, because writeHead has not run yet.
const originalEnd = res.end;
res.end = function (...args) {
if (!res.headersSent) {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
res.setHeader(header, durationMs.toFixed(1));
}
return originalEnd.apply(this, args);
};
next();
};
}res.on('finish') is no good because by then the headers have already travelled over the network: setHeader would throw or be ignored. res.on('close') is even worse, because it can fire with the connection already closed. This end wrapper is the technique morgan uses internally.
Solution 2
// hang.js
const app = require('express')();
app.use(hangDetector); // the one from section 3, always first
// The culprit: it only continues if the header is there; otherwise it does nothing.
app.use((req, res, next) => {
if (req.get('X-Venue')) next();
});
app.get('/tickets', (req, res) => res.json({ venue: req.get('X-Venue') }));
app.listen(3000);curl -s --max-time 5 localhost:3000/tickets hangs and stderr shows [hang] GET /tickets; adding -H 'X-Venue: Sala Boveda' it responds {"venue":"Sala Boveda"}.
Solution 3
# 1. First request: 200 with Cache-Control: public, max-age=3600 and ETag.
curl -si localhost:3000/styles.css | head -n 8
# 2. Conditional repeat: 304 Not Modified, no body.
curl -si -H 'If-None-Match: W/"1a4-1949f2c0a10"' localhost:3000/styles.css | head -n 2
# 3. Path traversal attempt: 404, it does not escape the directory.
curl -si 'localhost:3000/../.env' | head -n 1The third case works because express.static normalizes the path and rejects anything outside the base directory, just like your resolveWithin; the difference is that this protection now lives in a package that millions of deployments exercise every day, not in twenty lines of yours.
Conclusion
Middleware is the single mechanism the whole of Express is built on: a stack of (req, res, next) functions walked in the exact order of registration, where each one reads, modifies, short-circuits or passes the baton, and where next(error) opens a second path there is no coming back from. You have seen the full journey diagram, the framework's most frustrating bug —not calling next()— and how to diagnose it, the difference between req.url, req.baseUrl and req.originalUrl when mounting with a prefix, and why registration order admits no exceptions.
Escena Viva now has its own middleware: requestId for traceability, createRequestLogger measuring with res.on('finish') and writing to stderr, noCache mounted only where capacity changes, and requireHeader as an example of a configurable factory. And you have confirmed that express.json() is your body.js with more edge cases covered, and that express.static() is your static.js plus mime-types.js with range support and dotfiles. All of them are yours so far.
In the next lesson, Essential Third-Party Middleware, we assemble the minimum kit that no API should go to production without: helmet and its security headers one by one, cors so that Escena Viva's public/app.js can call the API from another origin, morgan for HTTP logging, compression for bandwidth and express-rate-limit so a script cannot exhaust the Auditorio Ribera's capacity in ten seconds. Each one put through the audit filter you learned in Module 5, and all of them ordered in the definitive version of createApplication().
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
