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(likereq.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
Errorjumps 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:
- A request for
GET /admin/statsarrives without thex-admin-keyheader. - Express matches
app.use("/admin", adminRouter)and hands control to the router. - Inside the router,
requireAdminKeyruns first because it was registered withadminRouter.use()before the/statsroute. - The key check fails, so
requireAdminKeycallsnext(err)instead ofnext(). Express immediately stops walking through regular middleware and route handlers and searches forward for the next function with four parameters. - 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
/statsroute handler and the 404 handler are never reached. - On a second request with the correct header,
requireAdminKeycalls plainnext(), so Express proceeds toadminRouter.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 readsreq.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
asyncroute handlers propagate naturally to your error middleware instead of wrapping every route in its owntry/catch. - Never leave a code path without an explicit
res.end()-family call or anext()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; callingnext(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
asyncmiddleware and handlers tonext(), simplifying error handling compared to Express 4.
