Routing From Scratch, and What a Framework Adds

The pull request was called “tidy up routes”. It sorted the route registrations alphabetically. Nothing else. No logic touched, forty-one lines moved, CI green, approved in ninety seconds.

By the next morning nobody could sign up.

POST /users            200  ok
GET  /users/new        500  invalid input syntax for type uuid: "new"

Here is the whole bug. In ASCII, : is 58 and n is 110. So when you sort route strings alphabetically:

['/users/new', '/users/:id', '/users/:id/posts'].sort()
// → ['/users/:id', '/users/:id/posts', '/users/new']

/users/:id moved above /users/new. The router walks its list top to bottom and stops at the first pattern that matches, so GET /users/new hit the :id route, bound id = "new", and handed the string "new" to a query expecting a UUID. Postgres did the only sensible thing and threw.

Nothing in the test suite requested /users/new. It was a plain page route added eight months earlier and nobody had thought about it since.

That bug is not about sorting. It is about the fact that routing looks like configuration and behaves like an algorithm, and if you cannot say which algorithm your router runs, you cannot predict what it will do. So let’s build one, break it in the same way, and then look at what Express, Fastify and Hono each decided to do about it.

A router is one function

Strip everything away and a router is a function that takes a request and picks some code to run.

import http from 'node:http';

http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/health') return health(req, res);
  if (req.method === 'GET' && req.url === '/users') return listUsers(req, res);
  if (req.method === 'POST' && req.url === '/users') return createUser(req, res);

  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');
}).listen(3000);

That is a router. Express, Fastify and Hono are all elaborations of those six lines. (If req, res and res.end are new, How an HTTP Server Actually Works covers the socket underneath.)

It already has a bug, and it is the same bug everyone writes once.

req.url is not the path. Node hands you the raw request target from the request line, which includes the query string. So GET /health?debug=1 compares '/health?debug=1' === '/health', gets false, and 404s a perfectly good health check. Someone will find this by pasting a URL with a ?utm_source= on the end.

Split the path off before you match anything:

const q = req.url.indexOf('?');
const path = q === -1 ? req.url : req.url.slice(0, q);

Or let the platform do it. URL needs an absolute URL, so give it a base (see URL objects for the full API):

const url = new URL(req.url, `http://${req.headers.host}`);
url.pathname;                     // '/health'
url.searchParams.get('debug');    // '1'

Two things are now clear that the if-chain was hiding. The router needs exactly two inputs, method and path. And the query string is not one of them.

the request line, straight off the socketGET /users/42?tab=postsmethodpath (the key)query (never part of the key)the routing tableGET/healthhealthzGET/userslistUsersPOST/userscreateUserGET/users/:idgetUsermatchGET/users/:id/postslistUserPostsrow 4 wins: handler getUser, params { id: 42 }?tab=posts is the handler’s business, not the router’s
A router is a lookup from method plus path to a handler. The query string rides along and never takes part in the match.

From a chain of ifs to a table

The if-chain has a shape hiding inside it: a key, and a value. Make it literal.

const routes = new Map([
  ['GET /health', health],
  ['GET /users', listUsers],
  ['POST /users', createUser],
]);

const handler = routes.get(req.method + ' ' + path);
if (handler) return handler(req, res);

Lookup is now a single hash probe. Ten routes or ten thousand, same cost.

This is not a toy step you skip past on the way to something clever. Real routers keep exactly this fast path. A pattern with no : and no * in it has nothing to match, it is a string equality test, and a hash map does string equality better than any loop you can write.

The limit is obvious though. /users/42 is not a key in that map and never will be, because 42 is data.

Patterns and params

You want to say “/users/ followed by anything, and call that thing id”. Compile that once, when the route is registered, not on every request.

function compile(pattern) {
  return pattern
    .split('/')
    .filter(Boolean)
    .map((seg) => (seg[0] === ':' ? { param: seg.slice(1) } : { literal: seg }));
}

compile('/users/:id/posts');
// [ { literal: 'users' }, { param: 'id' }, { literal: 'posts' } ]

Matching is then a walk down two arrays at once.

function match(tokens, segments) {
  if (tokens.length !== segments.length) return null;

  const params = {};
  for (let i = 0; i < tokens.length; i++) {
    const t = tokens[i];
    if (t.literal !== undefined) {
      if (t.literal !== segments[i]) return null;
    } else {
      params[t.param] = decodeURIComponent(segments[i]);
    }
  }
  return params;
}

