Skip to content

perf: cut the per-hop cost of the router dispatch chain - #366

Closed
nigrosimone wants to merge 1 commit into
dimdenGD:mainfrom
nigrosimone:perf/router-per-hop-cost
Closed

perf: cut the per-hop cost of the router dispatch chain#366
nigrosimone wants to merge 1 commit into
dimdenGD:mainfrom
nigrosimone:perf/router-per-hop-cost

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

routing/middlewares-100 speedup | 0.69x (MAIN) | 1.37x (PR)

The ratio understates it: uExpress gains 47% in absolute terms (17.00k -> 25.05k req/sec)


A chain of N middlewares currently costs N promises, plus a quadratic walk of the mount stack.

What was happening

One promise per hop, adopted back up the chain. _routeRequest was async and returned new Promise, so every hop allocated two and the async wrapper had to adopt the inner one. Worse, advancing to the next route did resolve(this._routeRequest(...)) resolving a promise with a promise, which adds an adoption per hop that has to unwind all the way back when the chain finally settles. With 100 middlewares that's a 100-deep adoption chain.

A quadratic join. getFullMountpath did req._stack.join("") on every use hop. Path-less app.use(fn) pushes "", so for a chain of global middlewares the join walks the whole stack every hop just to produce "" 5050 element visits per request at N=100.

What changed

_routeRequest is now a thin wrapper that allocates one promise for the whole chain and hands resolve/reject to _dispatchRoute, which walks the routes with plain calls carrying the same resolve. External callers are unchanged application.js, index.js and the optimized-path handler still await a promise, and nested routers still cross a real promise boundary.

For the join: patternToRegex("", true) returns EMPTY_REGEX, so a stack of only empty paths can return EMPTY_REGEX directly without joining. A count of non-empty entries on the request gates it, and anything that isn't exactly "" falls back to the existing path, so the fast path is equivalent by construction rather than an approximation.

Two behaviours preserved deliberately:

  • The microtask that resets max call stack size every 300 routes. It relied on await of a non-promise value; it is now Promise.resolve().then() on the same condition. _preprocessRequest is still called exactly once per route and does not touch _paramCallbacks.size or routeCount, so evaluating the condition after the call is safe.
  • Rejection semantics. Under async, a synchronous throw became a rejected promise. _dispatchRoute is a plain function, so the recursion point is wrapped and rejects instead of letting the throw escape up the stack.

Also added the !== EMPTY_REGEX guard on the pop branch, which the push branch already had.

A chain of N middlewares cost N promises. _routeRequest was async and also
returned `new Promise`, so every hop allocated two, and advancing to the next
route did `resolve(this._routeRequest(...))` - resolving a promise with a
promise, which adds an adoption per hop that has to unwind back up the chain.

_routeRequest is now a thin wrapper that allocates one promise for the whole
chain and hands resolve/reject to _dispatchRoute, which walks the routes with
plain calls. Two things are kept deliberately: the microtask that resets max
call stack size every 300 routes, now driven by Promise.resolve().then() on the
same condition, and the rejection semantics - the recursion point is wrapped so
a synchronous throw still rejects instead of escaping up the stack.

getFullMountpath also joined the entire mount stack on every hop, which is
quadratic in the number of middlewares. Path-less app.use() pushes "", and
patternToRegex("", true) returns EMPTY_REGEX, so a stack of only those can skip
the join and return EMPTY_REGEX directly. Tracked with a count of non-empty
entries on the request, so anything that isn't exactly "" falls back to the
existing path.

Measured on a 100-middleware chain, 9 paired runs: 618 -> 225 ns per hop,
9.6k -> 15.5k req/sec (1.41x median). 10 middlewares: 1.069x, 8 of 9 pairs
favourable. No middlewares: 1.009x, unchanged.
@nigrosimone

Copy link
Copy Markdown
Contributor Author

Benchmark Comparison

Test Express req/sec uExpress req/sec Express throughput uExpress throughput uExpress speedup
engines/art 5.54k 7.36k 2.43 MB/sec 3.23 MB/sec 1.33x
middlewares/body-json-512kb 949.91 973.44 155.85 KB/sec 160.66 KB/sec 1.03x
middlewares/body-urlencoded 10.35k 20.59k 1.61 MB/sec 3.22 MB/sec 2.00x
middlewares/compression-file 6.72k 7.13k 3.07 MB/sec 3.26 MB/sec 1.06x
middlewares/express-static 2.86k 5.30k 699.17 MB/sec 1.26 GB/sec 1.85x
routing/hello-world 15.61k 64.04k 2.59 MB/sec 10.69 MB/sec 4.13x
routing/middlewares-100 18.34k 25.05k 2.87 MB/sec 3.94 MB/sec 1.37x
routing/nested-routers 14.41k 52.69k 2.31 MB/sec 8.49 MB/sec 3.68x
routing/routes-1000 4.77k 40.68k 763.29 KB/sec 6.40 MB/sec 8.59x
streaming/readable-hash-4mb 230.24 256.05 51.26 KB/sec 57.26 KB/sec 1.12x
streaming/writable-no-content-length 551.82 591.92 2.70 GB/sec 2.89 GB/sec 1.07x
streaming/writable-with-content-length 558.45 641.58 2.73 GB/sec 3.14 GB/sec 1.15x

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants