Writing Custom Middleware
Most of the middleware you ship in a real Express app is middleware you write yourself: a request logger, an auth guard, a rate limiter, a response-timing wrapper. The pattern is small but precise — a function with the (req, res, next) signature that either passes control downstream by calling next() or ends the request by sending a response. Once you have the basic shape down, you graduate to middleware factories: functions that take configuration and return a middleware, so one piece of logic serves many call sites with different options.
The middleware signature
A standard Express middleware is a function that receives three arguments: the request object, the response object, and a next callback. You do your work, then you make exactly one of two choices — call next() to hand the request to the next function in the stack, or send a response (res.send, res.json, res.end, etc.) to terminate the chain. Doing neither leaves the request hanging; doing both throws Cannot set headers after they are sent.
function example(req, res, next) {
// 1. do something with req / res
// 2. then EITHER call next() ...
next();
// ... OR send a response — never both.
}
Warning: A middleware that sends a response must not also call
next(). If you writeres.json(...)and then let execution fall through tonext(), the next handler may try to write to an already-finished response and Express throwsERR_HTTP_HEADERS_SENT. Usereturn res.json(...)to stop right there.
Writing a request logger
Let’s build a logger from scratch. It records the method, URL, and how long the request took. The trick for timing is to capture a start timestamp, register a listener on the response’s finish event, then immediately call next() so the request continues down the pipeline. The listener fires once the response is fully sent.
const express = require("express");
const app = express();
function logger(req, res, next) {
const start = process.hrtime.bigint();
res.on("finish", () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
console.log(
`${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms.toFixed(1)}ms)`
);
});
next(); // hand off immediately; the listener runs after the response finishes
}
app.use(logger);
app.get("/users", (req, res) => {
res.json([{ id: 1, name: "Ada" }]);
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Output:
$ curl http://localhost:3000/users
[{"id":1,"name":"Ada"}]
# server console:
GET /users -> 200 (2.4ms)
Notice the logger never sends a response of its own — it only observes. That makes it safe to place at the top of the stack with app.use(logger) so it wraps every route. Because it calls next() synchronously, it adds no latency to the request itself; the logging happens off the finish event.
Building a middleware factory
A plain middleware is hard-coded. The moment you want the same behavior with different settings — log only certain methods, require a specific role, allow N requests per window — you reach for a factory: a function that accepts options and returns a (req, res, next) middleware that closes over those options. This is the single most useful pattern in Express; every configurable middleware in the ecosystem (cors(), express.json(), rateLimit()) is built this way.
// Factory: takes config, RETURNS a middleware
function requireRole(role) {
return function (req, res, next) {
const userRole = req.headers["x-role"];
if (userRole !== role) {
return res
.status(403)
.json({ error: `Requires '${role}' role` });
}
next();
};
}
// Each call produces an independent, configured middleware
app.get("/admin", requireRole("admin"), (req, res) => {
res.json({ panel: "metrics" });
});
app.get("/editor", requireRole("editor"), (req, res) => {
res.json({ panel: "content" });
});
Output:
$ curl -i http://localhost:3000/admin -H "x-role: editor"
HTTP/1.1 403 Forbidden
{"error":"Requires 'admin' role"}
$ curl http://localhost:3000/admin -H "x-role: admin"
{"panel":"metrics"}
The outer requireRole runs once, at registration time, and returns the inner function. The inner function runs per request. Configuration captured in the closure (role) stays fixed for that mount point, which is exactly what you want.
Accepting an options object
For more than one knob, pass an options object and apply defaults. This keeps call sites readable and lets you add options later without breaking existing usage.
function logger(options = {}) {
const { methods = null, prefix = "" } = options;
return function (req, res, next) {
if (methods && !methods.includes(req.method)) {
return next(); // skip logging, but still continue the chain
}
const start = Date.now();
res.on("finish", () => {
console.log(`${prefix}${req.method} ${req.originalUrl} ${Date.now() - start}ms`);
});
next();
};
}
app.use(logger({ methods: ["POST", "PUT", "DELETE"], prefix: "[mutation] " }));
Here the factory both configures behavior and decides whether to act at all — when a request doesn’t match methods, it calls next() without logging, so the pipeline is never blocked.
Factory vs. plain middleware
| Plain middleware | Middleware factory | |
|---|---|---|
| Shape | function (req, res, next) | function (opts) { return (req, res, next) => {} } |
| Registered as | app.use(fn) | app.use(fn(opts)) — note the call |
| Configurable | No | Yes, via closure |
Runs opts logic | Per request | Once, at registration |
| Best for | Fixed behavior (timing, parsing) | Reusable, parameterized guards |
Tip: A common mistake is registering a factory without calling it:
app.use(requireRole)passes the factory (which Express treats as a 1-arg error handler or a no-op) instead ofapp.use(requireRole("admin")). If a configurable middleware silently does nothing, check for the missing parentheses.
Async custom middleware
If your middleware does asynchronous work — a database lookup, a token verification — wrap it so rejected promises reach Express. In Express 4 you must catch errors and forward them with next(err); Express 5 automatically forwards rejected promises from async middleware, so a bare throw becomes a handled error.
function loadUser() {
return async function (req, res, next) {
try {
const id = req.headers["x-user-id"];
const user = await db.users.findById(id); // may reject
if (!user) return res.status(404).json({ error: "User not found" });
req.user = user;
next();
} catch (err) {
next(err); // forward to the error-handling middleware (required in v4)
}
};
}
Best Practices
- Give every middleware exactly one responsibility — log, or authorize, or parse, not all three.
- Always finish each path with
next(),next(err), or a single response; never two of them. - Use
return res.send(...)to make it visually obvious that the middleware terminates the chain. - Reach for a factory whenever the same logic needs different settings at different mount points.
- Keep per-request work in the returned inner function; do setup and validation of options in the outer factory.
- In async middleware, wrap awaited calls in
try/catchand forward failures withnext(err)(or rely on Express 5’s auto-forwarding). - Attach derived data to
req(e.g.req.user,req.requestTime) so downstream handlers can reuse it.