Read OSS

Five Routers, One Interface: Hono's Routing Strategy

Advanced

Prerequisites

  • Article 1: The Architecture of Hono
  • Article 2: Request Lifecycle and Middleware Engine
  • Basic familiarity with trie data structures and regular expressions

Five Routers, One Interface: Hono's Routing Strategy

Most frameworks ship one router. Hono ships five. This isn't feature bloat — it's a deliberate architecture that lets users trade off between bundle size, registration speed, and match throughput without changing a single line of application code. As we saw in Part 1, the two-class pattern makes this possible: HonoBase never names a router; concrete subclasses inject one. This article examines what sits behind that injection point.

The Router Interface Contract

All five routers implement the Router<T> interface:

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 at line 98 is the most interesting part — it has two distinct formats:

export type Result<T> =
  | [[T, ParamIndexMap][], ParamStash]  // Format 1: RegExpRouter
  | [[T, Params][]]                     // Format 2: All others

Format 1 (used by RegExpRouter) stores parameters in a shared ParamStash array (e.g., ['123', 'abc']) and gives each handler a ParamIndexMap that maps parameter names to indices into that array. This avoids allocating a new Params object per handler match.

Format 2 (used by simpler routers) attaches a Params object ({ id: '123' }) directly to each handler. More readable, slightly more allocation.

The HonoRequest class handles both transparently via its #getParamValue() method — checking for the presence of the stash at index [1] of the result.

PatternRouter: Maximum Simplicity

PatternRouter is the simplest router in Hono — about 60 lines. It stores one RegExp per route and does a linear scan on every match:

match(method: string, path: string): Result<T> {
  const handlers: [T, Params][] = []
  for (let i = 0, len = this.#routes.length; i < len; i++) {
    const [pattern, routeMethod, handler] = this.#routes[i]
    if (routeMethod === method || routeMethod === METHOD_NAME_ALL) {
      const match = pattern.exec(path)
      if (match) {
        handlers.push([handler, match.groups || emptyParams])
      }
    }
  }
  return [handlers]
}

Named parameters like :id become (?<id>[^/]+) in the RegExp. The route /users/:id/posts becomes ^/users/(?<id>[^/]+)/posts/?$.

Trade-offs: O(n) match time, O(1) registration, minimal code size. This is the router behind hono/tiny — when you need the smallest possible bundle and have few routes, the linear scan is perfectly adequate.

LinearRouter: Cold-Start Champion

LinearRouter also scans linearly, but without compiling RegExps upfront. Registration is a simple array push:

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])
  }
}

The match() method does character-by-character comparison using indexOf and charCodeAt for the common cases (static paths, wildcards, labeled parameters). It avoids RegExp entirely for simple routes, only constructing new RegExp(pattern, 'd') for routes with custom parameter patterns like :id{[0-9]+}.

The critical limitation is at line 136–138:

} else if (hasLabel && hasStar) {
  throw new UnsupportedPathError()
}

