Read OSS

The Five Routers — From Pattern Matching to Compiled RegExp

Advanced

Prerequisites

  • Article 2: The Request Lifecycle
  • Understanding of regular expressions
  • Familiarity with tree/trie data structures
  • Understanding of algorithmic complexity (Big-O notation)

The Five Routers — From Pattern Matching to Compiled RegExp

Most web frameworks ship with one router. Hono ships with five. This isn't complexity for its own sake — it reflects a genuine engineering tradeoff. A router optimized for steady-state throughput (compiled regex, O(1) matching) has expensive build time. A router optimized for cold starts (zero build cost, O(n) matching) is slower under sustained load. Hono lets you choose the right tradeoff for your deployment target, and provides SmartRouter to make the choice automatically.

In this article, we'll examine each router implementation in detail, starting with the minimal interface they all share, then working through the implementations from simplest to most sophisticated.

The Router Interface and Dual Result Format

Every router implements the Router<T> interface — just two methods and a name:

export interface Router<T> {
  name: string
  add(method: string, path: string, handler: T): void
  match(method: string, path: string): Result<T>
}

The Result<T> type is where things get interesting. It's a union of two formats:

export type Result<T> = [[T, ParamIndexMap][], ParamStash] | [[T, Params][]]

Format 1 ([[T, ParamIndexMap][], ParamStash]): Used by RegExpRouter. Each handler is paired with a ParamIndexMap (e.g., { id: 0, action: 1 }) that maps parameter names to indices in a shared ParamStash array (['123', 'abc']). This is memory-efficient because all handlers share one parameter array — the regex match result.

Format 2 ([[T, Params][]]): Used by all other routers. Each handler gets its own Params object (e.g., { id: '123', action: 'abc' }).

As we saw in Part 2, HonoRequest.param() handles both formats transparently via #getParamValue().

classDiagram
    class RouterInterface["Router&lt;T&gt;"] {
        +name: string
        +add(method, path, handler)
        +match(method, path): Result
    }
    class RegExpRouter {
        Format 1: ParamIndexMap + ParamStash
    }
    class TrieRouter {
        Format 2: Params per handler
    }
    class LinearRouter {
        Format 2: Params per handler
    }
    class PatternRouter {
        Format 2: Params per handler
    }
    class SmartRouter {
        Delegates to chosen router
    }
    RouterInterface <|.. RegExpRouter
    RouterInterface <|.. TrieRouter
    RouterInterface <|.. LinearRouter
    RouterInterface <|.. PatternRouter
    RouterInterface <|.. SmartRouter

SmartRouter — The Strategy Pattern with JIT Locking

SmartRouter is the orchestrator. It doesn't route anything itself — it tries each candidate router in order and locks onto the first one that succeeds.

Here's the complete match() method:

match(method: string, path: string): Result<T> {
  if (!this.#routes) {
    throw new Error('Fatal error')
  }

  const routers = this.#routers
  const routes = this.#routes

  const len = routers.length
  let i = 0
  let res
  for (; i < len; i++) {
    const router = routers[i]
    try {
      for (let i = 0, len = routes.length; i < len; i++) {
        router.add(...routes[i])
      }
      res = router.match(method, path)
    } catch (e) {
      if (e instanceof UnsupportedPathError) {
        continue
      }
      throw e
    }

    this.match = router.match.bind(router)
    this.#routers = [router]
    this.#routes = undefined
    break
  }
  // ...
}

On the first call to match(), SmartRouter:

  1. Iterates through candidate routers
  2. Feeds all accumulated routes to each candidate via add()
  3. Calls match() on the candidate
  4. If UnsupportedPathError is thrown, moves to the next candidate
  5. On success, replaces its own match method with the winning router's (this.match = router.match.bind(router)) at line 46
  6. Frees the routes array by setting this.#routes = undefined

This is a JIT compilation strategy. After the first request, SmartRouter effectively ceases to exist — all subsequent match() calls go directly to the chosen router with zero overhead.

flowchart TD
    FIRST["First match() call"] --> TRY["Try router[0].add() + match()"]
    TRY --> UNSUP{UnsupportedPathError?}
    UNSUP -->|Yes| NEXT["Try router[1]"]
    UNSUP -->|No| LOCK["this.match = router[0].match.bind(router[0])<br/>this.#routes = undefined"]
    NEXT --> UNSUP2{UnsupportedPathError?}
    UNSUP2 -->|No| LOCK2["this.match = router[1].match.bind(router[1])"]
    UNSUP2 -->|Yes| FATAL["throw Error('Fatal error')"]
    LOCK --> DONE["All future calls bypass SmartRouter"]
    LOCK2 --> DONE

