Express.js Middleware Architecture Deep Dive
A deep dive into Express.js middleware architecture covering the middleware stack, execution order, error propagation, router-level middleware, and custom middleware patterns.
Express.js Middleware Architecture Deep Dive
Express.js is a middleware framework. The entire request-response cycle is a pipeline of middleware functions, each with the opportunity to read the request, modify the response, and decide whether to pass control to the next function in the chain. Understanding how this pipeline works — the execution order, scope rules, and error propagation — is essential for building maintainable Express applications.
This guide examines the middleware architecture from the inside: how Express builds the middleware stack, how requests flow through it, and how to use this knowledge to structure real applications.
The Middleware Stack
What Is a Middleware Function
A middleware function receives three arguments:
function middleware(req, res, next) {
// req — the incoming HTTP request
// res — the outgoing HTTP response
// next — a function that passes control to the next middleware
}
Error-handling middleware receives four:
function errorMiddleware(err, req, res, next) {
// err — the error passed from a previous middleware via next(err)
}
Express distinguishes between these two signatures. Four-parameter functions are only called when an error occurs. Three-parameter functions are skipped during error propagation.
Execution Order
Express runs middleware in the order it is registered:
var express = require("express");
var app = express();
app.use(function(req, res, next) {
console.log("1: First middleware");
next();
});
app.use(function(req, res, next) {
console.log("2: Second middleware");
next();
});
app.get("/", function(req, res) {
console.log("3: Route handler");
res.send("Hello");
});
app.use(function(req, res, next) {
console.log("4: After route — only runs if route did not send response");
next();
});
For a GET request to /:
1: First middleware
2: Second middleware
3: Route handler
Middleware 4 never runs because the route handler sent a response. Once res.send(), res.json(), or res.end() is called, the response is sent and subsequent middleware is skipped.
The next() Function
next() does one thing: it tells Express to move to the next matching middleware. Without next(), the chain stops:
app.use(function(req, res, next) {
console.log("This runs");
// No next() call — request hangs here
// The client eventually gets a timeout
});
app.use(function(req, res, next) {
console.log("This never runs");
next();
});
Every middleware must either call next() or send a response. Forgetting both causes the request to hang until the client times out.
next() with Errors
Passing an argument to next() triggers error handling:
app.use(function(req, res, next) {
var error = new Error("Something went wrong");
next(error); // Skip all normal middleware, go to error handlers
});
// This is skipped because it only has 3 parameters
app.use(function(req, res, next) {
console.log("This is skipped during error handling");
next();
});
// This runs because it has 4 parameters
app.use(function(err, req, res, next) {
console.log("Error caught:", err.message);
res.status(500).json({ error: err.message });
});
When next(err) is called, Express skips all three-parameter middleware and jumps to the next four-parameter middleware.
next("route")
next("route") skips the remaining handlers on the current route and moves to the next matching route:
app.get("/users/:id",
function(req, res, next) {
if (req.params.id === "me") {
return next("route"); // Skip to the next app.get("/users/:id")
}
next(); // Continue to the next handler in this route
},
function(req, res) {
// Handles /users/123, /users/456, etc.
res.json({ id: req.params.id });
}
);
app.get("/users/:id", function(req, res) {
// Handles /users/me (reached via next("route"))
res.json({ id: req.user.id, name: req.user.name });
});
Application-Level Middleware
app.use()
app.use() registers middleware for all HTTP methods and matching paths:
// Runs for every request
app.use(function(req, res, next) {
req.requestTime = Date.now();
next();
});
// Runs only for requests starting with /api
app.use("/api", function(req, res, next) {
console.log("API request:", req.method, req.path);
next();
});
// Path matching is prefix-based
// /api matches: /api, /api/users, /api/users/123
// /api does NOT match: /application, /apis
app.METHOD()
Route-specific middleware runs only for the specified HTTP method and exact path:
// Only GET requests to /users
app.get("/users", function(req, res) {
res.json([]);
});
// Only POST requests to /users
app.post("/users", function(req, res) {
res.status(201).json({ created: true });
});
// Multiple handlers for one route
app.get("/dashboard",
authenticate, // First: check authentication
loadUserData, // Second: load user data
function(req, res) { // Third: render the page
res.render("dashboard", { user: req.userData });
}
);
Router-Level Middleware
Express Router
A Router is a mini-application with its own middleware stack. It is the primary tool for organizing large Express applications:
var express = require("express");
var router = express.Router();
// Router-level middleware — runs for all routes on this router
router.use(function(req, res, next) {
console.log("Router middleware:", req.method, req.path);
next();
});
router.get("/", function(req, res) {
res.json({ message: "Users list" });
});
router.get("/:id", function(req, res) {
res.json({ id: req.params.id });
});
// Mount the router on the app
app.use("/users", router);
Router Middleware Scope
Middleware registered on a router only runs for routes handled by that router:
var usersRouter = express.Router();
var articlesRouter = express.Router();
// This only runs for /users/* routes
usersRouter.use(function(req, res, next) {
console.log("Users middleware");
next();
});
// This only runs for /articles/* routes
articlesRouter.use(function(req, res, next) {
console.log("Articles middleware");
next();
});
usersRouter.get("/", listUsers);
articlesRouter.get("/", listArticles);
app.use("/users", usersRouter);
app.use("/articles", articlesRouter);
A GET request to /users triggers "Users middleware" but not "Articles middleware".
Nested Routers
Routers can be nested for hierarchical organization:
// routes/api/index.js
var express = require("express");
var apiRouter = express.Router();
// API-wide middleware
apiRouter.use(express.json());
apiRouter.use(authenticate);
apiRouter.use(rateLimit({ max: 100 }));
// Sub-routers
apiRouter.use("/users", require("./users"));
apiRouter.use("/articles", require("./articles"));
apiRouter.use("/admin", require("./admin"));
module.exports = apiRouter;
// routes/api/admin.js
var express = require("express");
var router = express.Router();
// Additional middleware for admin routes only
router.use(authorize("admin"));
router.get("/stats", getStats);
router.get("/users", listAllUsers);
module.exports = router;
// app.js
app.use("/api", require("./routes/api"));
The middleware execution order for GET /api/admin/stats:
express.json()(from apiRouter)authenticate(from apiRouter)rateLimit(from apiRouter)authorize("admin")(from admin router)getStats(route handler)
Path Handling in Routers
When a router is mounted at a path, req.path inside the router reflects the relative path:
var router = express.Router();
router.get("/profile", function(req, res) {
console.log(req.path); // "/profile"
console.log(req.baseUrl); // "/users"
console.log(req.originalUrl); // "/users/profile"
res.send("OK");
});
app.use("/users", router);
req.originalUrl always has the full path. req.path is relative to where the router is mounted.
Built-in Middleware
express.json()
Parses JSON request bodies:
app.use(express.json({
limit: "1mb", // Maximum body size
strict: true, // Only accept arrays and objects
type: "application/json" // Content types to parse
}));
express.urlencoded()
Parses URL-encoded form bodies:
app.use(express.urlencoded({
extended: true, // Use qs library for rich objects
limit: "1mb"
}));
express.static()
Serves static files from a directory:
app.use(express.static("public", {
maxAge: "1d", // Cache for 1 day
etag: true, // Enable ETag headers
index: "index.html", // Default file
dotfiles: "ignore" // Ignore dotfiles
}));
Common Middleware Patterns
Request Logging
function requestLogger(req, res, next) {
var start = Date.now();
// Capture the original end function
var originalEnd = res.end;
res.end = function() {
var duration = Date.now() - start;
console.log(JSON.stringify({
method: req.method,
path: req.path,
status: res.statusCode,
duration: duration + "ms",
ip: req.ip
}));
// Call the original end
originalEnd.apply(res, arguments);
};
next();
}
app.use(requestLogger);
Request ID
function requestId(req, res, next) {
var id = req.headers["x-request-id"] ||
Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
req.id = id;
res.setHeader("X-Request-ID", id);
next();
}
app.use(requestId);
Response Time Header
function responseTime(req, res, next) {
var start = Date.now();
res.on("finish", function() {
var duration = Date.now() - start;
// Cannot set header after response is sent, so use a listener
});
// Set the header before the response is sent
var originalSend = res.send;
res.send = function() {
res.setHeader("X-Response-Time", (Date.now() - start) + "ms");
originalSend.apply(res, arguments);
};
next();
}
Conditional Middleware
Run middleware only when a condition is met:
function when(condition, middleware) {
return function(req, res, next) {
if (condition(req)) {
return middleware(req, res, next);
}
next();
};
}
// Only parse JSON for API routes
app.use(when(
function(req) { return req.path.startsWith("/api"); },
express.json()
));
// Only authenticate non-public routes
app.use(when(
function(req) { return !req.path.startsWith("/public"); },
authenticate
));
Per-Route Middleware Arrays
var adminMiddleware = [authenticate, authorize("admin"), auditLog];
var editorMiddleware = [authenticate, authorize("admin", "editor")];
router.get("/admin/dashboard", adminMiddleware, dashboardHandler);
router.put("/articles/:id", editorMiddleware, updateHandler);
Express flattens arrays of middleware, so you can pass an array instead of listing each function individually.
Error Handling Architecture
Error Flow
app.use(function(req, res, next) {
console.log("A: Normal middleware");
next();
});
app.get("/fail", function(req, res, next) {
console.log("B: Route handler — about to throw");
next(new Error("Something broke"));
});
app.use(function(req, res, next) {
console.log("C: This is skipped during error handling");
next();
});
app.use(function(err, req, res, next) {
console.log("D: First error handler");
// Can fix the error and continue
if (err.message === "Something fixable") {
return next(); // Continue to normal middleware
}
next(err); // Pass to next error handler
});
app.use(function(err, req, res, next) {
console.log("E: Final error handler");
res.status(500).json({ error: err.message });
});
For GET /fail:
A: Normal middleware
B: Route handler — about to throw
D: First error handler
E: Final error handler
C is skipped because it is a normal middleware (three parameters) and there is an active error.
Multiple Error Handlers
Use multiple error handlers for different concerns:
// Log all errors
app.use(function(err, req, res, next) {
console.error(err.stack);
next(err); // Pass to the next error handler
});
// Handle known operational errors
app.use(function(err, req, res, next) {
if (err.isOperational) {
return res.status(err.statusCode).json({
error: { message: err.message, code: err.code }
});
}
next(err); // Unknown error, pass along
});
// Handle everything else
app.use(function(err, req, res, next) {
res.status(500).json({
error: { message: "Internal server error", code: "INTERNAL_ERROR" }
});
});
Application Structure
Recommended Layout
app.js # Creates the Express app, registers middleware
server.js # Starts the HTTP server
middleware/
authenticate.js # JWT authentication
authorize.js # Role-based authorization
errorHandler.js # Error handling
requestLogger.js # Request logging
rateLimit.js # Rate limiting
validate.js # Input validation
routes/
index.js # Route registration
users.js # User routes
articles.js # Article routes
admin.js # Admin routes
app.js Structure
var express = require("express");
var app = express();
// --- Global middleware (runs for every request) ---
// Security headers
app.use(require("helmet")());
// Request ID
app.use(require("./middleware/requestId"));
// Request logging
app.use(require("./middleware/requestLogger"));
// Body parsing
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
// Static files
app.use(express.static("public"));
// --- Routes ---
app.use("/", require("./routes"));
// --- Error handling (must be last) ---
app.use(require("./middleware/notFound"));
app.use(require("./middleware/errorHandler"));
module.exports = app;
The order matters:
- Security middleware first (helmet, CORS)
- Request metadata (request ID, logging)
- Body parsing
- Static files (returns early if file found)
- Routes
- 404 handler (catches unmatched routes)
- Error handler (catches errors from everything above)
Common Issues and Troubleshooting
Middleware runs in unexpected order
Registration order determines execution order:
Fix: Review the order of app.use() calls. Middleware registered first runs first. Route-specific middleware runs after app.use() middleware. Error handlers must be registered after all routes.
Request body is undefined
Body parsing middleware is not registered or registered after the route:
Fix: Register express.json() before routes that need request bodies. Check the Content-Type header — express.json() only parses application/json requests.
Error handler never catches errors
The error handler has three parameters instead of four, or it is registered before routes:
Fix: Error handlers must have exactly four parameters: (err, req, res, next). Register them after all routes. For async routes, ensure errors are passed to next(err).
Middleware runs for wrong routes
app.use("/api") matches all paths starting with /api:
Fix: Remember that app.use() does prefix matching. /api matches /api, /api/users, and /api/anything. Use app.get(), app.post(), etc. for exact path matching.
Response headers already sent error
Multiple middleware functions tried to send a response:
Fix: After sending a response (res.json(), res.send()), do not call next(). Use return before response calls to prevent falling through to additional code. Check for async operations that send responses after the handler already responded.
Best Practices
- Register middleware in a consistent order. Security, logging, parsing, routes, error handling — always in this sequence. Document the order in comments.
- Use routers to scope middleware. Apply authentication only to protected routes by mounting it on a router, not globally with
app.use(). - Keep middleware functions focused. Each middleware should do one thing. Compose multiple focused middleware instead of building one large function.
- Always call next() or send a response. Forgetting both causes the request to hang. Use linting rules to catch this pattern.
- Use arrays for reusable middleware chains.
var adminAuth = [authenticate, authorize("admin")]is cleaner than repeating both functions on every admin route. - Test middleware in isolation. Middleware functions are regular functions. Pass mock
req,res, andnextobjects to test them without running the full server. - Log middleware registration during startup. In development, log which middleware is mounted and where. This makes debugging execution order easier.