Same three lines. Two completely different moments.

Every confusing thing about Express — why req.body is undefined, why middleware order matters, why app.set sometimes does nothing — comes from one fact: your file runs once, and a request walks it every time.

Every behavioural claim here was produced by running the code against express@5.2.1 and express@4.22.2, with body-parser 2.3.0 and 1.20.8 and router 2.2.0, on Node v22.22.2.

Two phases, not one

Reading an Express file top to bottom feels like reading a program. It isn't. It is a program that builds a program.

When Node executes your file, none of your handlers run. Each call appends something to a list. Then app.listen hands that list to an HTTP server and your process goes idle.

Later — milliseconds or hours later — a request arrives, and Express walks that list from the top. That walk is the only time your callbacks execute. Everything that gets attached to req gets attached during the walk, by whichever entry is currently running.

So there are two clocks. Registration order is fixed at startup and never changes. Execution order replays that same sequence on every request. The bugs come from confusing a position in the file with a moment in time.

The one sentence version

app.use and app.get do not run your function. They store it. The store is ordered, and the order is the order you wrote.

Registration: every call is a write

Four lines, four different destinations. None of them is "run this".

server.tsruns once
import express from 'express';

const app = express();

app.set('query parser', 'extended');

app.use(express.json());

app.get('/products/:productId/reviews', (req, res) => {
  const productId = req.params.productId;

  res.json({ productId, query: req.query, body: req.body });
});

app.listen(3000);
  1. 1
    app.set writes one key into app.settings. A plain object hanging off the app, plus the compiled form the setter derives alongside it. Nothing reads either one yet — when it gets read is the whole story of section five.
  2. 2
    express.json() is a factory. Calling it returns a middleware function; app.use pushes that function onto the stack as a layer with no path, so it will match every request. The function itself has not run.
  3. 3
    The path is compiled to a regex right here, at startup. path-to-regexp turns /products/:productId/reviews into a pattern with a named capture group, and your callback is wrapped in a Route that sits there until a request matches it.
  4. 4
    Request time starts here, and only because the layers above ran. req.params is bound by the router for this layer alone, and its value is a string whatever the URL looked like.
  5. 5
    All three surfaces populated at once, for the only time in the walk. req.query uses the parser chosen above; req.body exists only because the parser layer ran and matched.
  6. 6
    app.listen is roughly http.createServer(app).listen(3000). The app object is itself the request handler. From here the stack is frozen in practice and the process waits.

Watch the stack fill

Step through those calls and watch the two places they write to. Flip the version switch in the header — v4 silently prepends two layers of its own that v5 does not have.

Every box in that stack is clickable. The v4 stack starts with query and expressInit because app.lazyrouter() injects them the first time you register anything. Express 5 removed both — req.query became a getter, and the init work moved into the app's own handler.

CallWrites toReads anything?Runs your code?
app.set(k, v) app.settings[k] only for compiled keys no
app.use(fn) router.stack — path /, matches all no no
app.get(p, fn) router.stack — compiled matcher + Route compiles p now no
app.listen(port) a real http.Server no no

The walk: one request down the stack

A request enters as a bare Node stream and leaves as an object with three extra surfaces bolted on. Each bolt has an owner.

Express hands the request to the router, which holds an index into the stack and a closure called next. Each layer either matches the path or is skipped. A matching layer runs, and it has exactly three ways to end: call next() to advance the index, write a response to finish, or call next(err) to switch the walk into error mode.

Nothing is concurrent. There is one index, moving one way.

The readouts under the diagram are the point. req.body is undefined until the parser layer runs, and req.params stays empty until a layer with a pattern matches. A plain app.use layer has no pattern, so it always sees {} — measured, both versions.

Reading a URL at a glance

Mounting makes one request carry two different path strings at once, and as plain text they are almost impossible to tell apart. Everywhere a URL appears in this page it is coloured by part, so the split is visible rather than something you have to diff by eye.

URL key /api mount prefix — becomes baseUrl /reviews static segment :productId dynamic segment — becomes req.params ?page=2 query string — becomes req.query

So the pair that catches everyone — /api/v1/list?a=1 against /v1/list?a=1 — stops being two similar strings and becomes one string with a prefix moved out of it. Step through the mount depth below and watch which characters cross the line.

