Spin JS/TS Router Showdown: Hono vs Itty vs Manual Routing

By: Thorsten Hans When building HTTP APIs with Spin, performance matters—not just in raw response time, but also in startup latency and memory footprint, especially for WebAssembly modules running in constrained environments. In this post, we will break down the performance characteristics of three approaches to routing in Spin applications written in JavaScript or TypeScript: Using the Hono Router Using the Itty Router Using no router at all, writing direct logic in Spin’s handler Why Routing Performance Matters in Spin Spin apps are built for speed, fast startup, tiny binaries, and low-latency execution. However, when every millisecond counts, even your choice of router can become a bottleneck. Adding unnecessary abstraction could impact the overall application performance. That’s why it’s critical to understand the tradeoffs between routing libraries and raw routing logic. The Setup We created a minimal HTTP API that must be able to respond to the following request characteristics: GET /items: To return a list of items as JSON GET /items/:id: To return a particular item as JSON looked up by its identifier We created three distinct applications with Spin v3.2.0 and latest http-js templates. For benchmarking with hey those apps are hosted locally using spin up. The Hono Implementation import { Hono } from 'hono/quick' let items = [ { id: 1, name: 'coffee' }, { id: 2, name: 'soda' }, { id: 3, name: 'milk' }, ] let app = new Hono() app.get('/items', (c) => c.json(items)) app.get('/items/:id', (c) => { let id = +c.req.param('id') let found = items.find(i => i.id === id) if (!found) { return c.status(404) } return c.json(found) }) app.fire() The Itty Implementation import { AutoRouter, json, status } from 'itty-router' let items = [ { id: 1, name: 'coffee' }, { id: 2, name: 'soda' }, { id: 3, name: 'milk' }, ] let router = AutoRouter() router.get('/items', () => { return json(items) }).get('/items/:id', ({ id }) => { let found = items.find(i => i.id === +id) if (!found) { return status(404) } return json(found) }) addEventListener('fetch', (event) => { event.respondWith(router.fetch(event.request)) }) The Manual Routing Implementation let items = [ { id: 1, name: 'coffee' }, { id: 2, name: 'soda' }, { id: 3, name: 'milk' }, ] function handle(request) { const url = new URL(request.url) const path = url.pathname.slice(1) const method = request.method if (method === 'GET' && (path === 'items' || path === 'items/')) { return new Response(JSON.stringify(items), { status: 200, headers: { 'content-type': 'application/json' } }) } const id = path.split('/')[1] if (method === 'GET' && !!id) { try { const item = items.find(i => i.id === parseInt(id, 10)) if (item) { return new Response(JSON.stringify(item), { status: 200, headers: { 'content-type': 'application/json' } }); } } catch (err) { return new Response(null, { status: 400 }) } } return new Response(null, { status: 404 }) } addEventListener('fetch', (event) => { event.respondWith(handle(event.request)) }) Benchmarking We used hey to stress test each routing setup with 200 concurrent workers. Each configuration was tested under constant load for 5s, 10s, and 20s, repeating each test 5 times. From these runs, we calculated the average requests per second (RPS) and latency percentiles (95th and 99th). All tests were performed on an Azure Standard D8s-v5 VM, equipped with 8 vCPUs and 32 GiB of RAM, running Debian 12 Bookworm. Router Average RPS 95th 99th Manual Routing 4922.2 15.7ms 17.5ms Itty Router 3718.3 20.1ms 23.5ms Hono Router 3509.8 21.4ms 24.3ms Analysis Manual Routing Manual routing delivered the highest throughput at 4922 RPS and the lowest tail latencies—15.7ms at the 95th percentile, 17.5ms at the 99th. This approach avoids all abstraction overhead and is ideal for tight, performance-critical APIs.

May 8, 2025 - 04:07
 0
Spin JS/TS Router Showdown: Hono vs Itty vs Manual Routing

By: Thorsten Hans

