borgo benchmarks
Five scenarios, one contract every implementation must satisfy before a number is reported. Method first, numbers second.
The biases, stated first
- We wrote the harness and one of the subjects. borgo is our framework; nobody from the other teams reviewed their implementation here.
- We are better at borgo than at the alternatives. Each competitor is idiomatic but un-tuned; where a faster mode was deliberately not used, the run's notes say so. Read their numbers as floors, not ceilings.
- A benchmark app is not an application. No database, no auth, no real template weight. A framework that wins here by 3× will not make your app 3× faster.
- Self-hosted against self-hosted. Next.js runs `next start` on Node, not Vercel — the deployment borgo competes with, not the one Next.js is most optimised for.
- One machine. The load generator competes with the server for the same cores; treat the numbers as conservative and compressed at the top end.
Found a scenario tilted our way? That is a harness bug — open an issue or send a better competitor implementation.
This run
- captured
- 2026-09-10 15:46 UTC
- borgo
- 0.21.0 @ cf3e1f6
- machine
- 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz · 8 cpu · 32 GB · Windows_NT 10.0.26200
- toolchain
- bun 1.4.2 · go1.27.0 windows/amd64 · node v24.16.0
- load
- oha 1.15.0 · 64 connections · 30s × 3 runs (median shown) · 5s warmup
- sweeps
- 2, in opposite directions — each bar below is the app's worse sweep, the conservative reading of a shared machine; the committed report shows both, with the drift between them
- note
- dev laptop, mains power; VS Code and an agent session idle in background during the run - read the cpu-idle rows and the drift-between-sweeps table before reading any row as a comparison
This run's machine was not idle
The harness measured 22.3% CPU busy before the first app started and 23.7% after the last was killed, against its own 10% threshold — so it marks every number below as contaminated by an unknown amount. Read the orderings as indicative, not as verdicts; the committed report carries the per-scenario drift table between the two sweeps, which is where the machine's noise shows itself. A clean two-machine run replaces this one the day it exists.
hello-json
the floor: request plumbing with almost no work attached — higher is better.
table view
| framework | value | latency |
|---|---|---|
| elysia | 37,286 req/s | p50 1.6 ms · p99 3.0 ms |
| hono | 36,483 req/s | p50 1.7 ms · p99 3.0 ms |
| fastify | 21,946 req/s | p50 2.8 ms · p99 4.9 ms |
| express | 16,484 req/s | p50 3.7 ms · p99 8.1 ms |
| borgo | 14,395 req/s | p50 3.9 ms · p99 14.3 ms |
| astro | 3,938 req/s | p50 15.2 ms · p99 29.3 ms |
| nextjs | 2,349 req/s | p50 25.4 ms · p99 53.5 ms |
api-list
~15 kB of JSON generated per request; serialisation and body writing — higher is better.
table view
| framework | value | latency |
|---|---|---|
| elysia | 18,204 req/s | p50 3.2 ms · p99 6.0 ms |
| hono | 16,924 req/s | p50 3.4 ms · p99 6.5 ms |
| fastify | 12,981 req/s | p50 4.7 ms · p99 8.2 ms |
| borgo | 10,944 req/s | p50 5.2 ms · p99 17.1 ms |
| express | 9,972 req/s | p50 6.2 ms · p99 11.1 ms |
| astro | 3,256 req/s | p50 18.4 ms · p99 31.9 ms |
| nextjs | 2,019 req/s | p50 29.6 ms · p99 63.8 ms |
ssr-page
a page with layout, nav, 20 rendered rows and a hydrated counter, server-rendered per request — higher is better.
table view
| framework | value | latency |
|---|---|---|
| borgo | 4,468 req/s | p50 13.5 ms · p99 23.7 ms |
| astro | 2,220 req/s | p50 27.3 ms · p99 50.2 ms |
| nextjs | 597 req/s | p50 104.3 ms · p99 148.3 ms |
static-asset
a 31,607-byte file from disk, byte-identical for every implementation — higher is better.
table view
| framework | value | latency |
|---|---|---|
| hono | 21,157 req/s | p50 2.7 ms · p99 5.0 ms |
| elysia | 21,111 req/s | p50 2.7 ms · p99 5.1 ms |
| fastify | 18,972 req/s | p50 3.2 ms · p99 6.6 ms |
| express | 17,301 req/s | p50 3.5 ms · p99 6.9 ms |
| nextjs | 5,924 req/s | p50 9.8 ms · p99 19.8 ms |
| astro | 4,949 req/s | p50 12.2 ms · p99 21.6 ms |
| borgo | 4,903 req/s | p50 12.7 ms · p99 20.7 ms |
memory-conn
RSS of the whole process tree at rest, and the cost of holding 1000 open SSE connections — lower is better.
resident memory after boot, idle
table view
| framework | value |
|---|---|
| hono | 38.5 MB |
| elysia | 44.9 MB |
| express | 51.2 MB |
| borgo | 53.6 MB |
| fastify | 57.2 MB |
| astro | 66.3 MB |
| nextjs | 86.3 MB |
per held connection
table view
| framework | value |
|---|---|
| elysia | 5.3 KB |
| hono | 6.9 KB |
| fastify | 20.1 KB |
| express | 21.6 KB |
| astro | 55.2 KB |
| borgo | 65.0 KB |
| nextjs | 134.9 KB |
Not measured
A missing competitor is honest; a half-implemented one would be a lie with a table.
- fresh — stub: Deno is not installed on the machine this harness was built on, so a Fresh implementation could not be written and verified - only guessed at. It is left unimplemented on purpose: an unrun competitor is honest, a competitor we wrote blind and could not test is not. To finish it: install Deno, scaffold Fresh 2, implement routes/api/hello.ts, routes/api/items.ts, routes/api/events.ts (SSE with an immediate `: ping` flush) and routes/page.tsx per CONTRACT.md, copy the shared payload into static/, then flip status to "implemented" and fill in "implements".
Per-implementation notes
- astro (node) — Server output with the standalone Node adapter - self-hosted, like borgo. Every route sets `prerender = false`, because Astro's default would turn /page into a build-time static file and the scenario is per-request rendering. The React counter is an island (client:load), which is Astro's idiom; Astro therefore ships less client JS than the React frameworks here by design, not by accident. The shared dataset module is copied into src/lib at build time.
- borgo (bun (SSR front server) + go (API binary)) — Two processes: the Bun front server on PORT and the Go API binary on API_PORT. /api/* is proxied by the front server, which is what a deployed borgo app does - so the JSON scenarios include the proxy hop. Measured against the borgo source in this repo (go.mod replace, package.json file: link), not a published release. BUN_CONFIG_MAX_HTTP_REQUESTS is raised from Bun's default of 256 because that default caps the number of concurrent requests the front server can have in flight to the Go API - which silently ceilings concurrent SSE streams at ~255. This is disclosed rather than quietly applied: see the finding in bench/README.md. Started via the CLI entrypoint directly, exactly as the shipped Dockerfile does, rather than through `bun run start`: the latter adds a shell and two launcher processes that RSS-of-the-tree would otherwise charge to borgo, and that no production deployment runs.
- elysia (bun) — Bun-native router. Like Hono it does not claim the ssr-page scenario. Elysia's own benchmarks lean on compile-time schema specialisation that this plain implementation does not use, so treat these numbers as Elysia-as-written-here rather than Elysia-at-its-best.
- express (node) — Run on Node, which is where Express lives; running it on Bun would be a different product. ETag generation is disabled because none of the other implementations hash their bodies - leaving it on would charge Express for a feature the comparison does not ask for. Does not claim ssr-page.
- fastify (node) — Run on Node with the logger off. Fastify is fastest when given JSON response schemas so it can compile a serialiser; this implementation does not use them, because the contract's list is generated per request and the other implementations serialise generically. Fastify-with-schemas would be a fair separate entry, not a substitute for this one. Does not claim ssr-page.
- hono (bun) — A router, not a meta-framework: it does not claim the ssr-page scenario, because rendering React through Hono would be an application we wrote rather than anything Hono provides. Included as a floor - it shows what the request plumbing costs when there is almost no framework above it.
- nextjs (node) — Production build, self-hosted with `next start` on Node - the deployment borgo actually competes with, not Vercel's edge. Every route sets `dynamic = 'force-dynamic'`: left alone Next would prerender /page at build time and serve a static file, which is a different and much faster thing than server-rendering per request. Compression is off in next.config.mjs so every implementation ships identical bytes. The shared dataset module is copied into lib/ at build time because Next's bundler will not reach outside the project root. Started by invoking Next's own bin on node directly rather than through `bun x next start`: the runner charges RSS over the whole process tree, and `bun x` interposes a launcher that borgo - which is deliberately started without one, for exactly this reason - was not being charged for. Same rule for everyone or the memory table is not a comparison.