How a Route Becomes a Function Call
A route table is a lookup structure over method and path; everything else about routing follows from which structure your framework chose.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What happens between POST /orders/42/items arriving as bytes and your handler function being invoked?
The product has features. Clients need addresses for them. Somebody has to turn "place an item on order 42" into a function that runs on a server.
The framework gives you app.post(path, handler). You list your routes, it matches the incoming request to one of them, and your function runs. Routing is configuration, not engineering.
A request returns 404 and the handler you are staring at is never entered — because another route matched first, or because the method did not match and the framework reported 404 instead of 405.
- A request returns 404 and the handler you are staring at is never entered — because another route matched first, or because the method did not match and the framework reported 404 instead of 405.
/orders/42works and/orders/42/returns 404, or redirects, or redirects and turns your POST into a GET, depending on the framework and one configuration flag.- A path containing
%2Fmatches a route it should not, because decoding happened before the path was split into segments (Path Parameters). - Metrics explode: every request is labelled with the raw path, so
/orders/42and/orders/43are separate time series and the dashboard costs more than the service (The Metrics a Backend Must Emit). - At 600 routes, a framework that walks an ordered list and runs a regex per entry spends measurable CPU per request deciding what to call — and the cost is highest for the routes registered last, which are usually the newest.
What is actually happening
- The request line gives you two things the router cares about: a method and a request target. Everything after
?is the query string and is not part of matching (Query Parameters). - The target is normalised before matching. What "normalised" means is a decision your stack made for you: percent-decoding, collapsing duplicate slashes, resolving
.and.., case folding, trailing-slash handling. Two frameworks in front of the same application can disagree here, which is how a proxy and an app end up routing the same bytes to different places. - The router matches against a route table — a data structure built at startup from your registrations. The two common shapes are an ordered list of compiled patterns tried in turn, and a prefix tree (a radix trie) walked segment by segment (Trie).
- Matching produces three things: the handler, the captured path parameters, and — critically — the route template (
/orders/:id/items). The template is the thing worth logging and labelling metrics with; the raw path is not. - Dispatch calls the handler, usually with the framework's request and response objects and whatever the middleware chain attached to them (The Middleware Pipeline).
- Method mismatch is a separate outcome from path mismatch. A router that knows
/orders/42exists but has noDELETEfor it can answer 405 with anAllowheader; one that only asks "did anything match?" answers 404 and hides the bug.
From request line to function pointer
The router is small, and it is worth knowing exactly how small. Given a parsed request (Parsing HTTP), it performs a handful of steps, each of which has a documented behaviour you can look up and a default you probably did not choose.
The step people forget is the last one: the router does not only produce a handler, it produces the template that matched. That string is the identity of the endpoint. Logs, metrics, traces and rate-limit keys all want it, and most services throw it away and use the raw path instead.
- 1Split target
Separates path from query at the first
?. The query plays no part in matching.fails by Code that "routes on a query parameter" — which the router cannot do, so it becomes a branch inside a handler that nobody can see from the table.
- 2Normalise path
Percent-decoding, slash collapsing, dot-segment removal, case and trailing-slash policy.
fails by Disagreeing with the proxy in front of you, which turns a denied path into a served one.
- 3Match
Walks the route structure to find candidate patterns for this path.
fails by Picking the first plausible pattern rather than the most specific one (Route Precedence).
- 4Check method
Selects the handler registered for this verb on the matched path.
fails by Collapsing "no such path" and "wrong verb" into one 404, hiding client bugs.
- 5Capture parameters
Binds path segments to names, as strings, always.
fails by Handing the raw string to a query or a cast with no parse step (Path Parameters).
- 6Expose the template
Records
/orders/:id/itemsas the endpoint identity for this request.fails by Not recording it, so every observability question about the endpoint has to be answered by regex over raw paths.
- 7Dispatch
Calls the handler inside the middleware chain that wraps it.
fails by Route-scoped middleware registered after the route, so it never runs (Middleware Ordering Is a Correctness Decision).
Two route tables, two personalities
Almost every router is one of two shapes, and the shape decides more about your day-to-day than the syntax does. An ordered list compiles each registration to a pattern and tries them in order; the first match wins, so your file is your precedence rule. A radix trie indexes routes by path segment and walks the tree, preferring a static segment over a parameter over a wildcard; registration order is irrelevant and two routes that could match the same path are a registration-time conflict.
Neither is wrong. The ordered list is trivial to explain and lets you deliberately shadow a route — which is occasionally exactly what you want for a migration. The trie is order-independent, scales flat in route count, and turns a class of runtime surprises into startup errors. What is wrong is not knowing which one you are standing on.
| Router | Structure | Precedence rule | Conflicting routes |
|---|---|---|---|
| Express 4/5 | Ordered list of compiled patterns | First registered match wins | Allowed and silent — the later one is dead code |
| Fastify (find-my-way) | Radix trie | Static beats parametric beats wildcard | Throws at registration |
Go http.ServeMux (1.22+) | Pattern set with a specificity relation | Most specific pattern wins | Panics at registration if neither is more specific |
| Flask / Werkzeug | Rule map sorted by complexity | Sorted at build time, not by decorator order | Allowed; resolution follows the sort, which surprises people |
| Django | Ordered urlpatterns list of regexes | First match wins | Allowed and silent |
| ASP.NET Core endpoint routing | Route table with a documented precedence order | Literal beats parameter beats constraint-free catch-all | Ambiguous match throws at request time |
The route table is the endpoint list, so make it readable
A route table is the only artefact in the codebase that answers "what can a client call". If it can only be reconstructed by executing the program, then security review, load testing, contract testing and deprecation all get harder for the same reason.
The failing pattern is not exotic. It is a router assembled from directory scanning, decorator side effects, conditional registration behind feature flags, and a plugin system that mounts sub-routers on prefixes computed at runtime. Every one of those is defensible individually. Together they produce a service where nobody can list the endpoints.
for (const f of readdirSync('./routes')) {
const mod = await import(`./routes/${f}`)
mod.register(app) // order = readdir order
}
if (flags.newCheckout) app.post('/checkout', v2) // sometimesexport const orderRoutes = [
{ method: 'POST', path: '/orders', handler: placeOrder },
{ method: 'GET', path: '/orders/:orderId', handler: getOrder },
{ method: 'POST', path: '/orders/:orderId/items', handler: addItem },
] as const
// one place mounts them, and a test can assert the whole tableThe second form makes the table an inspectable value: a test can assert it, a script can diff it against the OpenAPI document, and a reviewer can see that a new route shadows an existing one. The first form makes route resolution depend on filesystem ordering and flag state, which are not things a reader can hold in their head.
How to build it
Most important first.
- Treat the route table as data you can inspect. Most frameworks can print it; put that behind an internal endpoint or a test, so "which routes exist" is answerable without reading every file.
- Register routes in one place per module and mount them, rather than scattering registration across imports that run in whatever order the module graph resolves. Import order deciding routing is a bug waiting for a refactor.
- Log and label with the matched template, not the path. This is one change that fixes both metric cardinality and the ability to ask "how slow is this endpoint" (Cardinality: The Label That Took Down Monitoring).
- Answer 405 where you can, and make 404 mean "no such resource" rather than "your URL shape was wrong" (Status Codes From the Server's Side).
- Keep the route table dumb. A route that inspects the body, calls the database, or branches on a header is a handler wearing a route's clothes; it will be invisible to everyone reading the table.
What can go wrong
- Routes registered in a loop from a config file or a plugin system, so the table depends on file ordering and nobody notices until a deploy reorders it.
- A catch-all static-file or SPA-fallback route mounted before the API, quietly swallowing every unmatched API path and returning
index.htmlwith a 200 (Route Precedence). - A mounted sub-router whose prefix is stripped, so the child's idea of the path differs from the parent's — and any logging done inside the child records the wrong URL.
- Case sensitivity: the router is case-sensitive, the CDN in front normalises case, and a URL works in production but not locally.
- Trailing-slash redirects issued with 301/302, which historically causes clients to re-issue a POST as GET; 307/308 preserve the method.
- Routes registered asynchronously — a plugin that awaits a config fetch before calling
app.get()— can be registered after the server starts accepting connections, so early requests 404 non-deterministically. Build the table before listening. - Hot-reloading a route table in place while requests are in flight means a request can match against a half-built structure. Swap an immutable table in atomically instead.
- Path normalisation differences between a proxy and the application are an authorisation bypass primitive: the proxy denies
/admin/*, the application sees a differently-decoded path and serves it. Keep normalisation identical, or enforce authorisation in the application where the decision actually is (Where the Check Belongs). - A route existing is information. Distinct 404 and 403 responses tell an attacker which resources exist; deciding that is a policy question, not an accident to leave to the router (Not Leaking Your Internals).
- Debug, metrics and admin routes mounted on the same listener as public traffic are reachable by anyone who can reach the service. Bind them to a separate port or protect them explicitly.
- A regex route pattern with nested quantifiers can be driven into catastrophic backtracking by a crafted path, which is CPU denial of service on the router itself — before authentication runs.
- "Routing is just a
switchon the URL." It is a lookup keyed on method and path with a precedence rule, and the precedence rule is where the bugs are. - "Frameworks all route the same way." Express tries registered patterns in order; Fastify walks a radix trie and rejects conflicting registrations; Go's
net/httpmux since 1.22 ranks by specificity and panics on ambiguous patterns. The same three routes behave differently in each. - "The route table is startup cost, so it does not matter." It decides which function runs. That is not configuration; that is control flow.
- "404 means the resource does not exist." It frequently means the URL shape did not match a pattern, which is a different problem with a different fix.
Operating it
- A request log line carrying
method,route_template,path,statusandduration_ms. If the template is missing, no aggregate question about an endpoint is answerable (Structured Logging). - A counter of requests that matched no route, broken down by path prefix. A spike in unmatched
/api/v2/...is a client shipping against a route you have not deployed. - A 405 counter. Nonzero and steady usually means a client is using the wrong verb and getting a confusing error; nonzero and sudden usually means a deploy dropped a method.
- Router time as a share of request time, when you have hundreds of routes. It should be invisible; if it is not, your table shape is the reason.
- Route count, not request rate, is what stresses the router. A trie is effectively flat in the number of routes; an ordered regex list is linear, and the constant is a regex execution.
- At 100x request rate the router is almost never the bottleneck — but the metric cardinality caused by routing decisions absolutely can be, because it multiplies by every distinct path you have ever labelled.
- Many services behind one gateway turn routing into a two-level problem: the gateway routes to a service, the service routes to a handler, and each has its own normalisation rules (API Gateway).
- A specificity-ranked router removes order-dependence and adds a rule you have to learn; a first-match list is trivially explainable and makes correctness depend on the order of lines in a file.
- Rich pattern syntax — regex constraints, optional segments, nested wildcards — buys expressiveness and costs a route table nobody can predict by reading.
- Answering 405 properly requires the router to index by path first and method second, which some frameworks simply do not do; matching their behaviour means accepting a less informative 404.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALMethod plus path plus a precedence rule producing a handler and captured parameters is true of every HTTP router; the rest of this lesson names where stacks diverge.
- FRAMEWORK-SPECIFICExpress, Django and Rails resolve by registration order (first match wins). Fastify and Go's 1.22+
http.ServeMuxresolve by specificity, so registration order is irrelevant — and both refuse ambiguous registrations rather than silently picking one. Flask/Werkzeug also sorts by rule complexity rather than by the order you wrote the decorators. - PROTOCOL-SPECIFICHTTP/1.1 sends a request target on the request line; HTTP/2 and HTTP/3 send
:method,:path,:authorityas pseudo-headers. Routers see the same two values, but header-size and duplicate-header rules — and therefore what a malformed request looks like — differ per version.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — how a decorator or a registration call executes at import time, and why module evaluation order becomes routing behaviour.