When building HTTP APIs with Spin, performance matters—not just in raw response time, but also in startup latency and memory footprint, especially for WebAssembly modules running in constrained environments.

In this post, we will break down the performance characteristics of three approaches to routing in Spin applications written in JavaScript or TypeScript:

  • Using the Hono Router
  • Using the Itty Router
  • Using no router at all, writing direct logic in Spin’s handler

Why Routing Performance Matters in Spin

Spin apps are built for speed, fast startup, tiny binaries, and low-latency execution. However, when every millisecond counts, even your choice of router can become a bottleneck. Adding unnecessary abstraction could impact the overall application performance. That’s why it’s critical to understand the tradeoffs between routing libraries and raw routing logic.

The Setup

We created a minimal HTTP API that must be able to respond to the following request characteristics:

  • GET /items: To return a list of items as JSON
  • GET /items/:id: To return a particular item as JSON looked up by its identifier We created three distinct applications with Spin v3.2.0 and latest http-js templates. For benchmarking with hey those apps are hosted locally using spin up.

The Hono Implementation

import { Hono } from 'hono/quick'

let items = [
    { id: 1, name: 'coffee' },
    { id: 2, name: 'soda' },
    { id: 3, name: 'milk' },
]

let app = new Hono()

app.get('/items', (c) => c.json(items))

app.get('/items/:id', (c) => {
    let id = +c.req.param('id')
    let found = items.find(i => i.id === id)
    if (!found) {
        return c.status(404)
    }
    return c.json(found)
})

app.fire()

The Itty Implementation

import { AutoRouter, json, status } from 'itty-router'

let items = [
    { id: 1, name: 'coffee' },
    { id: 2, name: 'soda' },
    { id: 3, name: 'milk' },
]

let router = AutoRouter()
router.get('/items', () => {
    return json(items)
}).get('/items/:id', ({ id }) => {
    let found = items.find(i => i.id === +id)
    if (!found) {
        return status(404)
    }
    return json(found)
})

addEventListener('fetch', (event) => {
    event.respondWith(router.fetch(event.request))
})

The Manual Routing Implementation

let items = [
  { id: 1, name: 'coffee' },
  { id: 2, name: 'soda' },
  { id: 3, name: 'milk' },
]

function handle(request) {
  const url = new URL(request.url)
  const path = url.pathname.slice(1)
  const method = request.method

  if (method === 'GET' && (path === 'items' || path === 'items/')) {
    return new Response(JSON.stringify(items), {
      status: 200,
      headers: {
        'content-type': 'application/json'
      }
    })
  }
  const id = path.split('/')[1]
  if (method === 'GET' && !!id) {
    try {
      const item = items.find(i => i.id === parseInt(id, 10))
      if (item) {
        return new Response(JSON.stringify(item), {
          status: 200,
          headers: {
            'content-type': 'application/json'
          }
        });
      }
    } catch (err) {
      return new Response(null, { status: 400 })
    }
  }
  return new Response(null, { status: 404 })

}

addEventListener('fetch', (event) => {
  event.respondWith(handle(event.request))
})

Benchmarking

We used hey to stress test each routing setup with 200 concurrent workers. Each configuration was tested under constant load for 5s, 10s, and 20s, repeating each test 5 times. From these runs, we calculated the average requests per second (RPS) and latency percentiles (95th and 99th).

All tests were performed on an Azure Standard D8s-v5 VM, equipped with 8 vCPUs and 32 GiB of RAM, running Debian 12 Bookworm.

Router Average RPS 95th 99th
Manual Routing 4922.2 15.7ms 17.5ms
Itty Router 3718.3 20.1ms 23.5ms
Hono Router 3509.8 21.4ms 24.3ms

Spin JS/TS Routing Showdown

Latency Distribution

Analysis

Manual Routing

Manual routing delivered the highest throughput at 4922 RPS and the lowest tail latencies—15.7ms at the 95th percentile, 17.5ms at the 99th. This approach avoids all abstraction overhead and is ideal for tight, performance-critical APIs.