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.
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.
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".
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);
- 1app.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.
- 2express.json() is a factory. Calling it returns a middleware function;
app.usepushes that function onto the stack as a layer with no path, so it will match every request. The function itself has not run. - 3The path is compiled to a regex right here, at startup.
path-to-regexpturns/products/:productId/reviewsinto a pattern with a named capture group, and your callback is wrapped in a Route that sits there until a request matches it. - 4Request time starts here, and only because the layers above ran.
req.paramsis bound by the router for this layer alone, and its value is a string whatever the URL looked like. - 5All three surfaces populated at once, for the only time in the walk.
req.queryuses the parser chosen above;req.bodyexists only because the parser layer ran and matched. - 6app.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.
| Call | Writes to | Reads 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.
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.
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.use | app.get / app.post | |
|---|---|---|
| Path match | prefix, at segment boundaries | the whole path |
| HTTP method | ignored | filtered |
Rewrites req.url | yes, strips the prefix | no |
Sets req.baseUrl | yes | no |
| Layers per call | one per function | one, handlers inside its Route |
Wraps a Route | no | yes |
| Captures params | only if given a pattern | always |
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().
app.use(logger);
app.use('/api', rateLimit);
app.use('/a/:aid', loadAccount);
app.use('/api/v1', v1Router);
app.use((err, req, res, next) => { /* ... */ });
- 1No path, so it matches everything. The path defaults to
/, which as a prefix matches every request. The only case where a.uselayer genuinely sees an emptyreq.params. - 2A path, matched as a prefix. Measured:
/apimatches/api,/api/and/api/x— but not/apix. The boundary is the slash, not the character. - 3A pattern, so it captures. This layer gets
req.params.aid, andreq.baseUrlreads/a/9— the resolved segments, not the pattern. - 4A 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. - 5Four parameters, so it is an error layer. Classified on
fn.lengthalone. Skipped during a normal walk, and the only kind considered once an error is in flight.
"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
.useis 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".
| Surface | Set by | Set when | If nothing set it | Value 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 |
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]=4 | express@4.22.2 | express@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 |
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.
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));
- 1Before 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.
- 2In v4 this line triggers app.lazyrouter(). It reads
query parser fnonce and closes over the result inside a built-in query layer. Nothing re-reads it afterwards. - 3The same call here is already too late on v4.
app.settingschanges and the behaviour does not, so the setting reports one parser while another does the work. Nothing warns you. - 4On v5 there is no cutoff anywhere.
req.queryis a getter that looks the parser up on every access, so even a change made afterapp.listentakes effect on the next request.
The v5 getter is the fix. It reads the setting at access time:
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);
});
- 1It 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. - 2Looked up per access. This is the whole reason v5 has no
app.setcutoff: nothing here holds a cached copy of the parser, so a change lands immediately. - 3A null prototype on the empty case. Setting query parser to false disables parsing entirely. The null prototype means a key called
constructorcannot collide with anything inherited. - 4Re-parsed on every read. Which is why a write to
req.querylands on a throwaway object and silently disappears, and why req.query !== req.query.
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));
}
};
- 1The guard makes this run exactly once. Called by your first
app.use,app.get,app.routeorapp.param— never byapp.listen— and everything it reads is frozen at that moment. - 2Router options are read here and baked in. Which is why
case sensitive routingandstrict routingmust be set before your first registration of any kind,app.useincluded, not merely beforeapp.listen. - 3The cutoff, in one line. this.get(...) evaluates now, and query() closes over what it returns. A later
app.setupdatesapp.settingsand never reaches this closure, so setting and behaviour drift apart with no warning. - 4The 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.
| Setting | Consumed | Latest safe position | Symptom 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 |
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.settingsis one object shared by every concurrent request. Attach toreqin 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.
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.
app.post('/products', express.json({ limit: '10kb' }), (req, res) => {
res.status(201).json({ created: req.body });
});
- 1Middleware 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.
- 2Scoping 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.
- 3Reached 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.
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 });
});
- 1Three 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.
- 2originalUrl, not url. Inside a mounted router
req.urlhas had the mount prefix stripped, so logging it would report a path your API does not have. - 3Four arguments, so Express classifies this as an error layer. It reads
fn.lengthand 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. - 4err.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.
| Symptom | Cause | Fix |
|---|---|---|
| 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 |
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.