The characters never change. req.originalUrl keeps the whole thing, req.baseUrl takes the prefix that was stripped, and req.url holds what is left — until that layer calls next(), which restores it. req.path is req.url without the query string.

This is why a log line printing req.url from inside a mounted router shows /list?a=1, a path that does not exist anywhere in your API.

params is rebound per layer, not accumulated

The router sets req.params to that layer's captures before calling it, and the next matching layer replaces them with its own; a mounted router restores its parent's value only when it exits. A nested router sees only its own captures unless you create it with mergeParams: true. Measured: a child router at /a/:aid/plain sees {}; the same router with mergeParams sees { aid: '9' }.

.use is one operation, not several

It turns up on the app, on routers, inside Express\'s own source and at every level of nesting. That is one function doing one job, in a structure that contains itself.

The job: append a layer to a stack that matches by path prefix and ignores the HTTP method. Everything that looks like a different feature follows from those two properties plus one fact — the thing you hand it can be anything with a (req, res) signature, including another whole router.

Step through the five shapes it wears. The call changes; the operation does not.

Prefix versus whole path

This single difference against app.get generates almost everything else.

app.useapp.get / app.post
Path matchprefix, at segment boundariesthe whole path
HTTP methodignoredfiltered
Rewrites req.urlyes, strips the prefixno
Sets req.baseUrlyesno
Layers per callone per functionone, handlers inside its Route
Wraps a Routenoyes
Captures paramsonly if given a patternalways

Why it appears at every level

A router is a stack of layers. A router is also a valid layer handle. So mounting one inside another is not a separate mechanism — it is the same append, with a stack as the payload. The structure contains itself, which is why the same call reads naturally on an app, on a router, and inside Express\'s own lazyrouter().

the five shapessame call each time
app.use(logger);

app.use('/api', rateLimit);

app.use('/a/:aid', loadAccount);

app.use('/api/v1', v1Router);

app.use((err, req, res, next) => { /* ... */ });
  1. 1
    No path, so it matches everything. The path defaults to /, which as a prefix matches every request. The only case where a .use layer genuinely sees an empty req.params.
  2. 2
    A path, matched as a prefix. Measured: /api matches /api, /api/ and /api/x — but not /apix. The boundary is the slash, not the character.
  3. 3
    A pattern, so it captures. This layer gets req.params.aid, and req.baseUrl reads /a/9 — the resolved segments, not the pattern.
  4. 4
    A router, so the handle is another stack. The prefix stays stripped until the child hands control back through next(), so the child matches from its own root and never needs to know where it was mounted.
  5. 5
    Four parameters, so it is an error layer. Classified on fn.length alone. Skipped during a normal walk, and the only kind considered once an error is in flight.
The rule about .use and req.params is narrower than it sounds

"A .use layer sees an empty req.params" is true only for a .use with no path — it has no pattern, so it has nothing to capture. Give it a pattern and it binds captures exactly like a route.

Two consequences worth carrying

  • One layer per function. app.use(a, b, c) appends three layers; app.get(path, h1, h2) appends one, with both handlers inside it. That is why route-level middleware is scoped to the route — it was never in the app stack.
  • A trailing .use is your 404. It has no path, so it catches whatever nothing above it answered. Measured: it runs on an unmatched path and does not run when a route responded.

The three surfaces, and who owns each

params, query and body look alike in a handler. They arrive by three completely different mechanisms, at three different moments.

They fail in different directions

This is the table worth memorising. Read the middle column as "what has to have happened already".

SurfaceSet bySet whenIf nothing set itValue type
req.params the router, per layer at pattern match {} — never undefined always string
req.query a getter on the prototype on first access, then again on every access {} — null-prototype string | string[]
req.query the built-in query layer first layer of the walk, before your code {} string | string[] | object
req.body a body-parser layer you added when that layer runs, after the stream drains undefined whatever the parser produced
req.body a body-parser layer you added when that layer runs, after the stream drains undefined whatever the parser produced
The asymmetry that bites

req.params and req.query are always objects. So req.query.page on a request with no query string is undefined — harmless. But req.body can be undefined itself, so req.body.name throws TypeError. That is why a missing body parser crashes rather than returning nothing.

The version split on Express 5Express 4

The biggest change between versions is that req.query stopped being middleware and became a lazy getter, and the default parser flipped. Both are measured below on the same query string.