RegExpRouter — Single Regex Compilation via Trie

RegExpRouter is the crown jewel of Hono's routing system. It compiles all routes for a given HTTP method into a single regular expression, achieving O(1) matching regardless of how many routes are registered.

The compilation process involves three components:

1. Route Registration (add()): Routes are organized into two maps — #middleware (wildcard routes like /api/*) and #routes (specific path patterns). The middleware map tracks which middleware applies to which paths, so they can be prepended to route handlers during compilation.

2. Trie-Based Regex Construction: The Trie class builds a prefix tree from route patterns, then serializes it into a single regex string. The buildRegExp() method produces three outputs:

  • A RegExp that matches all routes
  • An indexReplacementMap mapping capture group indices to handler data indices
  • A paramReplacementMap mapping parameter slots to capture group indices

3. Self-Modifying Match in matcher.ts: The match function uses the same JIT trick as SmartRouter. On first call, it triggers buildAllMatchers(), which compiles the regex and builds matcher data. Then it replaces itself with an optimized closure:

export function match<R extends Router<T>, T>(this: R, method: string, path: string): Result<T> {
  const matchers: MatcherMap<T> = (this as any).buildAllMatchers()

  const match = ((method, path) => {
    const matcher = (matchers[method] || matchers[METHOD_NAME_ALL]) as Matcher<T>

    const staticMatch = matcher[2][path]
    if (staticMatch) {
      return staticMatch
    }

    const match = path.match(matcher[0])
    if (!match) {
      return [[], emptyParam]
    }

    const index = match.indexOf('', 1)
    return [matcher[1][index], match]
  }) as Router<T>['match']

  this.match = match
  return match(method, path)
}

Notice the static map fast path: parameterless routes (like /api/health) are stored in a plain object lookup. Only dynamic routes need the regex.

The match.indexOf('', 1) trick is clever — the compiled regex uses empty capture groups as markers for which route matched. The first empty string after index 0 in the match result identifies the winning route.

flowchart TD
    ADD["add('/users/:id', handler1)<br/>add('/posts/:id', handler2)<br/>add('/health', handler3)"] --> BUILD["buildAllMatchers()"]
    BUILD --> TRIE["Trie builds regex pattern"]
    TRIE --> REGEX["Single RegExp for all routes"]
    BUILD --> STATIC["Static map: {'/health': result}"]
    
    MATCH["match('GET', '/users/42')"] --> SCHECK{Static map hit?}
    SCHECK -->|Yes| SRET["Return static result (O(1))"]
    SCHECK -->|No| REXEC["path.match(compiledRegex)"]
    REXEC --> IDX["indexOf('', 1) → handler index"]
    IDX --> RET["Return [handlers[index], match]"]

Tip: RegExpRouter throws UnsupportedPathError for certain complex patterns. The PATH_ERROR sentinel is thrown from the Trie's insert() method when it encounters patterns it can't compile into a single regex. This is why SmartRouter pairs it with TrieRouter as a fallback.

LinearRouter — Zero Build Cost for Serverless

LinearRouter is the philosophical opposite of RegExpRouter. Where RegExpRouter invests heavily at build time for O(1) matching, LinearRouter does zero work during add() and pays O(n) at match time:

add(method: string, path: string, handler: T) {
  for (
    let i = 0, paths = checkOptionalParameter(path) || [path], len = paths.length;
    i < len;
    i++
  ) {
    this.#routes.push([method, paths[i], handler])
  }
}

Registration just pushes a tuple onto an array. No regex compilation, no tree building, no preprocessing. The match() method then iterates through all routes on every request, performing manual string matching with character-code comparisons.

