Knowing the dispatch algorithm explains ordering bugs, next('route'), mount-path rewriting and why error handlers need four arguments.
An Express app is a function (req, res) passed to http.createServer. Each request is handed to the app's router (from the router package; Express 5 exposes it as app.router). The router owns a stack — an array of Layer objects, one per app.use or route registration, in registration order.
Each Layer holds a compiled path matcher (path-to-regexp v8 in Express 5) and a handler:
app.use(path, fn)creates a prefix layer: it matchespathand anything below it on a segment boundary.app.get(path, ...fns)creates a layer whose handler is a Route. The Route has its own stack of layers, one per handler, each tagged with an HTTP method. Route layers match the path exactly (modulo trailing slash and case, by default).
Dispatch is a loop driven by next:
- The router walks forward from its current index until it finds a layer whose path matches, then calls it. When that layer calls
next(), the walk resumes from the following index. - Every layer is either a normal handler (arity ≤ 3) or an error handler (arity 4). While an error is pending, normal layers are skipped; when no error is pending, error layers are skipped. That is the entire reason the four-parameter signature matters.
next("route")skips the rest of the current Route's handlers;next("router")exits the current router back to its parent.- If a handler returns a promise, the router attaches a rejection handler that calls
next(err)— the Express 5 async behaviour.
For prefix layers, the router strips the matched prefix from req.url before calling in, saves it on req.baseUrl, and restores both afterwards. A mounted sub-router or sub-app therefore sees relative paths.
Route params are decoded and stored on req.params per layer. If the stack is exhausted, control returns to Express's finalhandler, which sends a 404, or a 500 if an error is still pending.
Example
import express from "express";
const app = express();
app.get("/items/:id",
(req, res, next) => (req.params.id === "special" ? next("route") : next()), // skip rest of route
(req, res) => res.send(`regular item ${req.params.id}`),
);
app.get("/items/:id", (req, res) => res.send("special handling")); // next("route") lands here
const api = express.Router();
api.use((req, res, next) => {
console.log({ url: req.url, baseUrl: req.baseUrl, originalUrl: req.originalUrl });
next(); // GET /api/v1/ping -> url: /ping, baseUrl: /api/v1
});
api.get("/ping", (req, res) => res.send("pong"));
app.use("/api/v1", api);
app.get("/err", () => { throw new Error("x"); });
app.use((req, res, next) => res.send("never reached for /err")); // skipped: error pending
app.use((err, req, res, next) => res.status(500).send(`handled: ${err.message}`));
// Peek at the stack (internal structure, for learning only)
console.log(app.router.stack.map((l) => l.name));
app.listen(3000);Remember
- The router walks an ordered stack of Layers;
next()resumes the walk app.uselayers match prefixes and rewritereq.url/req.baseUrl- Arity 4 marks error layers; pending errors skip normal layers and vice versa
next('route')andnext('router')skip ahead deliberately
Watch out for
- Wrapping an error handler in a helper that hides its arity, so Express no longer sees four parameters.