Line 2 does most of the work. Segment counts differ, done, no match, zero comparisons. Lines 8 and 9 are the whole idea: a literal must be equal, a param captures whatever is there.

patternusers:idpostsequalcaptureequalrequestusers42postsparams = { id: 42 }the cheap check comes first: 3 pattern segments against 3 path segments.different counts, no match, not a single comparison done.
Compile the pattern into segments once, then walk it against the request path. Literals must be equal; a param captures.

Register and look up:

const routes = [];

function add(method, pattern, handler) {
  routes.push({ method, pattern, tokens: compile(pattern), handler });
}

function find(method, path) {
  const segments = path.split('/').filter(Boolean);
  for (const route of routes) {
    if (route.method !== method) continue;
    const params = match(route.tokens, segments);
    if (params) return { handler: route.handler, params };
  }
  return null;
}

Read for (const route of routes) again, slowly. First match wins, and “first” means “registered first”. You just wrote the alphabetical-sort bug from the top of this page, in six lines, on purpose.

Before we fix that, two smaller landmines in match.

Wildcards

Some routes need the tail rather than one segment: static files, a catch-all, a proxy prefix. Add a third token type, and require it to be last.

function compile(pattern) {
  return pattern.split('/').filter(Boolean).map((seg) => {
    if (seg[0] === ':') return { param: seg.slice(1) };
    if (seg[0] === '*') return { wildcard: seg.slice(1) || 'rest' };
    return { literal: seg };
  });
}

The length check has to bend, because a wildcard matches one segment, or ten, or zero:

function match(tokens, segments) {
  const last = tokens[tokens.length - 1];
  const greedy = last !== undefined && last.wildcard !== undefined;

  if (greedy ? segments.length < tokens.length - 1
             : segments.length !== tokens.length) return null;

  const params = {};
  for (let i = 0; i < tokens.length; i++) {
    const t = tokens[i];
    if (t.literal !== undefined) {
      if (t.literal !== segments[i]) return null;
    } else if (t.wildcard !== undefined) {
      params[t.wildcard] = segments.slice(i);   // an array, on purpose
      return params;
    } else {
      const v = safeDecode(segments[i]);
      if (v === null) return null;
      params[t.param] = v;
    }
  }
  return params;
}