Paths that mix named parameters and wildcards (e.g., /users/:id/*) throw UnsupportedPathError. This is deliberate — SmartRouter catches this error and falls back to TrieRouter, which can handle the pattern.

TrieRouter: The Balanced Default

TrieRouter is the shortest facade — 28 lines wrapping a Node-based trie data structure:

export class TrieRouter<T> implements Router<T> {
  name: string = 'TrieRouter'
  #node: Node<T>
  constructor() { this.#node = new Node() }
  add(method, path, handler) { this.#node.insert(method, path, handler) }
  match(method, path) { return this.#node.search(method, path) }
}

The heavy lifting happens in the Node class, which builds a prefix tree where each node represents a path segment. Routes like /users/:id and /users/profile share the /users/ prefix node, diverging at the next segment.

TrieRouter offers O(k) match time (where k is the number of path segments) with reasonable registration speed. It serves as the reliable fallback in SmartRouter when other routers can't handle a particular path pattern.

graph TD
    Root["/"] --> Users["/users"]
    Users --> ID["/:id"]
    Users --> Profile["/profile"]
    ID --> Posts["/posts"]
    Root --> API["/api"]
    API --> V1["/v1"]

RegExpRouter: All Routes in One Regex

RegExpRouter is the crown jewel. It compiles all routes for a given HTTP method into a single regular expression, then uses match group indexing to identify which handler matched.

The build pipeline works in stages:

  1. Route collectionadd() stores routes in method-keyed maps, pre-computing middleware applicability with wildcard RegExps
  2. Trie constructiontrie.ts inserts tokenized paths into a trie, tracking parameter associations
  3. RegExp compilationbuildRegExp() serializes the trie into a single RegExp string with capture groups
  4. Matcher construction — the compiled RegExp, handler data, and static map are bundled into a Matcher tuple

The static-map optimisation in matcher.ts#L17-L19 short-circuits routes with no parameters:

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

For routes like /health or /api/status, this is a hash map lookup — O(1), no RegExp execution at all.

flowchart TD
    A["add('/users/:id')"] --> B["Store in routes map"]
    A2["add('/posts/:slug')"] --> B
    C["First match() call"] --> D["buildAllMatchers()"]
    D --> E["Trie.insert() for each route"]
    E --> F["buildRegExp() → single RegExp"]
    F --> G["Build static map for parameterless routes"]
    G --> H["Create Matcher tuple"]
    H --> I["Self-replace this.match"]
    I --> J["Subsequent match() calls"]
    J --> K{"Static map hit?"}
    K -->|Yes| L["Return directly (O(1))"]
    K -->|No| M["RegExp.exec(path)"]
    M --> N["Index into handler data"]

The self-replacing pattern at matcher.ts#L31-L32 is worth highlighting:

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

After building matchers, this.match is replaced with a closure that goes straight to the compiled RegExp. No check for "have I built yet?" on every subsequent request. The setup cost is paid once and only once.

SmartRouter: The Meta-Router

SmartRouter is the orchestrator. It implements the same Router interface but delegates to child routers:

match(method: string, path: string): Result<T> {
  const routers = this.#routers
  const routes = this.#routes

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

    this.match = router.match.bind(router)  // Self-replace!
    this.#routers = [router]
    this.#routes = undefined
    break
  }
}
flowchart TD
    A["SmartRouter.match() — first call"] --> B["Buffer: routes stored during add()"]
    B --> C["Try Router[0]: RegExpRouter"]
    C --> D{"All routes added<br/>successfully?"}
    D -->|Yes| E["Use RegExpRouter"]
    D -->|"UnsupportedPathError"| F["Try Router[1]: TrieRouter"]
    F --> G["Use TrieRouter"]
    E --> H["Self-replace: this.match = router.match.bind(router)"]
    G --> H
    H --> I["All subsequent calls bypass SmartRouter"]

The key insight: SmartRouter's match method replaces itself after the first call. Every subsequent request goes directly to the winning router's match method — no SmartRouter overhead at all. The #routes array is set to undefined, freeing the buffered route data for garbage collection.

The selection heuristic is simple: try routers in the order they were provided. For the default preset, that's [RegExpRouter, TrieRouter]. RegExpRouter is tried first because it's the fastest at steady-state. If any route throws UnsupportedPathError during add(), SmartRouter catches it and moves to the next candidate.

Tip: After your application handles its first request, you can check app.router.name to see which router SmartRouter selected. It will read something like "SmartRouter + RegExpRouter". If you see TrieRouter instead, you have a route pattern that RegExpRouter can't handle.

Path Utilities and Pattern Matching

The shared path utilities in src/utils/url.ts provide the foundation all routers build on:

  • splitPath() splits /api/users/:id into ['api', 'users', ':id']
  • getPattern() converts path labels into pattern descriptors: ':id' becomes [':id', 'id', true], while ':id{[0-9]+}' becomes [':id{[0-9]+}', 'id', /^[0-9]+$/]
  • checkOptionalParameter() expands /api/animals/:type? into ['/api/animals', '/api/animals/:type'] — two separate routes registered together

Back in HonoBase, the #path sticky variable at lines 122–141 enables the chaining API:

app.get('/users').post(handler)  // POST /users — #path remembers '/users'

When args1 is a string, it updates this.#path. When it's a handler function, it uses the stored #path. This lets you chain HTTP method calls on the same path without repeating the path string.

What's Next

With the routing system fully understood, we're ready for the most ambitious part of Hono's architecture: its type system. In the next article, we'll dissect how route definitions become compile-time types, how the HandlerInterface's 20+ overloads enable middleware type inference, and how the hc RPC client converts all of this into typed fetch calls — all with zero runtime cost.