Elysia 2 vs Hono 4.13 on Bun: within 6%, and one TypeBox call was worth 17% to our Hono app
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
Cleanfor exact-mirror raised our Hono app's throughput 16-17% on the two body routes. If your Hono app strips unknown keys withClean(tbValidator'suseCleanflag, 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.
POST /echo+15.7% from the swapPOST /orders+17.2% from the swaptable view
| route | c | Hono + Clean | Hono + exact-mirror | Elysia | swap gain | Clean costs |
|---|---|---|---|---|---|---|
| POST /echo | 10 | 99,906.8 | 114,608.0 | 120,942.5 | +14.7% | −12.8% |
| POST /echo | 100 | 103,350.4 | 119,575.8 | 126,490.4 | +15.7% | −13.6% |
| POST /echo | 500 | 100,776.5 | 117,940.8 | 125,121.4 | +17.0% | −14.6% |
| POST /orders | 10 | 24,608.4 | 28,508.0 | 28,711.5 | +15.8% | −13.7% |
| POST /orders | 100 | 26,373.1 | 30,907.2 | 30,789.1 | +17.2% | −14.7% |
| POST /orders | 500 | 25,134.4 | 30,071.9 | 29,880.9 | +19.6% | −16.4% |
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.
- 1. Started by cron · 2026-09-24 02:00thrown away46,631
cron scheduled the whole run as background work; every route ran about 3x slow
- 2. Daytime, by hand · 2026-09-24 09:00thrown away128,419
thermal throttling in round 1, 95-400% foreign CPU, spreads 7-40% on most cells, up to 86%
- 3. Quiet night · 2026-09-27 00:21thrown away143,512
machine clean, but our Hono app validated with interpreted TypeBox 0.34 while Elysia compiled 1.3
- 4. Run of record · 2026-09-27 11:13run of record142,489
three configurations, same compiled validator, cleaner held constant in H-mirror; 189/189 windows valid
table view
| attempt | when | req/s | windows | run status | valid windows | kept |
|---|---|---|---|---|---|---|
| Started by cron | 2026-09-24 02:00 | 46,631.5 | 46,726, 46,631, 45,647 | passed | 126 of 126 | no |
| Daytime, by hand | 2026-09-24 09:00 | 128,419.2 | 109,304, 135,159, 128,419 | failed | 125 of 126 | no |
| Quiet night | 2026-09-27 00:21 | 143,512.2 | 143,800, 143,512, 143,249 | passed | 126 of 126 | no |
| Run of record | 2026-09-27 11:13 | 142,488.9 | 143,012, 142,334, 142,489 | passed | 189 of 189 | yes |
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.
GET /healthElysia won 3 of 3 roundsPOST /echoElysia won 3 of 3 roundsGET /meElysia won 2 of 3 roundsGET /users/:id *Elysia won 3 of 3 roundsGET /users (list) *Elysia won 3 of 3 roundsPOST /ordersElysia won 1 of 3 roundsGET /cpuElysia won 3 of 3 rounds* 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
| route | c | Elysia req/s | Hono + exact-mirror req/s | Elysia ÷ Hono | rounds Elysia won |
|---|---|---|---|---|---|
| GET /health | 10 | 131,056.2 | 129,425.4 | 1.013 | 3 of 3 |
| GET /health | 100 | 142,488.9 | 139,159.2 | 1.024 | 3 of 3 |
| GET /health | 500 | 141,963.8 | 137,652.9 | 1.031 | 3 of 3 |
| POST /echo | 10 | 120,942.5 | 114,608.0 | 1.055 | 3 of 3 |
| POST /echo | 100 | 126,490.4 | 119,575.8 | 1.058 | 3 of 3 |
| POST /echo | 500 | 125,121.4 | 117,940.8 | 1.061 | 3 of 3 |
| GET /me | 10 | 28,014.8 | 27,701.1 | 1.011 | 2 of 3 |
| GET /me | 100 | 27,984.5 | 27,813.5 | 1.006 | 2 of 3 |
| GET /me | 500 | 27,295.5 | 27,318.4 | 0.999 | 1 of 3 |
| GET /users/:id * | 10 | 35,522.1 | 32,994.9 | 1.077 | 3 of 3 |
| GET /users/:id * | 100 | 35,934.9 | 33,964.1 | 1.058 | 3 of 3 |
| GET /users/:id * | 500 | 35,261.6 | 33,589.2 | 1.050 | 3 of 3 |
| GET /users (list) * | 10 | 26,088.5 | 24,542.5 | 1.063 | 3 of 3 |
| GET /users (list) * | 100 | 25,938.6 | 24,544.5 | 1.057 | 3 of 3 |
| GET /users (list) * | 500 | 25,558.0 | 24,271.1 | 1.053 | 3 of 3 |
| POST /orders | 10 | 28,711.5 | 28,508.0 | 1.007 | 2 of 3 |
| POST /orders | 100 | 30,789.1 | 30,907.2 | 0.996 | 1 of 3 |
| POST /orders | 500 | 29,880.9 | 30,071.9 | 0.994 | 1 of 3 |
| GET /cpu | 10 | 236.6 | 230.8 | 1.025 | 2 of 3 |
| GET /cpu | 100 | 234.9 | 230.0 | 1.021 | 3 of 3 |
| GET /cpu | 500 | 234.3 | 231.5 | 1.012 | 3 of 3 |
| 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.
ticks: the two measured spreads
table view
| route | runs (req/s) | spread |
|---|---|---|
| GET /health | 140,429, 140,278, 139,971, 139,857, 140,404 | 0.4% |
| GET /users/:id | 32,299, 31,938, 32,361, 32,136, 32,278 | 1.3% |
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
- Apps, harness and raw results for this comparison: https://github.com/Dave93/elysia-benchmarks
- Hono app with the cleaner switch: https://github.com/Dave93/elysia-benchmarks/blob/main/apps/hono-api/src/app.ts