perf(router): index route matching for large route tables - #135
Open
gerardp wants to merge 3 commits into
Open
Conversation
The existing benchmark serves a single static route, so it measures the request pipeline rather than route matching. This workload registers many routes and measures first, last, alternating and varied URLs against both AdonisJS and Fastify, with Fastify acting as a control for machine drift. The route table is built in one of two shapes. `uniform` declares every static route before any dynamic one and has no catch-all. `app` places a dynamic route early among the static ones and closes with a top level param route and a catch-all, mirroring how routes accumulate in an application. Matching is sensitive to both, and to whether a scenario repeats one URL or spreads over the table, so all of them are measured.
Matching scanned every registered route in order on every request, so throughput depended on where a route sat in the table. With 1,000 static routes a request for the last one served 51,492 req/s against 146,531 for the first. Two indexes are added to the store, and the scan is kept for tables small enough not to need them. - Static routes join a hash table keyed by their pattern, unless an earlier route matches that pattern. Only actual shadowing keeps a route out, so declaration order does not decide whether the table is populated. - Routes are grouped by their leading static segments, and a request only consults the groups whose prefix its URL starts with. Groups also carry the routes that have no static prefix, so a top level param route or a catch-all does not force a second pass. - A one entry memo answers a repeated URL per domain and method. - Below 64 routes for a domain and method the store scans every token array, because slicing the path at each segment boundary costs more than a scan of a short table. Registration order still decides which route wins. Each group is matched on its own and the winner with the lowest registration index is selected, which resolves to the same route as matching the whole table in order. Regex matchers, casts, optional parameters and wildcards are unaffected. The matching indexes on `StoreMethodNode` are optional properties. The type is re-exported from the public `./types` entry point, so making them required would break code constructing that shape.
Records both table shapes. The large uniform table shows the route position penalty disappearing. The small app shaped table is measured against an intermediate revision of this work as well, because the static prefix index on its own regressed that case by up to 4% and the original workload could not see it.
gerardp
force-pushed
the
perf/router-hot-path
branch
2 times, most recently
from
August 17, 2026 20:34
cf4627a to
560ed64
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Route matching scanned every registered route in order on every request, so throughput depended on where a route sat in the table. With 1,000 static routes, a request for the last one served 51,492 req/s while a request for the first served 146,531.
This adds two indexes to the store and keeps the scan for tables small enough not to need them.
/:paramor/*route does not force a second pass.Registration order still decides which route wins. Each group is matched on its own and the winner with the lowest registration index is selected, which resolves to the same route as matching the whole table in order. Regex matchers, casts, optional parameters and wildcards are unaffected.
Results
Large table, 1,000 static and 100 dynamic routes:
Small table, 83 routes shaped like an application's route file:
Fastify runs as a control in every measurement and its numbers are recorded alongside. Full tables, the control, the noise floor and the raw JSON are in
benchmarks/results/router-comparison.md.Benchmark changes
The original workload declared every static route before any dynamic one, had no catch-all, and repeated a single URL per scenario. That combination hides what matching costs, and an earlier revision of this branch regressed small tables by up to 4% without the benchmark noticing.
BENCH_SHAPE=appnow builds a table that places a dynamic route early among the static ones and closes with a top level param route and a catch-all. Thevaried-*scenarios spread requests over 25 paths across the table, so per-URL memoisation cannot answer them. The comparison document records that regressing revision next to the fixed one.Notes for review
StoreMethodNodeare optional properties. The type is re-exported from the public./typesentry point, so making them required would break any code constructing that shape.matchit.match(url, <every token array in registration order>)over adversarial shadowing tables, generated tables, andadd()interleaved withmatch(), in bothshouldDecodeParammodes. No mismatches.