Given ?color=red&color=blue&filter[min]=4express@4.22.2express@5.2.1
Default query parser 'extended' — qs 'simple' — querystring
req.query.color ['red','blue'] ['red','blue']
req.query.filter { min: '4' } undefined
req.query['filter[min]'] undefined '4'
Where it is set a stack layer a prototype getter
req.query = {...} sticks silently discarded
Measured, and genuinely surprising

In v5, req.query.page = 99 throws nothing and changes nothing. The getter re-parses the URL on every access, so your write lands on a throwaway object. Any v4 middleware that sanitised the query in place is a silent no-op after upgrading.

app.set and the read moment

"Why can't I call app.set after that line?" has one answer: because something already read the value and kept a copy.

app.set itself is trivial — it assigns into app.settings. What varies is when the value is consumed, and consumption comes in two flavours.

  • Compiled at registration. Something reads the setting once, turns it into a function or a regex, and holds that. Changing the setting afterwards changes the object but not the behaviour.
  • Read at request time. Something looks the setting up on every request. Changing it takes effect immediately, even after listen.

Two settings make this concrete, and they behave differently in the two majors.

the cutoff testmeasured
const app = express();

app.set('query parser', 'extended');

app.use((req, res, next) => next());

app.set('query parser', 'extended');

app.get('/x', (req, res) => res.json(req.query));
  1. 1
    Before any registration, this position always wins. Guaranteed to be in place before anything can read it — correct in every version, for every setting, which is why "put app.set at the top" is decent advice.
  2. 2
    In v4 this line triggers app.lazyrouter(). It reads query parser fn once and closes over the result inside a built-in query layer. Nothing re-reads it afterwards.
  3. 3
    The same call here is already too late on v4. app.settings changes and the behaviour does not, so the setting reports one parser while another does the work. Nothing warns you.
  4. 4
    On v5 there is no cutoff anywhere. req.query is a getter that looks the parser up on every access, so even a change made after app.listen takes effect on the next request.

The v5 getter is the fix. It reads the setting at access time:

express@5.2.1 — lib/request.js:217reads on every access
defineGetter(req, 'query', function query() {
  var queryparse = this.app.get('query parser fn');
  if (!queryparse) {
    return Object.create(null);
  }
  var querystring = parse(this).query;
  return queryparse(querystring);
});
  1. 1
    It is a getter, not a property. defineGetter puts this on the request prototype, so no layer ever assigns req.query — it is computed the instant someone reads it.
  2. 2
    Looked up per access. This is the whole reason v5 has no app.set cutoff: nothing here holds a cached copy of the parser, so a change lands immediately.
  3. 3
    A null prototype on the empty case. Setting query parser to false disables parsing entirely. The null prototype means a key called constructor cannot collide with anything inherited.
  4. 4
    Re-parsed on every read. Which is why a write to req.query lands on a throwaway object and silently disappears, and why req.query !== req.query.
express@4.22.2 — lib/application.js:144reads once, keeps a copy
app.lazyrouter = function lazyrouter() {
  if (!this._router) {
    this._router = new Router({
      caseSensitive: this.enabled('case sensitive routing'),
      strict: this.enabled('strict routing')
    });
    this._router.use(query(this.get('query parser fn')));
    this._router.use(middleware.init(this));
  }
};
  1. 1
    The guard makes this run exactly once. Called by your first app.use, app.get, app.route or app.param — never by app.listen — and everything it reads is frozen at that moment.
  2. 2
    Router options are read here and baked in. Which is why case sensitive routing and strict routing must be set before your first registration of any kind, app.use included, not merely before app.listen.
  3. 3
    The cutoff, in one line. this.get(...) evaluates now, and query() closes over what it returns. A later app.set updates app.settings and never reaches this closure, so setting and behaviour drift apart with no warning.
  4. 4
    The second built-in layer. It cross-links req and res and swaps in the Express prototypes. v5 moved this work out of the stack entirely.
SettingConsumedLatest safe positionSymptom if late
query parser compiled into a layer at first registration looked up per access by the getter before the first app.use / app.get anywhere, even after listen setting and behaviour disagree, silently none
case sensitive routing read when the router is created before the first app.use or route routes keep the old matching rule
strict routing read when the router is created before the first app.use or route trailing-slash behaviour unchanged
trust proxy read per request, by req.ip and friends anywhere none
view engine read when res.render runs anywhere before the first render none in practice
etag compiled to a function by the setter itself anywhere — recompiled on each set none
The general rule

