Inside the router: layers and matching

Express · Expert · 18 min

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 matches path and 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.use layers match prefixes and rewrite req.url/req.baseUrl
  • Arity 4 marks error layers; pending errors skip normal layers and vice versa
  • next('route') and next('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.

Check yourself

  1. An error is pending and the next layer in the stack is (req, res, next) => {...}. What does the router do?
  2. Inside a router mounted with app.use('/shop', r), a request is for /shop/cart. What is req.url within r?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/api/router