Note that the wildcard captures an array of segments, not a joined string. That is deliberate, and Express 5 made the same call: GET /foo/bar against /*splat gives you &#123; splat: ['foo', 'bar'] &#125;. A joined string is an invitation:

// do not do this
app.get('/files/*rest', (req, res) => {
  res.sendFile(path.join('/srv/files', req.params.rest));
});

GET /files/..%2f..%2fetc/passwd decodes per-segment to ../../etc plus passwd, path.join cheerfully walks up out of /srv/files, and you are serving /etc/passwd. An array makes you think about each segment. If you build a filesystem path from user input at all, resolve it and then check containment:

const root = '/srv/files';
const full = path.resolve(root, ...req.params.rest);
if (full !== root && !full.startsWith(root + path.sep)) {
  return res.writeHead(403).end();
}

Precedence, which is where it actually hurts

Now the interesting part. Given GET /users/new and a table containing both /users/new and /users/:id, which one runs?

There are two answers in the wild, and they are genuinely different philosophies.

✗ :id registered first✓ new registered firstroutes, in registration order1 GET /users/:id2 GET /users/newrequestGET /users/new→ row 1 matches. params { id: new }→ row 2 is unreachable code500 invalid input syntax for uuid(and /users/42 still works fine)routes, in registration order1 GET /users/new2 GET /users/:idrequestGET /users/new→ row 1 matches exactly→ row 2 still catches /users/42200 the new-user form(the file order is now load-bearing)On a registration-order router (Express, Hono) the order of the list is the algorithm.A specificity router (Fastify) picks the static branch either way, whatever the order.
On a registration-order router, the order of your route file is the algorithm. Put the param route first and the static route underneath it is dead code.

Registration order. Express does this. Hono does this. Your six-line find above does this. The router keeps an array, walks it, first match wins. It is trivially predictable if you can see the whole list on one screen, which you cannot, because your routes live in twelve files whose registration order is decided by the order of some import statements that a formatter is allowed to move.

Specificity. Fastify’s router does this. It sorts by how specific a pattern is, not by when you typed it, and the order is fixed: static first, then a parametric segment with a static ending (/:file.png), then regex and multi-param segments, then a plain parametric segment, then a wildcard last. /users/new beats /users/:id no matter which one you registered first. Register the same method and path twice and it refuses to boot instead of silently shadowing.

I have an opinion here, and it is not a close call. Specificity is the better default, and “it threw at startup” is a feature, not rudeness. A shadowed route on a registration-order router is a bug that compiles, passes review, passes tests, and waits.

But you rarely get to pick the router you inherited. So the practical move is: know which one you are on, and if it is registration order, stop relying on discipline and write the check.

// Boot-time guard: is a fully static route shadowed by an earlier pattern?
function findShadowed(routes) {
  const problems = [];

  for (let i = 0; i < routes.length; i++) {
    const later = routes[i];
    // only a fully static route has an unambiguous example path
    if (!later.tokens.every((t) => t.literal !== undefined)) continue;
    const example = later.tokens.map((t) => t.literal);

    for (let j = 0; j < i; j++) {
      const earlier = routes[j];
      if (earlier.method !== later.method) continue;
      if (match(earlier.tokens, example)) {
        problems.push(`${later.method} ${later.pattern} is shadowed by ${earlier.pattern}`);
      }
    }
  }
  return problems;
}

const problems = findShadowed(routes);
if (problems.length) throw new Error('shadowed routes:\n' + problems.join('\n'));

Twenty lines, deliberately conservative (it only reasons about fully static routes, where “would this ever match” has an exact answer). It runs once at startup and it costs nothing. It would have failed the “tidy up routes” pull request in CI, which is roughly seven hours before a customer found it.

Here is the whole thing, live. The table below has /users/:id registered before /users/new, exactly like the bad deploy. Type a URL, flip the strategy, and watch which route wins.

interactiveA route matcher: patterns in, winner and params out

Leave it on registration order and /users/new matches /users/:id with id = "new", while the route you actually wanted sits below it marked as never running. Flip to specificity and the static route wins without you touching a line. That toggle is the entire difference between Express and Fastify on this point.

Route params vs query params

The matcher only ever sees the path, which makes the design rule sharper than most people state it.

The test that settles arguments: remove it, and ask whether what is left is still a valid request for something. Drop 42 from /users/42 and /users/ is a different resource entirely. Drop page=2 from /users?page=2 and you still have the users list, just page one. So id belongs in the path and page belongs in the query.

The tell that you got it wrong is an optional route param. /users/:id? means one route doing two jobs, and both jobs will grow different auth rules, different caching, and different response shapes. Express 5 removed ? for optionals outright (you now write /users{/:id} with braces), which had the pleasant side effect of making people notice how many of those they had.

Read the query with URLSearchParams and read it carefully:

const url = new URL(req.url, `http://${req.headers.host}`);

url.pathname;                    // '/users'   (still percent-encoded, which is what you want)
url.searchParams.get('page');    // '2'
url.searchParams.getAll('tag');  // ['a', 'b']   for ?tag=a&tag=b

get returns the first value and silently drops the rest. That is the number one query bug in server code: ?tag=a&tag=b arrives, your validator sees 'a', and the request quietly does the wrong thing.

The object-style parsers are worse, because the type changes based on user input:

import qs from 'node:querystring';

qs.parse('tag=a');        // { tag: 'a' }        ← string
qs.parse('tag=a&tag=b');  // { tag: ['a','b'] }  ← array

One is a string, the other is an array, and an attacker picks which. Any code that does req.query.tag.toLowerCase() throws on the second. Any validator written for a string either throws or gets skipped. Express 5 changed its default query parser from extended (the qs library, with nested objects) to simple (Node’s querystring), which shrinks the blast radius but does not remove this: both shapes still flip. Normalise at the edge of your handler and be explicit about arity.

Why fast routers use a tree

Look again at what the linear scan costs. Every request walks the array and runs a pattern test per route until something hits. Fifty routes, up to fifty tests. Two hundred routes, up to two hundred. Express works exactly like this: its router keeps a stack of layers, next() walks them in order, and each layer runs its own compiled matcher.

A radix tree (a prefix tree with single-child chains collapsed) throws away the route-count term entirely. You do not test routes. You walk the path.

looking up GET /users/42/posts in a table of four routeslinear scantest each pattern in turn/health/users/users/:id/users/:id/posts4 pattern tests for 1 request200 routes: up to 200 testscost grows with the route tableradix treewalk the path once/healthusers/new:id/postsstatic branch,tried before :idThe tree tries static branches before parametric ones, and wildcards last of all.Precedence falls out of the structure. It does not depend on registration order.
Linear scan tests each route until one hits, so cost tracks the route count. A radix tree walks the path once and the branch order gives you precedence for free.

Two things fall out of that picture at once, and the second one matters more than the speed.

Lookup cost stops tracking the route count. It tracks the length of the path you are matching. Adding your three-hundredth route costs the tree a node, not a comparison per request.

Precedence stops being your problem. The static branch physically sits before the parametric branch at that node. There is nowhere for registration order to enter. This is why Fastify’s precedence rules feel like laws rather than conventions: they are the shape of the data structure.

There is a third design worth knowing because it is genuinely different. Hono’s RegExpRouter compiles every route into one large regular expression at startup, so a lookup is a single regex execution regardless of how many routes you registered. It cannot express every pattern, so Hono’s default SmartRouter tries it, falls back to TrieRouter for route sets it cannot compile, and caches the decision after the first request.

Hono also ships LinearRouter, which is the naive thing on purpose. It exists for one-shot environments where the process is torn down after a request or two, so registration cost dominates and query cost barely matters. Spending two milliseconds building a big regex is a fine trade when you serve ten thousand requests with it and a terrible one when you serve one. That tradeoff is real on edge runtimes; see Edge and serverless runtimes for why those processes are so short-lived.

404 is not 405

Go back to find. It returns null, and you write 404. But there are two different nulls in there and they mean opposite things to whoever is calling you.

  • No route has this path at all. That is 404 Not Found. The URL is wrong.
  • A route has this path, just not with this method. That is 405 Method Not Allowed. The URL is right, the verb is wrong.
match the PATH, ignore the methoddid any route match?no404 Not Foundthis URL does not existyesdoes the set allow this method?no405 Method Not AllowedAllow: GET, PUT, DELETEyesrun the handlerThe set of methods that match the path IS the Allow header. RFC 9110 says a 405 MUST carry it.The same set answers OPTIONS: reply 204 (or 200) with that Allow list and no body.
Match the path first, ignoring the method. The set you get back decides between 404, 405 and running the handler, and it is also the Allow header.

RFC 9110 is not gentle about this: the origin server MUST generate an Allow header on a 405, listing the methods that resource does support. A bare 405 with no Allow is a spec violation and it is also useless, because it tells the caller “not that verb” without telling them which verb.

Why bother, beyond correctness?

  • It halves someone’s debugging time. A 404 sends an integrator hunting for a typo in the URL. A 405 with Allow: GET, DELETE tells them in one line that they sent PUT to a resource that does not take PUT.
  • CORS preflight depends on it. A browser sends OPTIONS before a cross-origin PUT or a DELETE. If your router 404s that OPTIONS, the preflight fails and the real request never leaves the browser. See Fetch: Cross-Origin Requests.
  • Your dashboards mean different things. A spike in 404s is a broken link or a scanner. A spike in 405s is a client that changed.

The restructure is small. Match the path first, collect the methods, then decide.

function find(method, path) {
  const segments = path.split('/').filter(Boolean);
  const allow = new Set();
  let hit = null;

  for (const route of routes) {
    const params = match(route.tokens, segments);
    if (!params) continue;              // path did not match at all
    allow.add(route.method);
    if (route.method === method && !hit) hit = { handler: route.handler, params };
  }

  if (hit) return hit;
  if (allow.size === 0) return { status: 404 };
  if (allow.has('GET')) allow.add('HEAD');
  return { status: 405, allow: [...allow].sort() };
}

Notice the price you just paid. The old loop could skip a route on the method check before doing any matching work. This one cannot, because it needs the full set of methods for the path. On a linear router, a correct 405 costs you a full scan on every request.

On a tree router it costs nothing. You walk to the node, and the node holds a small map of method to handler. The set of allowed methods is sitting right there. Correct 405 handling is one of the quieter arguments for the tree.

Mounting one router inside another

Nobody keeps three hundred routes in one array. You group them, and grouping means a router that contains routers.

The whole trick is prefix stripping. A sub-router registered at /api/users should be written as if it owns the root: it declares /, /:id, /:id/posts, and knows nothing about the prefix.

function createRouter() {
  const routes = [];
  const mounts = [];

  return {
    add(method, pattern, handler) {
      routes.push({ method, pattern, tokens: compile(pattern), handler });
    },
    mount(prefix, child) {
      mounts.push({ segments: prefix.split('/').filter(Boolean), child });
    },
    find(method, segments) {
      for (const m of mounts) {
        const head = segments.slice(0, m.segments.length);
        if (head.join('/') !== m.segments.join('/')) continue;
        // hand the child only the tail
        const found = m.child.find(method, segments.slice(m.segments.length));
        if (found) return found;
      }
      for (const route of routes) {
        const params = match(route.tokens, segments);
        if (params) return { handler: route.handler, params };
      }
      return null;
    },
  };
}

const users = createRouter();
users.add('GET', '/', listUsers);
users.add('GET', '/:id', getUser);

const app = createRouter();
app.mount('/api/users', users);

segments.slice(m.segments.length) is the entire abstraction. Everything a mounting system does is this plus bookkeeping.

That bookkeeping is where the leaks are. Express implements the same idea by rewriting req.url while the sub-router runs and stashing the prefix on req.baseUrl, restoring both on the way out. Which means, inside a mounted Express router:

// mounted at /api/users, request GET /api/users/42
req.url;          // '/42'            ← rewritten
req.baseUrl;      // '/api/users'
req.originalUrl;  // '/api/users/42'  ← the only one that is the truth

Log req.url from inside a mounted router and your access logs quietly lose the prefix. Build a redirect or a Location header out of it and you point the browser at a URL that does not exist. Use req.originalUrl when you mean “what the client asked for” and req.url only when you mean “what is left for this router to match”.

The other thing mounting brings is ordering across groups, and it is a live footgun on registration-order routers. Hono’s docs call it out directly: mount a sub-app before you have finished adding routes to it and those routes simply are not there, because route() copies what exists at that moment. Build the child fully, then mount.

The same routes, three frameworks

Here is the whole thing you just built, expressed three ways. This is the point of the exercise: you should now be able to look at each of these and name what has been abstracted, and what has quietly been decided for you.

The shape is identical in all three, which is the real lesson. method, pattern, handler. Everything else is a policy decision about precedence, about what happens on a duplicate, and about whether you get a 405. Those are the questions to ask of a router, and none of them show up on a benchmark chart.

Summary

  • A router is a lookup from (method, path) to a handler. Everything else is elaboration.
  • req.url is not the path. It carries the query string. Split at ? or use new URL(req.url, base) before you match anything (URL objects).
  • Static routes want a hash map. Patterns want compiling once, at registration, into segment tokens, then a walk down two arrays.
  • Decode per segment, never the whole path, because %2F is data and splitting after decoding turns it into structure. decodeURIComponent throws on malformed input, so guard it and answer 400, not 500.
  • Precedence has two philosophies. Registration order (Express, Hono): first match wins, so a param route above a static route makes it unreachable. Specificity (Fastify): static beats parametric beats wildcard, whatever the order. Know which one you are on, and add a boot-time shadowing check if it is the former.
  • Path identifies, query modifies. If removing it still leaves a valid request for something, it belongs in the query. An optional route param is one route doing two jobs. Watch getAll vs get, and watch parsers whose value type flips between string and array based on user input.
  • Radix trees drop the route-count term from lookup, but the better reason to want one is that precedence becomes a property of the structure instead of a property of your file order. The throughput usually vanishes behind your database.
  • 404 and 405 are different answers. Match the path first, ignoring the method: empty set means 404, non-empty set that excludes the method means 405 and RFC 9110 says it MUST carry an Allow header. That set is also your OPTIONS response, and CORS preflight depends on it.
  • Mounting is prefix stripping, so compare whole segments (/api must not match /apiary), and remember Express rewrites req.url inside a mounted router while req.originalUrl keeps the truth.
  • Ask a router three questions: what wins on a tie, what happens on a duplicate, and do I get a 405. The answers matter far more than the benchmark.