Backend Systems · Request Handling

Route Matching Precedence and the Cost of Ambiguity

How a dispatcher chooses one handler among overlapping patterns, why specificity beats registration order, and where percent-decoding changes which route wins.

A routing table is a set of predicates, and a request can satisfy several at once. /users/me matches both a literal route and a route with a parameter in the second segment. /api/v1/orders matches a versioned prefix route and a mount point for the whole /api subtree. The dispatcher’s job is to convert that partial order into exactly one handler, and the rule it uses is the part of routing that determines whether a new route can break an existing one.

Two dispatchers with the same table and different precedence rules behave differently on the same request. Neither is wrong in the abstract; one is wrong for a given deployment, and the difference surfaces as a request that reaches a handler the author of the route did not intend.

The dimensions are decided separately

Before any path matching happens, three other decisions have already narrowed the candidate set.

The listener or virtual host decides which routing table applies at all. The Host header then selects among sites sharing an address; HTTP/1.1 requires exactly one Host field in a request, and a server with several names on one socket has to decide what to do when the field names a host it does not serve. Returning the default site is common and produces confusing behaviour when TLS certificates or cookies differ between sites; rejecting the request is more honest.

The method is a separate key, not a filter applied afterwards. GET /orders and POST /orders are different routes, and a path that exists only for GET must answer a POST with 405 Method Not Allowed and an Allow header listing what is supported, not with 404. The distinction matters to clients: 404 says the resource does not exist, 405 says the request is malformed for an existing resource, and intermediaries and client libraries act on that difference.

Only then is the path matched. Some frameworks add a content-type or Accept predicate at the same level, which turns routing into content negotiation and makes the table harder to reason about; it is usually better to dispatch on the path and negotiate inside the handler.

Specificity as a total order

The most common rule ranks patterns by specificity: a literal segment beats a single-segment parameter, which beats a catch-all, and among otherwise equal patterns the longer one wins. Go’s standard ServeMux adopted this rule when it gained method and wildcard patterns, using the phrasing that the most specific pattern wins and that a pattern which matches every request of another is less specific. Routers such as httprouter and chi implement the same ordering through a radix tree, where a literal child is preferred over a parameter child at each level.

Registration-order routing, the model used by Express and by many small frameworks, instead scans the table and takes the first match. Its appeal is that it needs no ordering theory: what is written first wins, and the reader of the file can see the outcome by reading top to bottom. Its cost is that the table’s meaning depends on position, so inserting a route in the wrong place silently shadows a later one. A catch-all registered as a convenience before the specific routes below it turns every request into a 404 from the catch-all’s handler.

Specificity ordering fails in a different way. Two patterns can be incomparable: /a/:x/c and /a/b/:y both match /a/b/c, and neither is more specific by the segment kinds alone. Frameworks resolve this by declaring such a pair a conflict at registration time and refusing to start, which is the right trade for a server whose table is fixed at build time. Frameworks that allow dynamic registration must pick something, and whichever they pick becomes a rule nobody remembers.

type pattern struct {
    parts []string // literal, ":param", or "*rest"
}

// moreSpecific reports whether a should win over b when both match.
func moreSpecific(a, b pattern) bool {
    for i := 0; i < len(a.parts) && i < len(b.parts); i++ {
        if rank(a.parts[i]) != rank(b.parts[i]) {
            return rank(a.parts[i]) < rank(b.parts[i]) // literal 0, param 1, rest 2
        }
    }
    return len(a.parts) < len(b.parts) // shorter rest wins over longer rest
}

func rank(p string) int {
    switch {
    case p == "*rest":
        return 2
    case len(p) > 0 && p[0] == ':':
        return 1
    default:
        return 0
    }
}

Matching bytes that have not been normalised

The path in the request line is a sequence of octets, and the router compares patterns against it. Between the wire and the comparison sit three transformations that are easy to apply in the wrong order.

Percent-decoding turns %41 into A and %2F into /. RFC 3986 distinguishes reserved characters, whose encoded forms are not equivalent to their literal forms, from unreserved ones, where the encoded and literal forms are required to be equivalent. A router that decodes the whole path before splitting it on / turns %2F into a segment separator, so /files/a%2Fb reaches the route for /files/:name with name equal to a and an extra segment, or fails to match at all. Matching first and decoding each captured segment afterwards preserves the difference.

Dot-segment removal, the algorithm RFC 3986 calls remove_dot_segments, collapses .. and . before matching, so /orders/../admin cannot reach the admin route by way of a path that appears to traverse out of orders. Whether the server, the framework, or an intermediary performs this removal decides what the router sees; a server that normalises and a router that then decodes can reintroduce the traversal.

Case folding applies to the scheme and host, not the path. /Orders and /orders are different resources, and a router that lowercases the path to be helpful will serve one route for both while the client believes it addressed a different one.

Trailing slashes are a fourth case with no universal answer. /orders and /orders/ are distinct identifiers, and a redirect between them costs an extra round trip; worse, a 301 historically allowed clients to change a POST into a GET, so a redirect-based canonicalisation can silently drop a request body. A 308 preserves the method and body, which is why redirects that keep semantics use it.

Prefix mounts and the strip decision

Composing routers means mounting one table under a prefix of another: /api/v1 forward to a service whose own routes begin at /. Two things break at that seam.

The first is whether the prefix is stripped before the inner router sees the path. If it is, the inner service’s own redirects and generated links must be built with the prefix, or they will point outside the mount. If it is not, every inner route has to repeat the prefix, and the mount point becomes a fiction.

The second is the boundary between prefixes. A mount at /api must not capture /apifoo, so the strip is defined on segment boundaries rather than on string prefixes. Implementations that use a plain string prefix and then strip its length produce a request for /apifoo/x that becomes /foo/x in the inner router, which is a route that exists and should not have been reachable. A reverse proxy that rewrites the path before forwarding adds a second place where the same mistake can be made, which is why proxy path handling treats normalisation as something to be done once, at one defined point.

What matching costs per request

A radix tree over segments is walked once per request, with cost proportional to the number of segments times the branching factor at each level, which for realistic tables is a handful of byte comparisons per segment. Parameter extraction allocates only if the framework copies substrings rather than slicing the request buffer, and the copy is the expensive part: a table of a hundred routes matched against a short path is not a measurable cost next to the work the handler does.

Regex routers change that. Each route is a compiled expression, and a table without a literal prefix to prune on evaluates them in order, so cost grows with the number of routes. Ordering regex routes so the likely ones come first is a way to live with it, and caching the last match keyed by method and path is a way to avoid repeating it.

The 404 path deserves attention disproportionate to its frequency in successful traffic. Matching a request that hits nothing walks the whole tree or table before concluding there is no handler, and a flood of requests for nonexistent paths is a cheap way to make a server do the maximum amount of routing work per byte. A bounded cache from path to outcome, including the negative outcome, converts that repetition into a hash lookup. The next layer down, deciding what a matched request is allowed to carry, is treated in middleware chain ordering, since most policy that runs before a handler is attached at the same point as the route.

References

  1. RFC 3986: Uniform Resource Identifier (URI): Generic Syntax — IETF
  2. RFC 9110: HTTP Semantics — IETF
  3. Go standard library — net/http ServeMux — Go project documentation