A setting is safe to change late exactly when nothing has cached a derived form of it. "Before app.listen" is folklore — the real boundary is before the first thing that reads it, which for router options is your first registration of any kind, app.use included, not listen.

What app.set is not for

  • Per-request data. app.settings is one object shared by every concurrent request. Attach to req in a middleware instead.
  • Per-route options. Pass route-level middleware: app.get('/admin', requireAuth, handler).
  • Anything you intend to change at runtime, unless you have checked that the consumer re-reads it. The routing options, and in Express 4 the query parser, do not.

Scenarios

Each of these is a real server that was started, hit with a real request, and torn down. The output pane is what came back.

How these were produced

A probe script builds each app, calls app.listen(0), issues the request over loopback, records the JSON, and closes the server — once under express@4.22.2 and once under express@5.2.1. Nothing in the output pane is written by hand.

The ordering rules, derived

Every rule below falls out of "registration order is execution order, and each layer only sees what earlier layers did".

1. A parser must be registered above every route that reads it

Not "at the top" — above. app.use(express.json()) placed after a route simply never runs for that route, because the walk reached the handler and the handler ended the response.

2. Scope the parser rather than globalising it

app.use(express.json()) buffers a body on every request that carries one. Mounting it per-route keeps the cost and the attack surface where it belongs.

route-scoped parsingpreferred
app.post('/products', express.json({ limit: '10kb' }), (req, res) => {
  res.status(201).json({ created: req.body });
});
  1. 1
    Middleware passed as an argument is scoped to the route. It becomes a layer inside this Route rather than in the app stack, so it runs for this path and this method only.
  2. 2
    Scoping the parser also scopes its limit. The body cap is per-parser, so a route that accepts large uploads does not have to widen the limit for everything else.
  3. 3
    Reached only after the parser called next(). If the payload was over the limit the parser called next(err) instead, and this line never ran.

3. Error handlers go last, and must take four arguments

Express switches to error mode on next(err) and from then on skips every three-argument layer. Arity is the only signal — a four-argument function is an error handler, whatever you name it. Measured: with an error in flight, a three-argument app.use registered before the error handler never ran.

4. A 404 handler is just the last non-error layer

There is no special 404 hook. If the walk runs off the end of the stack, Express finalises with a 404. Registering a catch-all as the final layer intercepts that.

the tail of every apporder is load-bearing
app.use((req, res) => {
  res.status(404).json({ error: 'not found', path: req.originalUrl });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: err.message });
});
  1. 1
    Three arguments, no path, registered after every route. So it is reached only when nothing above it wrote a response. Express has no special 404 hook — running off the end of the stack is the mechanism. Put this before your routes and it swallows the whole app.
  2. 2
    originalUrl, not url. Inside a mounted router req.url has had the mount prefix stripped, so logging it would report a path your API does not have.
  3. 3
    Four arguments, so Express classifies this as an error layer. It reads fn.length and nothing else, so the parameter names are irrelevant — and a default value on any of them drops length to 3 and quietly demotes this to ordinary middleware.
  4. 4
    err.status is a convention, not a guarantee. Some libraries set it and some do not, which is what the fallback is for.

5. In v5, async handlers that reject go to the error handler by themselves

Measured: a handler declared async that throws reaches the four-argument error layer in v5 and returns 500. In v4 the same handler produces an unhandled rejection and the request hangs until the client gives up — the probe recorded a timeout with no response written. That is why v4 codebases wrap every async handler.

SymptomCauseFix
TypeError: Cannot read properties of undefined no body parser registered above this route add express.json() above it
req.body is {} but the client sent JSON parser ran and skipped — Content-Type did not match send application/json, or widen type
req.query.filter is undefined on v5 default parser is simple; brackets stay literal app.set('query parser','extended')
app.set had no effect the value was compiled before you changed it move it above the first registration
route never fires an earlier layer responded, or a catch-all sits above it check registration order, not the file's shape
errors reach the client as HTML stack traces no four-argument layer registered add one, last
The mental model, compressed

Your file writes an ordered list. A request walks that list once, top to bottom, carrying one object. Each layer may add to that object, end the walk, or step aside. Everything else is detail.

Back to JIT-Atlas