Routing
Matching method + path to handlers. Radix trees, parameters, versioning.
A URL is just an address for code
Every backend has the same first job once a request arrives: figure out which piece of code should handle it. A request for GET /users/42 and a request for POST /orders should run completely different functions. The component that makes that decision is the router.
Think of it as a switchboard. The request comes in carrying two facts the router cares about — an HTTP method (GET, POST, PUT, DELETE...) and a path (/users/42). The router holds a table of registered routes, each one pairing a method-and-path pattern with a handler function. It walks that table, finds the first pattern that matches, and calls the handler.
GET /users/42
│
▼
┌─────────────────────────────┐
│ Route table │
│ GET /users → list │
│ GET /users/:id → show │ ◄── matches
│ POST /users → create│
│ GET /orders → orders│
└─────────────────────────────┘
│
▼
show(id=42)
That is the whole idea. Everything else in this module is detail on top of it: how patterns are written, how the matching algorithm works, and the failure modes that bite people who never learned the detail.
Static routes, path parameters, and query strings
Routes come in two shapes. A static route matches one exact path: /health, /login, /about. A dynamic route has a placeholder segment — usually written :id or {id} depending on the framework — that matches any value and captures it.
// Express-style route registration
app.get('/health', healthHandler); // static
app.get('/users/:id', showUserHandler); // dynamic — :id is a path parameter
app.get('/users/:id/posts/:postId', ...); // two parameters
When GET /users/42 matches /users/:id, the router hands the handler id = "42". Note the quotes — path parameters always arrive as strings. Converting "42" to a number is your job, and forgetting it is a real source of bugs ("42" + 1 is "421", not 43).
Path parameters identify which resource you want. Query strings — the ?key=value part — modify how you want it. The distinction matters:
/products/42 ← path param: which product (the resource identity)
/products?sort=price ← query string: how to present the list (a modifier)
/products?page=2&limit=20 ← pagination, also a modifier
A good rule: if removing the value would change what resource is addressed, it belongs in the path. If it only filters, sorts, or paginates, it belongs in the query string. /products/42 is a specific product; /products?category=42 is the product list, filtered. Routers match on the path only — query strings are parsed separately and never affect which route is chosen.
How the matching algorithm actually works
Naively, a router could store routes as a flat list and scan it top to bottom on every request. That works, and many small frameworks do exactly this. It is O(n) in the number of routes — fine for 50 routes, measurable for 5,000.
Performance-focused routers store routes in a prefix tree (a trie, sometimes called a radix tree). Shared path prefixes become shared branches:
/
├── users
│ ├── (static) → listUsers
│ └── :id → showUser
│ └── posts → userPosts
└── orders → listOrders
Matching /users/42/posts walks the tree segment by segment instead of testing every route. Lookup time depends on path depth, not route count — effectively O(1) for a real API. Go's httprouter, Gin, and Fastify all do this. You rarely implement it yourself, but knowing it exists explains why route registration order can matter (static branches are usually preferred over parameter branches at the same level) and why some routers forbid two routes that would create an ambiguous branch.
The output of matching is always the same regardless of algorithm: a handler function plus a map of captured parameters. The handler does not know or care how it was found.
Route ordering — the bug everyone hits once
Here is the single most common routing bug, and it has nothing to do with algorithms. Consider these two routes registered in this order:
app.get('/users/:id', showUser); // registered first
app.get('/users/me', showCurrentUser); // registered second
A request for /users/me is supposed to hit showCurrentUser. In a list-scanning router it does not — /users/:id is checked first, :id happily matches the literal string "me", and showUser runs with id = "me". Your "current user" endpoint is dead and you get a confusing "user not found: me" error.
The fix is ordering: register more specific routes before more general ones.
app.get('/users/me', showCurrentUser); // specific — register FIRST
app.get('/users/:id', showUser); // general — register AFTER
Trie-based routers often handle this for you by preferring static segments over parameter segments automatically — but not all of them, and not in every framework version. The honest advice: do not rely on the router being clever. Register specific routes first regardless. It costs nothing and removes a whole class of bug.
Two related ordering traps:
- Catch-all routes (
/or/:path) must always be registered last, or they swallow everything below them.
- Method matters too.
GET /usersandPOST /usersare different routes. A request that matches the path but not the method should return405 Method Not Allowed, not404— though many routers incorrectly return404.
Grouped routes and API versioning
As an API grows past a dozen endpoints, registering each route individually gets repetitive — every admin route needs the same auth check, every v1 route shares the /v1 prefix. Routers solve this with route groups: a shared prefix and a shared set of middleware applied to a batch of routes.
const adminRoutes = router.group('/admin', requireAdmin);
adminRoutes.get('/users', listAllUsers);
adminRoutes.get('/metrics', showMetrics);
// Both become /admin/users and /admin/metrics,
// both run requireAdmin before their handler.
The most important use of grouping is API versioning. Once external clients depend on your API, you cannot change a response shape without breaking them. Versioning gives you a way to ship a new shape while the old one keeps working:
const v1 = router.group('/api/v1');
v1.get('/users/:id', showUserV1); // returns { name, email }
const v2 = router.group('/api/v2');
v2.get('/users/:id', showUserV2); // returns { firstName, lastName, email }
URL-path versioning (/api/v1/...) is the most common approach because it is visible, easy to route, and easy to debug — you can see the version in any log line. The alternatives are header-based versioning (Accept: application/vnd.api.v2+json) and query-parameter versioning (?version=2); both keep the URL stable but make the version invisible in logs and harder to test by hand.
**When you do not need versioning:** internal APIs where you control every client, and additive-only changes. Adding a new optional field to a response breaks nobody — old clients ignore it. You only need a new version when you remove or rename something, or change a field's type. Reach for a /v2 when you have an actual breaking change, not on a schedule.
Common mistakes
The routing bugs that show up in real codebases, beyond the ordering trap already covered.
Treating path parameters as trusted. :id matches anything — 42, me, '; DROP TABLE, a 10,000-character string. The router does no validation. Validate and coerce every path parameter before using it, exactly as you would query input.
Trailing-slash inconsistency. /users and /users/ are different paths to a strict router. A client appending a slash gets a 404. Pick one convention and enforce it — most frameworks have a setting to redirect or normalize. Just decide deliberately instead of discovering it from a bug report.
Case sensitivity surprises. /Users and /users differ by default in most routers. Pick lowercase URLs and stick to it.
Putting verbs in paths. /getUser, /createOrder, /deleteItem — the HTTP method already is the verb. Use GET /users/42 and DELETE /items/9, not GET /getUser?id=42. RESTful design (covered in Module 11) leans entirely on this.
Returning 404 for a wrong method. If /users exists for GET but the request was DELETE, the correct response is 405 Method Not Allowed with an Allow header listing valid methods. A 404 tells the client the resource does not exist, which is a lie and sends them debugging the wrong thing.
Overly clever regex routes. Some routers let you put raw regular expressions in route patterns. A complex regex route is hard to read, hard to test, and a potential ReDoS (regular-expression denial-of-service) vector. Prefer simple :param segments and validate inside the handler.
Routing looks trivial — match a string, call a function — and the happy path is trivial. The bugs all live in ordering, validation, and the small inconsistencies. Get those right and the router becomes the boring, reliable switchboard it is supposed to be. Next module: middleware — the code that runs around every handler the router picks.
The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.