In the 144-line match() method, routes are tested in four categories:

  1. Wildcard routes (* or /*): Always match, with empty params
  2. Static routes (no : or *): Direct string comparison
  3. Star-only routes (wildcards without params): Prefix matching by splitting on *
  4. Label routes (:param segments): Segment-by-segment matching with parameter extraction

This approach makes perfect sense for serverless platforms where each instance handles a single request. If your container boots, handles one request, and shuts down, spending time compiling routes into a regex is pure waste. LinearRouter minimizes cold-start time by deferring all work to match time.

Router add() cost match() cost Build phase Best for
RegExpRouter O(n) routes O(1) Heavy (regex compilation) Long-running servers
LinearRouter O(1) per route O(n) per request None Serverless cold starts
PatternRouter O(1) per route O(n) per request Light (per-route regex) Smallest bundle
TrieRouter O(k) per route O(k) per request Light (tree insertion) Universal fallback

k = number of path segments

PatternRouter and TrieRouter — Simplicity and Reliability

PatternRouter is the simplest router at 60 lines. Each route gets its own RegExp, compiled during add():

add(method: string, path: string, handler: T) {
  // ... handle wildcards and optional params
  const parts = (path.match(/\/?(:\w+(?:{...})?)|\/?\ [^/\?]+/g) || []).map(
    (part) => {
      const match = part.match(/^\/:([ ^{]+)(?:{(.*)})?/)
      return match
        ? `/(?<${match[1]}>${match[2] || '[^/]+'})`
        : part === '/*' ? '/[^/]+' : part.replace(/[.\\+*[^\]$()]/g, '\\$&')
    }
  )
  this.#routes.push([
    new RegExp(`^${parts.join('')}${endsWithWildcard ? '' : '/?$'}`),
    method, handler,
  ])
}

Named groups in the regex ((?<id>[^/]+)) let match.groups provide the Params object directly. The match() method tests each route's regex in order — O(n) per request, but the per-route cost is constant since each regex is pre-compiled.

TrieRouter is 28 lines for the router class itself, delegating the real work to its Node implementation:

export class TrieRouter<T> implements Router<T> {
  name: string = 'TrieRouter'
  #node: Node<T>

  constructor() { this.#node = new Node() }

  add(method: string, path: string, handler: T) {
    const results = checkOptionalParameter(path)
    if (results) {
      for (let i = 0, len = results.length; i < len; i++) {
        this.#node.insert(method, results[i], handler)
      }
      return
    }
    this.#node.insert(method, path, handler)
  }

  match(method: string, path: string): Result<T> {
    return this.#node.search(method, path)
  }
}

TrieRouter handles all route patterns — including the complex ones that make RegExpRouter throw UnsupportedPathError. This is why it serves as the universal fallback in SmartRouter configurations. Its tree-based structure provides O(k) matching where k is the number of path segments, which is a reasonable middle ground.

Both routers use checkOptionalParameter() from src/utils/url.ts#L171-L206 to handle optional parameters (:param?). This function expands /api/animals/:type? into two routes: /api/animals and /api/animals/:type.

flowchart TD
    subgraph PatternRouter["PatternRouter (60 lines)"]
        PA["add(): Compile per-route RegExp"]
        PM["match(): Test each regex sequentially"]
    end
    subgraph TrieRouter["TrieRouter (28 lines + Node)"]
        TA["add(): Insert into prefix tree"]
        TM["match(): Walk tree by segments"]
    end
    subgraph Role["Role in SmartRouter"]
        PR_ROLE["PatternRouter: Used standalone in hono/tiny<br/>Smallest bundle, per-route regex"]
        TR_ROLE["TrieRouter: Universal fallback<br/>Handles ALL patterns"]
    end

Choosing the Right Router

The router selection decision comes down to your deployment model:

  • Long-running server (Node.js, Deno, Bun): Use the default preset. RegExpRouter's one-time compilation cost is amortized over thousands of requests, and O(1) matching dominates.

  • Serverless with cold starts (AWS Lambda, Cloudflare Workers with many routes): Use hono/quick. LinearRouter's zero build cost minimizes time-to-first-response.

  • Bundle-size constrained (edge functions, browser): Use hono/tiny. PatternRouter adds minimal code to your bundle.

  • Custom requirements: Pass any Router<T> implementation to the Hono constructor via options.router.

Tip: You can check which router is active in a SmartRouter configuration via app.router.activeRouter.name after the first request. This is useful for debugging whether RegExpRouter or TrieRouter won the selection.

In the next article, we'll shift from runtime behavior to the type system — exploring how Hono achieves end-to-end type safety from route definitions to the RPC client, using some of the most sophisticated TypeScript generics you'll find in the wild.