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

  1. What a middleware is
  2. The journey of a request
  3. next(), next(error) and not calling next()
  4. Types of middleware
  5. Registration order is execution order
  6. Middleware with a mount path
  7. Escena Viva's own middleware
  8. Built-in middleware: json, urlencoded, static
  9. Asynchronous middleware
  10. Middleware factories

  1. 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 route

The 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.

  1. 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.

  1. 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(...), write return unless it is the last line. Calling next() twice triggers the Cannot set headers after they are sent to the client warning, another classic that is hard to track down.

  1. 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).

  1. 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.

  1. 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.js always use req.originalUrl. With req.url inside a mount, your logs will show incomplete paths and you will not be able to correlate them with what the client sees.

  1. 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 cached

Serving 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 }).

  1. 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.

  1. 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');
});

  1. 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 write return after every next(...).
  • Calling next() and responding as well. It triggers Cannot set headers after they are sent; it usually comes from a forgotten return.
  • Registering express.json() after the routes. req.body will be undefined: the number one error in your first POSTs.
  • Registering the error handler in the middle. It only catches what was registered before it; it always goes last.
  • Using req.url in logs inside a mount. You will see paths without the prefix: use req.originalUrl.
  • Global middleware that is only needed on one route. noCache across 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-Id comes 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 1

The 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

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved