Back to blog

Elysia 2 vs Hono 4.13 on Bun: within 6%, and one TypeBox call was worth 17% to our Hono app

Davron10 min read
BenchmarkBunElysiaPerformanceHonoTypeBox

We ran the same seven-route API on Elysia 2 (a beta) and Hono 4.13, both on Bun, plus a third build of the Hono app with a different cleaner. With the cleaner held constant, the two frameworks came out within about 6% of each other in raw throughput at 100 connections. Swapping TypeBox's Clean for exact-mirror inside the Hono app raised its throughput 15.7% on POST /echo and 17.2% on POST /orders at the same concurrency. Here is the swap, the tables, and the three runs we threw away first.

TL;DR: Same cleaner on both sides, Bun, 100 connections: Elysia 2 (a beta) and Hono 4.13 came out within about 6% raw, and within about 2% on four of seven routes. The bigger number was in our own code: swapping TypeBox's Clean for exact-mirror raised our Hono app's throughput 16-17% on the two body routes. If your Hono app strips unknown keys with Clean (tbValidator's useClean flag, off by default), time that call first.

We set out to compare Elysia and Hono on Bun and ended up measuring our own validator. The frameworks are close. GET /health at 100 connections has no validator on it, so it's routing, context and response and nothing else. Elysia 2.0.0-beta.12 did 142,488.9 req/s there and Hono 4.13.8 did 139,159.2, 2.4% apart. Both Hono builds read the same on it, and in a noise check just before the run, five identical Hono windows on that route spread 0.41%.

The big gap came from one call. Our Hono app validated bodies with compiled TypeBox 1.3.26, then ran TypeBox's Clean to drop unknown keys. We swapped that call for exact-mirror, same build, same day, and Hono went up 15.7% on POST /echo and 17.2% on POST /orders at c=100 (an environment variable switches between the two configurations).

Everything here is Bun, one process per framework, no memory numbers. Hono also runs on Cloudflare Workers, Deno, Node and several other platforms from one codebase; we tested only Bun.

The swap

Here's the body path of our Hono validator, trimmed from apps/hono-api/src/app.ts and wired through hono/validator. It's close to what @hono/typebox-validator's tbValidator does with useClean, with two differences: tbValidator cleans before checking where we check first, and it answers 400 with the error list where we answer 422, Elysia's status.

import { validator } from "hono/validator";
import type { TSchema } from "typebox";
import { Compile } from "typebox/compile";
import { createMirror } from "exact-mirror";

function tbJson<T extends TSchema>(s: T) {
  const v = Compile(s);
  // Before: const clean = (x: unknown) => v.Clean(x);
  // Drop the cleaner entirely and unknown keys ({ "isAdmin": true })
  // pass validation and reach your handler untouched.
  const clean = createMirror(s as any, { Compile } as any);
  return validator("json", (input, c) => {
    if (v.Check(input)) return clean(input) as any;
    return c.json({ error: "validation" }, 422);
  });
}

The real file also runs a coercion fallback for strings, which is how "age": "30" gets a 200, as it does on Elysia. This trimmed version answers 422 to that body, so run your own contract tests after the swap.

Both versions live in one build in the benchmark, and HONO_CLEANER=mirror picks between them. We pinned exact-mirror at 1.2.4, the version Elysia 2.0.0-beta.12 resolves, which means the Hono app and the Elysia app strip keys with the same code.

We clean at all because our parity gate has an "extras" case: send a body with an unknown key, expect it gone from the response. Both cleaners pass it.

hono/validator runs whatever function you hand it, so choosing TypeBox's Clean was our decision, and so was the cost. Elysia makes that choice for you. Its normalize option defaults to exact-mirror, which installs as a peer dependency, and Elysia's own option docs mark the 'typebox' mode as the one with a performance cost. If exact-mirror fails to load, Elysia falls back to TypeBox Clean and prints a warning.

exact-mirror has its own catch. Version 1.2.4 builds its cleaner with Function(). We only ran it on Bun, but on a runtime that forbids code generation from strings (Cloudflare Workers is one), expect it to fail.

What the cleaner costs

We ran three configurations. E is Elysia, H is Hono with TypeBox Clean, and H-mirror is the same Hono build with exact-mirror. The seven routes and the seed are the ones from our Elysia 2 vs NestJS 12 layer-by-layer benchmark. Both apps here run on Bun 1.4.2 with Drizzle over Bun SQL, and the Hono app imports the Elysia app's database, JWT and hashing modules instead of copying them.

hono · typebox clean vs exact-mirror · c=100
One function call, most of the gap
POST /echo+15.7% from the swap
Hono 4.13 · TypeBox Clean
103,350
Hono 4.13 · exact-mirror
119,576
Elysia 2 · Bun
126,490
POST /orders+17.2% from the swap
Hono 4.13 · TypeBox Clean
26,373
Hono 4.13 · exact-mirror
30,907
Elysia 2 · Bun
30,789
table view
routecHono + CleanHono + exact-mirrorElysiaswap gainClean costs
POST /echo1099,906.8114,608.0120,942.5+14.7%−12.8%
POST /echo100103,350.4119,575.8126,490.4+15.7%−13.6%
POST /echo500100,776.5117,940.8125,121.4+17.0%−14.6%
POST /orders1024,608.428,508.028,711.5+15.8%−13.7%
POST /orders10026,373.130,907.230,789.1+17.2%−14.7%
POST /orders50025,134.430,071.929,880.9+19.6%−16.4%
Median of three 30-second windows per cell. Same Hono build, same compiled TypeBox 1.3.26 check; only the step that strips unknown keys differs (env HONO_CLEANER). Elysia, which uses exact-mirror internally, is drawn for reference.

H against H-mirror at c=100, raw successful requests per second:

Route H (TypeBox Clean) H-mirror (exact-mirror) H-mirror over H
POST /echo 103,350.4 119,575.7 +15.7%
POST /orders 26,373.1 30,907.2 +17.2%
GET /users/:id 32,966.5 33,964.1 +3.0%
GET /users (list) 23,713.1 24,544.5 +3.5%
GET /me 27,835.7 27,813.5 -0.1%
GET /health 139,201.6 139,159.2 0.0%

/health and /me have no validator, and they come out even; that's the control. Put the other way, H is 13.6% slower than H-mirror on /echo and 14.7% slower on /orders.

It held in every round. Nine round pairs (three rounds at each of three concurrencies), and H never beat H-mirror on /echo, /orders, /users/:id or the list. On the two body routes the median H/H-mirror ratio stayed between 0.836 and 0.872 at all three concurrencies.

Latency moved too. At c=100, /echo p99 went from 1.70 ms on H to 1.39 ms on H-mirror, and /orders p50 from 3.72 ms to 3.16 ms.

Param and query routes pay less, about 3%. Both H builds send those strings through a coercion path we wrote by hand (more on it below) before the cleaner runs, and we think that's why the cleaner is a smaller share of those requests. /cpu validates its query too, and H even read 1.3% above H-mirror there, but a few milliseconds of hashing per request swamp the cleaner, so we count that as noise.

Three runs we threw away

We didn't find the cleaner by looking for it. It turned up after three full runs, all of which we threw out, and two of those had finished green.

The first one ran overnight from cron on 2026-09-24. It finished with 126 of 126 windows valid and the harness green. Then we looked at Elysia's /health at c=100 and saw 46,631 req/s, on a route that had read 138,992 in our Django run of record. Something was off by about 3x. A SHA-256 self-test took 85 ms from a terminal and 227 to 246 ms under cron, which pointed at macOS scheduling cron's children as background work: taskpolicy -b reproduced the slowdown and a LaunchAgent marked Interactive did not. Worse, the throttling didn't land evenly. /cpu is the same module on both frameworks, identical code, and it read Hono 7% faster. We publish ratios, and a bias like that would have sat inside every one of them.

So we ran it by hand the same day. This time the machine was thermally throttled in round 1 and the noise phase, round-to-round spread landed between 7% and 40% on most cells and reached 86% on /orders, and one window went missing (125 of 126). Foreign CPU reached 400% and still passed our gate, which sat at 450% back then. We filed the numbers as a preview, cut the gate to 200%, and turned autovacuum off on the orders table for every later run.

The third run started just after midnight on 2026-09-27, after we'd quit Raycast, rebooted and killed dasd. Foreign CPU had a median of 11%, and it passed. That morning we read the Hono code again and saw it validating with the interpreted Value.* API of @sinclair/typebox 0.34, while Elysia compiles typebox 1.3. The harness had measured correctly; our Hono app was wrong, and every validated route in that run had compared two validators instead of two frameworks.

We moved Hono to compiled typebox 1.3.26, the version Elysia resolves, and the /echo gap still didn't close. So we timed each step of the validator on the /echo body. Clean was the step that cost.

We didn't keep that micro-benchmark script, so every cleaner number in this article comes from the full run, and that's why the run of record has three configurations instead of two.

same code · GET /health · c=100 · four attempts
What one cell read before we trusted it
  1. 1. Started by cron · 2026-09-24 02:00thrown away
    46,631

    cron scheduled the whole run as background work; every route ran about 3x slow

  2. 2. Daytime, by hand · 2026-09-24 09:00thrown away
    128,419

    thermal throttling in round 1, 95-400% foreign CPU, spreads 7-40% on most cells, up to 86%

  3. 3. Quiet night · 2026-09-27 00:21thrown away
    143,512

    machine clean, but our Hono app validated with interpreted TypeBox 0.34 while Elysia compiled 1.3

  4. 4. Run of record · 2026-09-27 11:13run of record
    142,489

    three configurations, same compiled validator, cleaner held constant in H-mirror; 189/189 windows valid

table view
attemptwhenreq/swindowsrun statusvalid windowskept
Started by cron2026-09-24 02:0046,631.546,726, 46,631, 45,647passed126 of 126no
Daytime, by hand2026-09-24 09:00128,419.2109,304, 135,159, 128,419failed125 of 126no
Quiet night2026-09-27 00:21143,512.2143,800, 143,512, 143,249passed126 of 126no
Run of record2026-09-27 11:13142,488.9143,012, 142,334, 142,489passed189 of 189yes
Elysia's GET /health at c=100, median of that run's valid windows. Only the last run is the run of record; the others stay in the repository as the record of what went wrong.

Elysia vs Hono with the cleaner held constant

E against H-mirror is the framework comparison, since the two share the cleaner, the validator library and the driver. Numbers are at c=100. The last column counts the rounds Elysia won across all three concurrencies, so 9 is the maximum.

elysia vs hono · cleaner held constant · c=100
Where the framework itself makes a difference
Elysia 2 · Bun Hono 4.13 · exact-mirror
GET /healthElysia won 3 of 3 rounds
+2.4%
POST /echoElysia won 3 of 3 rounds
+5.8%
GET /meElysia won 2 of 3 rounds
+0.6%
GET /users/:id *Elysia won 3 of 3 rounds
+5.8%
GET /users (list) *Elysia won 3 of 3 rounds
+5.7%
POST /ordersElysia won 1 of 3 rounds
−0.4%
GET /cpuElysia won 3 of 3 rounds
+2.1%

* Path and query values arrive as strings. Our Hono app turns them into numbers with TypeBox's interpreted Convert; Elysia compiles that step. Part of the gap on these two rows is our coercion, not Hono's router.

table view
routecElysia req/sHono + exact-mirror req/sElysia ÷ Honorounds Elysia won
GET /health10131,056.2129,425.41.0133 of 3
GET /health100142,488.9139,159.21.0243 of 3
GET /health500141,963.8137,652.91.0313 of 3
POST /echo10120,942.5114,608.01.0553 of 3
POST /echo100126,490.4119,575.81.0583 of 3
POST /echo500125,121.4117,940.81.0613 of 3
GET /me1028,014.827,701.11.0112 of 3
GET /me10027,984.527,813.51.0062 of 3
GET /me50027,295.527,318.40.9991 of 3
GET /users/:id *1035,522.132,994.91.0773 of 3
GET /users/:id *10035,934.933,964.11.0583 of 3
GET /users/:id *50035,261.633,589.21.0503 of 3
GET /users (list) *1026,088.524,542.51.0633 of 3
GET /users (list) *10025,938.624,544.51.0573 of 3
GET /users (list) *50025,558.024,271.11.0533 of 3
POST /orders1028,711.528,508.01.0072 of 3
POST /orders10030,789.130,907.20.9961 of 3
POST /orders50029,880.930,071.90.9941 of 3
GET /cpu10236.6230.81.0252 of 3
GET /cpu100234.9230.01.0213 of 3
GET /cpu500234.3231.51.0123 of 3
Median of three 30-second windows per cell. Both sides validate with compiled TypeBox 1.3.26 and clean with exact-mirror 1.2.4, so what is left is the framework. Rounds won counts Elysia ahead of Hono in the same round. One process each, one laptop, load generator included.
Route E req/s H-mirror req/s Elysia ahead, raw Elysia ahead, per CPU-second Rounds Elysia won
GET /health 142,488.9 139,159.2 +2.4% +2.5% 9 of 9
GET /me 27,984.5 27,813.5 +0.6% +2.4% 5 of 9
POST /orders 30,789.1 30,907.2 -0.4% +1.6% 4 of 9
GET /cpu 234.9 230.0 +2.1% +4.2% 8 of 9
POST /echo 126,490.4 119,575.7 +5.8% +7.4% 9 of 9
GET /users/:id* 35,934.9 33,964.1 +5.8% +7.8% 9 of 9
GET /users (list)* 25,938.6 24,544.5 +5.7% +6.4% 9 of 9

* Partly our own coercion code on the Hono side; see below.

Four rows are within about 2%. /health has no validator, and neither does /me (it verifies a JWT from the header and reads one row). /orders validates a body and, under H-mirror, cleans it with the same code as Elysia. /cpu also goes through our coercion path, but a few milliseconds of hashing per request swamp it. Hono actually won /orders, by 0.4% raw, and /me and /orders split their rounds. Elysia took /health in all nine rounds, and 2.4% is about six times the 0.41% spread of those five back-to-back Hono windows. That's one config and five windows, a thin floor; the same check on /users/:id spread 1.3%, which is the band the chart draws. Call those four close, not ranked.

config H · c=100 · 5 back-to-back 30 s runs per route
Why a delta under 1.3% is called noise
GET /health139,857 – 140,429 req/s
spread 0.4%
GET /users/:id31,938 – 32,361 req/s
spread 1.3%
verdict scale, % delta
noise
small
real
0%1.3%10.0%20.0%

ticks: the two measured spreads

table view
routeruns (req/s)spread
GET /health140,429, 140,278, 139,971, 139,857, 140,4040.4%
GET /users/:id32,299, 31,938, 32,361, 32,136, 32,2781.3%
The largest run-to-run spread, (max − min) ÷ min, is the significance floor for every comparison in this article. Deltas inside it are noise; between the floor and 10% small; 10% and above real.

The largest framework gap we can defend is /echo, at about 6%: a validated body, no database, the same cleaner. /users/:id and the list show the same size and we trust them less. Path params and query strings reach our Hono validator as strings and go through a hand-written guard, coercibleLikeElysia(), then TypeBox's Default and Convert.

We wrote that guard because TypeBox's Convert accepted inputs Elysia rejects: "1.5" became 1, true became 1, and 1 became "1", each a 200 where Elysia returns 422. Part of that 5.8% is our coercion code. Our guess is that most Hono apps write Number(c.req.param("id")) by hand and never take that path at all.

Against plain H, Hono looks much worse. E/H on /echo runs 1.21 to 1.24 raw across 10, 100 and 500 connections. Most of that is the cleaner, and the remaining 5.8% at c=100 is the E vs H-mirror gap above.

What this run leaves out

Everything ran on one Apple M4 Pro laptop with 14 cores and macOS 27.0, with PostgreSQL 17.10 and bombardier on the same machine and no CPU affinity. Each framework ran as one process using 1.0 to 1.5 cores, which is why we report per-CPU-second next to raw throughput; the Elysia 2 vs Django 6.1 write-up explains that metric.

The Elysia we measured is a beta, 2.0.0-beta.12. There's no memory number, because the harness serves every route from one process in a fixed order, so resident memory records the process's history rather than any one route.

People pick Hono for its middleware and for running on Workers, Deno and Node. This run measured neither.

There's one cell we can't explain. H's /orders at c=500 dropped to 19,334 req/s in round 3, against 25,134 and 25,141 in rounds 1 and 2, with zero Postgres checkpoints in the window and foreign CPU at 21%. E and H-mirror didn't dip at that cell. The median is unaffected, and our earlier confounded run also had a one-round dip on the same cell.

Running it yourself

The apps, harness, parity checks and raw results are in github.com/Dave93/elysia-benchmarks. The run of record is results/full-hono3-2026-09-27.json (189 of 189 windows valid, three rounds, config order rotated each round), with medians in results/full-hono3-2026-09-27-summary.md. H-mirror is the Hono app started with HONO_CLEANER=mirror.

On a Mac, start the bench by hand from a terminal, plugged in to AC power. The cron run above is why. Our run script, scripts/overnight-hono.sh, also refuses to start on battery (set ANY_HOUR=1 to start it outside 01:00-05:00). If you run a Workers or Deno lane, we want the numbers.

(ShipKit's own API is still on Elysia 1.4.16, not the 2.0 beta measured here, so none of this changes what ShipKit ships.)

Sources