Express.js Middleware Deep Dive
A deep dive into Express.js middleware: how the request-response cycle works, writing custom middleware, and ordering middleware correctly in real applications.
Introduction
Middleware is the single most important concept for understanding how Express actually works. Nearly everything Express does — parsing JSON bodies, handling cookies, checking authentication, logging requests, matching routes — is implemented as middleware, executed in a specific order for every incoming request. Once you understand the middleware chain, Express stops feeling like a collection of magic helpers and starts feeling like a simple, predictable pipeline.
The Shape of a Middleware Function
Every piece of Express middleware is a function that receives the request, the response, and a next function:
function logger(req, res, next) {
console.log(`${req.method} ${req.url}`);
next(); // pass control to the next middleware/route handler
}
Calling next() hands control to whatever comes next in the chain. If you never call it (and never send a response), the request simply hangs forever — one of the most common Express bugs for beginners.
Registering Middleware
import express from "express";
const app = express();
app.use(logger); // runs for every request, any method or path
app.use(express.json()); // built-in body parser for JSON requests
app.get("/users", (req, res) => {
res.json([{ id: 1, name: "Ada" }]);
});
app.listen(3000);
Route handlers themselves — the functions passed to app.get, app.post, and so on — are technically middleware too; they simply do not call next() because they typically end the request-response cycle by sending a response.
Middleware Order Matters
Express executes middleware strictly in the order it was registered. Placing body-parsing middleware after the routes that need req.body means those routes will see undefined:
// Wrong order
app.post("/users", (req, res) => {
console.log(req.body); // undefined, json() hasn't run yet
});
app.use(express.json());
// Correct order
app.use(express.json());
app.post("/users", (req, res) => {
console.log(req.body); // parsed object
});
Path-Scoped and Method-Scoped Middleware
Middleware can be scoped to specific paths, or chained directly onto a single route:
function requireAuth(req, res, next) {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: "Unauthorized" });
next();
}
app.use("/admin", requireAuth); // applies to every /admin/* route
app.get("/profile", requireAuth, (req, res) => {
res.json({ message: "Welcome to your profile" });
});
Stacking multiple middleware functions before a route handler (as in the second example) is a common way to compose validation, authentication, and logging per-route without repeating logic.
Error-Handling Middleware
Error-handling middleware is defined with four parameters, and Express recognizes it by that signature alone:
app.get("/risky", (req, res, next) => {
try {
doSomethingThatMightThrow();
res.json({ ok: true });
} catch (err) {
next(err); // forward the error to error-handling middleware
}
});
// Error-handling middleware — must have 4 parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: "Something went wrong" });
});
Error-handling middleware should always be registered last, after all your routes, so it can catch errors from anything above it.
Async Errors Need Extra Care
Express does not automatically catch errors thrown inside async route handlers; an unhandled rejection there will not reach your error middleware unless you catch it yourself:
app.get("/users/:id", async (req, res, next) => {
try {
const user = await findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
} catch (err) {
next(err); // manually forward async errors
}
});
A common pattern is a small wrapper that automatically catches rejected promises and forwards them to next:
const asyncHandler = (fn) => (req, res, next) => fn(req, res, next).catch(next);
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
}));
A Typical Middleware Stack
A realistic Express application often layers middleware like this, from outermost to innermost:
1. Logging (record every request)
2. Security headers (helmet)
3. Body parsing (express.json)
4. CORS handling
5. Authentication
6. Route-specific validation
7. Route handlers
8. 404 handler (catch-all for unmatched routes)
9. Error-handling middleware
Best Practices
- Register body parsers, security middleware, and logging before your routes, not after.
- Keep each middleware function focused on one responsibility — parsing, auth, logging — rather than combining several concerns.
- Always place error-handling middleware last, and give it exactly four parameters so Express recognizes it.
- Wrap or forward errors from async route handlers explicitly; Express will not do this for you automatically.
- Use
app.use(path, middleware)to scope middleware to a section of your API instead of manually checkingreq.pathinside a global middleware.
Common Mistakes to Avoid
- Forgetting to call
next(), causing requests to hang indefinitely with no response. - Placing
express.json()after the routes that rely onreq.body. - Writing error-handling middleware with the wrong number of parameters, so Express never recognizes or invokes it.
- Letting a rejected Promise inside an
asynchandler go uncaught, silently failing without ever reaching your error handler.
Third-Party Middleware in Practice
Most real Express applications lean heavily on well-tested third-party middleware rather than writing every concern from scratch. A typical stack combines several focused packages, each handling one specific cross-cutting concern:
import express from "express";
import helmet from "helmet";
import cors from "cors";
import morgan from "morgan";
import compression from "compression";
const app = express();
app.use(helmet()); // sets security-related HTTP headers
app.use(cors()); // handles Cross-Origin Resource Sharing
app.use(morgan("combined")); // logs incoming requests
app.use(compression()); // gzip-compresses responses
app.use(express.json()); // parses JSON request bodies
Each of these follows the exact same middleware contract described earlier — a function receiving (req, res, next) — which is precisely why they compose so cleanly with your own custom middleware and route handlers. Reading the source of a small middleware package like morgan is a genuinely good way to deepen your understanding of the pattern, since at its core it is just a function that inspects req, logs something, and calls next(), dressed up with configuration options for different log formats.
Router-Level and Application-Level Middleware
Express lets you scope middleware to exactly the routes that need it, instead of applying every check globally to every request. Application-level middleware registered with app.use() runs for every request, while router-level middleware attached to an express.Router() instance only runs for requests handled by that router:
const adminRouter = express.Router();
adminRouter.use((req, res, next) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: "Forbidden" });
next();
});
adminRouter.get("/stats", getStats);
adminRouter.delete("/users/:id", deleteUser);
app.use("/admin", adminRouter);
Every request under /admin passes through the admin-check middleware first, while requests to unrelated routes never touch it at all. This scoping is what makes large Express applications manageable — authentication middleware only on routes that need a logged-in user, request logging on everything, rate limiting only on expensive endpoints — rather than a single monolithic stack of checks that runs unconditionally for every request regardless of whether it is relevant. Middleware order still matters within each scope: a router's own middleware always runs before its route handlers, and routers themselves are matched in the order they were registered with app.use().
Conclusion
Once you see Express as "a chain of middleware functions, executed in order, each deciding whether to respond or pass control onward," the framework becomes far more predictable. Whether you are using a built-in parser, a third-party security library, or your own custom authentication check, they are all playing by the exact same rules.
Article FAQ
References
Comments
Comments are coming soon. Meanwhile, share your feedback via our contact page.
Related Articles
View allError Handling Best Practices in Express
Best practices for handling errors in Express applications, covering centralized error middleware, custom error classes, async error handling, and consistent...
Building REST APIs with Node.js
A practical guide to building REST APIs with Node.js, covering routing, request parsing, status codes, validation, and structuring a maintainable backend pro...
Node.js Event Emitter Patterns
Learn how Node.js's EventEmitter works and explore practical patterns for building event-driven modules, including error handling and avoiding memory leaks.
Node.js Streams and Buffers Explained
Learn how Node.js streams and buffers work, including readable, writable, and transform streams, backpressure, and practical examples for handling large file...