skip to content

When several Ktor routes match the same request path, how does Ktor choose the handler?

level: middleimportance: must knowfreq 55%

answer

  1. Not first match wins
  2. Think tree and scoring
  3. Specificity beats declaration position
  4. Literal, capture, single-segment, catch-all
  5. One character at the end changes the path

basics

~20 s

Ktor builds a routing tree, evaluates every branch that matches, scores each one, and runs the highest-scoring handler. A constant segment outranks a path parameter, which outranks a wildcard, which outranks a tailcard. Declaration order is not the tiebreaker.

solid answer

~40 s

Routing in Ktor is a tree of selectors, not an ordered list of patterns. For each request Ktor walks the tree, collects every branch whose selectors match, and gives each match a quality score; the best-scoring branch wins. The ranking is by specificity: a literal segment beats a `{param}` capture, which beats a `*` wildcard, which beats a `{tail...}` tailcard. So `get("/files/latest")` handles `/files/latest` even if `get("/files/{name}")` was declared first — unlike first-match frameworks where ordering is the whole mechanism. Two practical consequences: you can declare routes in whatever order reads best, and a route that never fires is usually a spelling or nesting mistake rather than an ordering one. Trailing slashes are also part of matching — `/users` and `/users/` are distinct paths unless you install Ktor's `IgnoreTrailingSlash` plugin.

code

kotlin · 7 lines
kotlin
routing {
    route("/files") {
        get("/{name}") { call.respondText("by name") }   // second most specific
        get("/latest") { call.respondText("latest") }     // wins /files/latest
        get("/{path...}") { call.respondText("fallback") } // lowest score
    }
}

go deeper

for a junior

Recall the shapes: a literal path, {id}, * and {rest...}. Knowing that Ktor prefers the most specific of these is enough at this level.

for a middle

Explain the mechanism — a tree of selectors, every matching branch scored, highest specificity wins — and predict the winner for a given pair of routes without hedging.

for a senior

Demonstrate debugging judgment: when a route does not fire, reconstruct the effective path from the nesting and check method and trailing slash before blaming precedence.

for a principal

Own the conventions: one URL style across services, a decision on trailing slashes made once, and catch-all branches used deliberately rather than as accidental sinks.

## Routing is a tree, not a list Many web frameworks resolve a request by testing patterns top to bottom and taking the first that matches; the order you write routes in is then load-bearing. Ktor does something different. Every `route`, method handler and path segment you declare becomes a node in a tree, and each node carries a selector that answers one question: does this part of the request match me? Nesting in the DSL is literally nesting in the tree, so `route("/api") { route("/users") { get("/{id}") { } } }` and `get("/api/users/{id}") { }` build equivalent branches. ## Resolution: match, score, pick the best When a request arrives, Ktor descends the tree and gathers every branch that matches end to end. If more than one does, it compares them by quality rather than by declaration position, and the highest-quality branch's handler runs. The ordering of selector kinds, from most specific to least: 1. A constant segment — `latest` in `/files/latest`. 2. A named path parameter — `{name}`. 3. A wildcard `*`, matching exactly one segment without capturing it. 4. A tailcard `{rest...}`, matching all remaining segments including the slashes between them. So given both `get("/files/{name}")` and `get("/files/latest")`, a request for `/files/latest` runs the constant route and a request for `/files/report.csv` runs the parameterised one — in either declaration order. A tailcard route such as `get("/files/{path...}")` acts as a catch-all for that subtree without stealing requests from the more specific siblings above it. ## Why this matters in practice The first consequence is that you organise routes for readability. Grouping every `/orders` endpoint together, or putting the catch-all at the top of the file, does not change behaviour. Reviewers coming from first-match frameworks often "fix" ordering that was never broken. The second is diagnostic. When a Ktor route does not fire, ordering is almost never the cause. The usual causes are: a typo in the literal segment; a route declared outside the `routing { }` block or inside a `route` prefix you forgot about, so the effective path is not what you think; the method selector not matching (a `post` handler cannot serve a GET); or a path that differs only by a trailing slash. Reconstruct the full path by walking the nesting from the outermost `route` inwards before you suspect precedence. ## Selectors beyond the path Path segments are only one kind of selector. HTTP method handlers (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`) add a method selector, and Ktor also offers content and header-based selectors — `accept(ContentType.Application.Json) { }` and `header(name, value) { }` — that let two branches with the same path diverge on what the client asked for. These participate in the same match-and-score process, which is how one path can serve different representations from different handlers. ## Trailing slashes By default `/users` and `/users/` are different paths, and a route declared for one does not serve the other. That surprises people whose clients build URLs by concatenation. Installing `IgnoreTrailingSlash` makes routing treat the two as equivalent for the whole application. Decide this once, early: retrofitting it after clients have baked in one form is the awkward version of the conversation. ## Wildcards versus tailcards The distinction is worth stating precisely because interviewers probe it. `*` consumes exactly one segment and captures nothing, so `/files/*/meta` matches `/files/7/meta` but not `/files/a/b/meta`. `{path...}` consumes zero or more remaining segments and captures them, retrieved with `call.parameters.getAll("path")`; an unnamed `{...}` matches the same shape but discards the values. Because tailcards score lowest, they are the right tool for a fallback branch — a static-content mount, a legacy redirect, a custom not-found handler for one subtree. ## What interviewers listen for A good answer names specificity as the ranking rule, states that declaration order does not decide the winner, and can predict the outcome for a concrete pair of routes. A weak answer asserts first-match semantics and then explains an ordering bug that Ktor would not have produced.

  • Does the order in which you declare Ktor routes ever matter?
    Not for choosing between differently-shaped matches — specificity decides that. Order affects only readability and, at the margin, branches that are genuinely equivalent, which is a design smell you should remove rather than rely on. Treat a route that "works only when declared first" as a duplicate-path bug.
  • How do you write a catch-all inside one subtree without swallowing its specific routes?
    Use a tailcard: `route("/files") { get("/{path...}") { ... } }`. Tailcards score lowest, so the sibling literal and `{param}` routes under `/files` still win their own requests, and the tailcard only receives what nothing more specific matched.
  • A Ktor route for "/users" returns 404 when the client requests "/users/". Why?
    Trailing slashes are part of the path, so those are two distinct routes by default. Install `IgnoreTrailingSlash` to make routing treat them as equivalent application-wide, or normalise the URL at the edge. Pick one approach and apply it globally rather than declaring both variants per endpoint.

saying these in an interview costs you the question

  • Says Ktor matches routes top to bottom, first one wins
  • Reorders routes to fix a 404 that has another cause
  • Believes a tailcard route shadows its more specific siblings
  • Thinks * matches multiple path segments
  • Assumes /users and /users/ are the same route by default

context