HTTP router on a radix tree. No dependencies, no knowledge of req/res.
import { Router } from 'radixrouter';
const router = new Router();
router.get('/users/:id', handler);
router.lookup('GET', '/users/42?q=abcd');
// → { pattern: '/users/:id', params: { id: '42' }, handler }
// → null when nothing matchesThree fields, each earning its place. handler is whatever you registered. params holds
the values pulled out of the path. pattern is the route template, not the concrete
URL — that is what metrics and spans get tagged with, and tagging them with /users/42
makes a metric's cardinality equal to your user count.
npm i radixrouterNode 20+.
The router never calls the handler — it stores it and hands it back. What lives in there, and how it gets invoked, is the application's business.
import http from 'node:http';
import { Router } from 'radixrouter';
const router = new Router();
router.get('/health', (req, res) => res.end('ok'));
router.get('/users/:id', (req, res, params) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ id: params.id }));
});
router.post('/users', (req, res) => res.writeHead(201).end());
http.createServer((req, res) => {
const match = router.lookup(req.method, req.url);
if (match !== null) {
// the template, not the URL: a metrics tag with bounded cardinality
res.setHeader('X-Route', match.pattern);
return match.handler(req, res, match.params);
}
const allowed = router.allowedMethods(req.url);
if (allowed.length > 0) {
res.writeHead(405, { Allow: allowed.join(', ') }).end();
return;
}
res.writeHead(404).end();
}).listen(3000);| Option | Default | Effect |
|---|---|---|
maxPathLength |
8192 |
a longer path misses immediately, without walking; the query string does not count |
headFallback |
true |
a HEAD request with no route of its own uses the GET handler |
implicitOptions |
true |
allowedMethods includes OPTIONS |
Registers a route. Shorthands: get, post, patch, put, delete.
handler is stored verbatim — a function, an array of middleware, an object carrying a
schema, anything. The method is upper-cased.
Throws on malformed percent-encoding, on a wildcard that is not the last segment, and on registering the same method twice for the same route:
Duplicate route: GET /users/:userId conflicts with /users/:id
The message names both spellings because they are one route: parameter names are not part of a route's identity.
A parameter name may contain only letters, digits, _ and $. A static suffix after a
parameter — /:file.json — is rejected rather than quietly read as a parameter named
file.json that swallows the whole segment.
The params object of every route is assembled by a small function compiled at
registration with new Function: the names become literal keys, which V8 stores as named
properties instead of going through a keyed store — worth ~10% on routes with two
parameters. Under --disallow-code-generation-from-strings, or a CSP without
'unsafe-eval', the builder falls back to a plain loop: routes with several parameters
get ~10% slower, nothing else changes.
Returns { pattern, params, handler } or null. The url may carry a query string — it
is cut off. method is expected upper-case, the way it arrives in req.method.
params has a null prototype, so a __proto__ or constructor segment coming from a
URL cannot reach inherited properties.
The sorted list of methods registered on this path. An empty array means a genuine 404;
a non-empty one means 405 with an Allow header.
It is a separate call rather than a field on the lookup result because every method has
its own tree: finding out who else answers on this path means walking each of them. For
/users/me, with GET /users/me and POST /users/:id registered, the answer is
['GET', 'POST']. Only the request that missed pays for it.
HEAD is added when GET is registered, and OPTIONS is added whenever the path matched
anything — an Allow header should list what the server will actually answer. Both can be
turned off through the constructor options.
Re-registers the child router's routes under a prefix, which may itself contain parameters:
const users = new Router();
users.get('/', list);
users.get('/:id', one);
const root = new Router();
root.mount('/orgs/:orgId/users', users);
root.lookup('GET', '/orgs/7/users/42');
// → pattern '/orgs/:orgId/users/:id', params { orgId: '7', id: '42' }It is a snapshot, not a live link: routes added to the child afterwards do not appear in the parent. A parameter name colliding between the prefix and the child throws.
The joined pattern is normalised rather than concatenated blindly: nobody typed it by
hand, and it ends up in metrics, where /api/users and /api/users/ would be two series
for one endpoint.
Removes a route, returns whether there was one. Parameter names are not part of a route's
identity, so off('GET', '/users/:id') also removes /users/:userId.
Removal rebuilds the tree from the registration log: splitting a node is easy to do incrementally and impossible to undo the same way.
Drops every route.
Registered routes as { method, pattern, handler }, in registration order.
Instance fields such as trees, treeGET and registered are implementation details,
not API: they are deliberately absent from the type declarations, and a minor release may
rename them.
The trees as text, one per method — which is where the compression becomes visible:
GET
/
api/v1/
users [GET]
/
:param [GET]
orgs [GET]
files/
*wildcard [GET]
Four routes share one api/v1/ node instead of three per-segment ones.
| Syntax | Matches | Example |
|---|---|---|
users |
that exact segment | /users |
:name |
any single segment | /users/:id → params.id |
*name |
the whole remainder | /files/*rest → params.rest |
* |
same, named '*' |
/files/* → params['*'] |
A wildcard must occupy the entire last segment — /a/*/b and /a*b are rejected at
registration.
A static prefix before a parameter works — /user:name matches /userBob. A static
suffix after one does not: /:file.json is rejected at registration.
Regex constraints on parameters (:id(\\d+)) and multiple parameters inside one segment
(:file.:ext) are not supported: both drag matching back to linear scanning and
defeat the point of a tree.
Branch priority: static → :param → wildcard.
Backtracking is mandatory. When a static branch matches but dead-ends deeper, the search returns and tries the parametric one:
| Registered | Request | Result |
|---|---|---|
/a/:b/c, /a/x/d |
/a/x/c |
/a/:b/c, { b: 'x' } |
/users/me, /users/:id |
/users/me |
/users/me — static wins |
/a/:b/stop, /a/* |
/a/x/go |
/a/*, { '*': 'x/go' } |
Every method has its own tree. With GET /users/me and POST /users/:id
registered, POST /users/me resolves to /users/:id with { id: 'me' }: the POST tree
has no me node, so there is nothing to check at the end and nothing to get wrong. Routes
of one method never shape the tree of another — a POST /users/:id/promote does not split
GET's /members node into / and members, and does not cost a GET request a hop.
HEAD is the one method that looks at two trees: its own first, in full, then GET's when
headFallback is on. An explicit HEAD /users/:id therefore beats an implicit
GET /users/me for HEAD /users/me — a HEAD route you wrote wins over one you did not.
Parameter names belong to the route, not the node. /users/:id and /users/:userId
are one route — registering both for the same method throws — and different methods may
name the parameter differently:
router.get('/users/:id', a);
router.post('/users/:userId', b);
router.lookup('GET', '/users/42'); // { id: '42' }
router.lookup('POST', '/users/42'); // { userId: '42' }A leading slash is required; trailing and repeated slashes are insignificant, so /users,
/users/ and //users// are one route. Registration and lookup prepare the path with the
same function — otherwise the two would drift apart.
Percent-encoding is resolved in two passes. Decoding everything up front is unsafe:
a %2F inside a value would turn into a separator, and /files/a%2F..%2F..%2Fetc%2Fpasswd
would climb out of its subtree. Leaving everything encoded is equally wrong: a browser
sends /café/42 as /caf%C3%A9/42, and the static segment would never match.
So: decodeURI over the whole path — non-ASCII and spaces come out, reserved characters
stay encoded — then decodeURIComponent over each parameter value.
router.get('/café/:id', h);
router.lookup('GET', '/caf%C3%A9/42'); // → { id: '42' }
router.get('/files/:name', h);
router.lookup('GET', '/files/a%2Fb.txt'); // → { name: 'a/b.txt' }Malformed encoding yields null from lookup and throws from add.
Parameter names are inferred from the path literal, wildcards included:
router.get('/orgs/:orgId/files/*rest', (req, res, params) => {
params.orgId; // string
params.rest; // string
params.userId; // compile error
});The precision exists exactly where the path is a literal — at registration. By lookup
time the path is a runtime string, and nothing more specific than
Record<string, string> can be said about params.
By default the handler is typed (req, res, params) => unknown using node:http types.
To store something other than a function, name your own type:
const router = new Router<{ fn: Handler; schema: Schema }>();215 routes modelled on a real REST API, Node 24.15, best of 5 rounds of 1M iterations. Millions of operations per second:
| request | ops/s |
|---|---|
| static, shallow | 23.8M |
| static, deep | 6.4M |
| one parameter | 6.5M |
| parameter + sub-resource | 4.8M |
| two parameters | 4.5M |
| backtrack three levels | 5.4M |
| path miss | 11.6M |
| method miss | 6.6M |
| registering 215 routes | 0.36 ms |
| heap per router | 179 KB |
The first version was 30–50% slower on every row, and the gap was closed by changing one
thing at a time and measuring each: static children in two parallel arrays instead of a
Map (~30% on every row — Map.get hashes and probes, a scan over a handful of integers
is a few compares); one tree per method instead of one shared tree (routes of other
methods no longer split static chains, and a method miss no longer walks the whole path);
a root node that is / itself rather than a parent of one; and the compiled params
builder. Things that measured as noise or worse were left out: scanning the path with
indexOf instead of a character loop (four calls cost more than the loop on
20-character paths), a flat backtracking stack, a shared values array (−40%: it escapes
the function and defeats escape analysis).
The bench warms the router with every query shape before measuring: V8 otherwise specialises the walk to the one shape it has seen and reports a number a server would never get.
- host and version constraints — pick a route by request header, not by path alone
- a static suffix after a parameter (
/:file.json)
npm test # 87 tests, differential check included
npm run typecheck
npm run bench
node examples/router.js # API tour: mount, wildcards, 405, the treeCI runs the suite on Node 20, 22 and 24 — the minimum declared in engines, the current
release, and the one in between, since both .ts execution and the test runner's
behaviour shift across that range.
The differential test runs 300 random route tables and 12,000 requests through this
router and an independent reference implementation and compares pattern together with
params. The generator is
deterministic, so a failure reproduces. It is what catches disagreements about priority
and backtracking depth — the kind of thing you would never think to write a targeted test
for.
src/router.d.ts is not checked against src/router.js automatically: TypeScript treats
the declaration as the truth and never looks at the implementation beside it.
test-d/types.ts guards against the drift by using the API exactly as a consumer of the
package would.
MIT