Express Middleware

Middleware is the core mechanism that makes Express work. Every incoming request passes through a chain of functions before a response goes out, and each function in that chain — a middleware — can read the request, modify it, log it, reject it, or hand control to the next function in line. Body parsing, authentication, logging, CORS, validation, and even routing itself are all implemented internally as middleware. Once you understand the middleware chain and its next function, the rest of Express falls into place.

Overview: How Express Middleware Works

An Express application is really just an ordered stack of functions. When a request arrives, Express walks down that stack from top to bottom, calling each middleware function in the order it was registered with app.use() or a routing method such as app.get(). Under the hood, app itself is nothing more than a request handler function you could hand straight to Node’s http.createServer() — Express does not replace the built-in http module, it layers routing and middleware dispatch on top of it.

Every regular middleware function receives three arguments: the request object req, the response object res, and a next function. Inside that function, a middleware must do exactly one of three things: end the request-response cycle by calling something like res.send(), res.json(), or res.end(); pass control to the next middleware in the stack by calling next(); or hand an error to Express’s error-handling machinery by calling next(err). If a middleware does none of these, the request simply hangs — the connection stays open until the client eventually times out, because nothing ever wrote a response.

Because middleware runs in registration order, where you call app.use() matters as much as what you put inside it. A logging middleware registered first sees every request. A JSON body parser must run before any route handler that reads req.body. A catch-all 404 handler must be registered last, after every real route, or it will swallow every request before your routes ever get a chance to run.

Types of middleware

Type Registered with Purpose
Application-level app.use(), app.get(), etc. Runs for every request, or for requests matching a path/method
Router-level router.use(), router.get(), etc. Same idea, scoped to an express.Router() instance
Built-in express.json(), express.urlencoded(), express.static() Ships with Express itself, no extra install needed
Third-party e.g. cors, morgan, helmet npm packages that export a middleware function
Error-handling app.use((err, req, res, next) => {}) Identified by having exactly four parameters; only runs when next(err) is called or an async handler throws/rejects

Syntax

function middlewareName(req, res, next) {
  // read or modify req/res here
  next(); // or res.send(...), or next(err)
}

app.use(middlewareName);                    // runs for every request
app.use("/api", middlewareName);            // runs only for paths starting with /api
app.get("/path", middlewareName, handler);  // runs only for this route, before handler

// error-handling middleware must have exactly four parameters
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});
  • req, res — the same request and response objects every handler sees; a middleware can attach properties to req (like req.user) for later handlers to read.
  • next — a function supplied by Express. Calling it with no arguments moves to the next middleware; calling it with an Error jumps straight to error-handling middleware.
  • Arity matters — Express decides whether a function is regular or error-handling middleware purely by counting its declared parameters (4 means error handler).
  • Mount path — the optional first argument to app.use() restricts a middleware to requests whose path starts with that prefix.

Examples

Example 1: A simple logging middleware

Every Express project starts the same way:

npm install express

This first example logs the method, path, status code, and response time for every request that hits the server:

const express = require("express");

const app = express();

function requestLogger(req, res, next) {
  const start = Date.now();
  res.on("finish", () => {
    const ms = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
  });
  next();
}

app.use(requestLogger);

app.get("/", (req, res) => {
  res.send("Home page");
});

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

Output:

Server listening on port 3000
GET / 200 3ms

requestLogger is application-level middleware mounted with app.use(), so it runs before every route. Notice it does not log immediately — it attaches a listener on the response’s finish event, then calls next() right away so the request keeps moving. The actual log line only prints once res.send() has fully written the response, which is why the timing is accurate.

Example 2: Built-in and custom middleware working together

Real apps chain multiple middleware in front of a single route: a built-in body parser, then a custom validator.

const express = require("express");

const app = express();
app.use(express.json());

function validateUser(req, res, next) {
  const { name, email } = req.body ?? {};
  if (typeof name !== "string" || name.trim() === "") {
    return res.status(400).json({ error: "name is required" });
  }
  if (typeof email !== "string" || !email.includes("@")) {
    return res.status(400).json({ error: "a valid email is required" });
  }
  next();
}

app.post("/users", validateUser, (req, res) => {
  res.status(201).json({ message: "user created", user: req.body });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

Output:

$ curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Ada","email":"ada@example.com"}'
{"message":"user created","user":{"name":"Ada","email":"ada@example.com"}}

$ curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":""}'
{"error":"name is required"}

express.json() is built-in middleware: it reads the request body stream, parses it as JSON, and assigns the result to req.body. It must run before any route that reads req.body, or that property will be undefined. validateUser is a route-specific middleware passed as an extra argument to app.post(); because it either sends a response directly or calls next(), the real handler only ever runs with valid data.

Example 3: Router-level and error-handling middleware

Larger apps group related routes with express.Router() and centralize error formatting in one place:

const express = require("express");

const app = express();
const adminRouter = express.Router();

function requireAdminKey(req, res, next) {
  const key = req.get("x-admin-key");
  if (key !== "secret123") {
    const err = new Error("Forbidden: invalid admin key");
    err.status = 403;
    return next(err);
  }
  next();
}

adminRouter.use(requireAdminKey);

adminRouter.get("/stats", (req, res) => {
  res.json({ users: 42, uptime: process.uptime() });
});

app.use("/admin", adminRouter);

app.use((req, res) => {
  res.status(404).json({ error: "Not found" });
});

app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(err.status ?? 500).json({ error: err.message });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

Output:

$ curl http://localhost:3000/admin/stats
{"error":"Forbidden: invalid admin key"}

$ curl -H "x-admin-key: secret123" http://localhost:3000/admin/stats
{"users":42,"uptime":12.345}

requireAdminKey is router-level middleware — it only runs for requests that reach adminRouter. When the key is wrong it builds an Error, attaches a custom status, and calls next(err), which skips every remaining regular middleware and jumps straight to the four-parameter error handler at the bottom of the stack. The 404 handler and the error handler are both application-level middleware registered last, after every real route, which is exactly why they only catch what nothing else handled.

How Middleware Execution Order Works

Following a single request through Example 3 makes the order concrete:

  1. A request for GET /admin/stats arrives without the x-admin-key header.
  2. Express matches app.use("/admin", adminRouter) and hands control to the router.
  3. Inside the router, requireAdminKey runs first because it was registered with adminRouter.use() before the /stats route.
  4. The key check fails, so requireAdminKey calls next(err) instead of next(). Express immediately stops walking through regular middleware and route handlers and searches forward for the next function with four parameters.
  5. It finds the error-handling middleware at the bottom of the stack, which logs the message and sends the JSON error response. The cycle ends there — the /stats route handler and the 404 handler are never reached.
  6. On a second request with the correct header, requireAdminKey calls plain next(), so Express proceeds to adminRouter.get("/stats", ...), which sends the success response directly. Because the response cycle already ended, neither the 404 handler nor the error handler ever runs.

One Express 5 detail worth knowing: if a middleware or route handler is an async function and it throws, or the promise it returns rejects, Express 5 automatically forwards that error to next() for you. In Express 4 you had to wrap every async handler yourself and call next(err) from a catch block, or the rejection would be silently lost.

Common Mistakes

1. Forgetting to call next()

If a middleware neither sends a response nor calls next(), the request hangs forever:

function logRequest(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  // forgot to call next() -- every request now hangs
}

app.use(logRequest);

app.get("/", (req, res) => {
  res.send("Home page");
});

The browser or client just spins with no response and no error, which makes this bug confusing to diagnose. The fix is simple — always call next() once your middleware is done, unless it ended the cycle itself:

function logRequest(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next();
}

2. Mounting the catch-all handler too early

Middleware runs in registration order, so a catch-all placed before your real routes intercepts everything:

const express = require("express");
const app = express();

app.use((req, res) => {
  res.status(404).json({ error: "Not found" });
});

app.get("/", (req, res) => {
  res.send("Home page");
});

Because the 404 middleware is registered first and never calls next(), no request ever reaches the / route — every response is 404. The fix is to register catch-all and error-handling middleware last, after every real route, exactly as shown in Example 3.

3. Giving error-handling middleware the wrong number of parameters

Express decides whether a middleware is an error handler purely by counting its parameters:

app.use((req, res, next) => {
  res.status(500).json({ error: "something broke" });
});

With only three parameters, Express treats this as regular middleware, so it never runs when next(err) is called — the error falls through to Express’s plain-text default error page instead of your JSON response. It must declare all four parameters, even if next is unused inside the body, as shown in the corrected error handler in Example 3.

Best Practices

  • Register body-parsing middleware (express.json(), express.urlencoded()) before any route that reads req.body.
  • Keep each middleware focused on one concern — compose several small middleware functions rather than one that does logging, auth, and validation all at once.
  • Register the 404 handler after all real routes, and the error-handling middleware last of all, so nothing accidentally shadows your routes.
  • Always declare error-handling middleware with exactly four parameters, (err, req, res, next), even if some are unused.
  • Use express.Router() to scope middleware like authentication to just the routes that need it, instead of checking the URL manually inside one giant function.
  • Prefer well-maintained third-party middleware (helmet, cors, morgan) over hand-rolled security or logging code.
  • In Express 5, let errors thrown by async route handlers propagate naturally to your error middleware instead of wrapping every route in its own try/catch.
  • Never leave a code path without an explicit res.end()-family call or a next() call — an unresolved request will hang until the client times out.

Practice Exercises

Exercise 1: Write a requestTimer middleware that logs how many milliseconds each request took, similar to Example 1. Mount it as the very first application-level middleware in a small app with two or three routes, then make a few requests and confirm every response gets logged.

Exercise 2: Build an /admin router protected by an API-key middleware like the one in Example 3, but read the expected key from process.env.ADMIN_KEY instead of hardcoding it. What response does a client get if you forget to set that environment variable before starting the server?

Exercise 3: Add a single error-handling middleware to an existing app that returns { error: message } as JSON with the correct status code for at least three different situations: a validation failure (400), a missing resource (404), and an unexpected thrown error (500). Trigger each case with curl and confirm the status code and body.

Summary

  • Middleware are functions with the signature (req, res, next), or (err, req, res, next) for error handlers, that run in the order they were registered.
  • Calling next() passes control to the next middleware; calling next(err) jumps directly to the nearest error-handling middleware; sending a response ends the cycle.
  • Express recognizes application-level, router-level, built-in, third-party, and error-handling middleware — the last is identified purely by having four parameters.
  • Registration order is meaningful: body parsers must come before routes that need them, and catch-all/error handlers must come after every real route.
  • A middleware that never calls next() and never sends a response leaves the request hanging indefinitely.
  • Express 5 automatically forwards errors thrown or rejected by async middleware and handlers to next(), simplifying error handling compared to Express 4.