borgo benchmarks

Five scenarios, one contract every implementation must satisfy before a number is reported. Method first, numbers second.

The biases, stated first

  1. We wrote the harness and one of the subjects. borgo is our framework; nobody from the other teams reviewed their implementation here.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

elysia: 37,286 req/s (p50 1.6 ms · p99 3.0 ms)elysia37,286 req/sp50 1.6 ms · p99 3.0 mshono: 36,483 req/s (p50 1.7 ms · p99 3.0 ms)hono36,483 req/sp50 1.7 ms · p99 3.0 msfastify: 21,946 req/s (p50 2.8 ms · p99 4.9 ms)fastify21,946 req/sp50 2.8 ms · p99 4.9 msexpress: 16,484 req/s (p50 3.7 ms · p99 8.1 ms)express16,484 req/sp50 3.7 ms · p99 8.1 msborgo: 14,395 req/s (p50 3.9 ms · p99 14.3 ms)borgo14,395 req/sp50 3.9 ms · p99 14.3 msastro: 3,938 req/s (p50 15.2 ms · p99 29.3 ms)astro3,938 req/sp50 15.2 ms · p99 29.3 msnextjs: 2,349 req/s (p50 25.4 ms · p99 53.5 ms)nextjs2,349 req/sp50 25.4 ms · p99 53.5 ms
table view
frameworkvaluelatency
elysia37,286 req/sp50 1.6 ms · p99 3.0 ms
hono36,483 req/sp50 1.7 ms · p99 3.0 ms
fastify21,946 req/sp50 2.8 ms · p99 4.9 ms
express16,484 req/sp50 3.7 ms · p99 8.1 ms
borgo14,395 req/sp50 3.9 ms · p99 14.3 ms
astro3,938 req/sp50 15.2 ms · p99 29.3 ms
nextjs2,349 req/sp50 25.4 ms · p99 53.5 ms

api-list

~15 kB of JSON generated per request; serialisation and body writing — higher is better.

elysia: 18,204 req/s (p50 3.2 ms · p99 6.0 ms)elysia18,204 req/sp50 3.2 ms · p99 6.0 mshono: 16,924 req/s (p50 3.4 ms · p99 6.5 ms)hono16,924 req/sp50 3.4 ms · p99 6.5 msfastify: 12,981 req/s (p50 4.7 ms · p99 8.2 ms)fastify12,981 req/sp50 4.7 ms · p99 8.2 msborgo: 10,944 req/s (p50 5.2 ms · p99 17.1 ms)borgo10,944 req/sp50 5.2 ms · p99 17.1 msexpress: 9,972 req/s (p50 6.2 ms · p99 11.1 ms)express9,972 req/sp50 6.2 ms · p99 11.1 msastro: 3,256 req/s (p50 18.4 ms · p99 31.9 ms)astro3,256 req/sp50 18.4 ms · p99 31.9 msnextjs: 2,019 req/s (p50 29.6 ms · p99 63.8 ms)nextjs2,019 req/sp50 29.6 ms · p99 63.8 ms
table view
frameworkvaluelatency
elysia18,204 req/sp50 3.2 ms · p99 6.0 ms
hono16,924 req/sp50 3.4 ms · p99 6.5 ms
fastify12,981 req/sp50 4.7 ms · p99 8.2 ms
borgo10,944 req/sp50 5.2 ms · p99 17.1 ms
express9,972 req/sp50 6.2 ms · p99 11.1 ms
astro3,256 req/sp50 18.4 ms · p99 31.9 ms
nextjs2,019 req/sp50 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.

borgo: 4,468 req/s (p50 13.5 ms · p99 23.7 ms)borgo4,468 req/sp50 13.5 ms · p99 23.7 msastro: 2,220 req/s (p50 27.3 ms · p99 50.2 ms)astro2,220 req/sp50 27.3 ms · p99 50.2 msnextjs: 597 req/s (p50 104.3 ms · p99 148.3 ms)nextjs597 req/sp50 104.3 ms · p99 148.3 ms
table view
frameworkvaluelatency
borgo4,468 req/sp50 13.5 ms · p99 23.7 ms
astro2,220 req/sp50 27.3 ms · p99 50.2 ms
nextjs597 req/sp50 104.3 ms · p99 148.3 ms

static-asset

a 31,607-byte file from disk, byte-identical for every implementation — higher is better.

hono: 21,157 req/s (p50 2.7 ms · p99 5.0 ms)hono21,157 req/sp50 2.7 ms · p99 5.0 mselysia: 21,111 req/s (p50 2.7 ms · p99 5.1 ms)elysia21,111 req/sp50 2.7 ms · p99 5.1 msfastify: 18,972 req/s (p50 3.2 ms · p99 6.6 ms)fastify18,972 req/sp50 3.2 ms · p99 6.6 msexpress: 17,301 req/s (p50 3.5 ms · p99 6.9 ms)express17,301 req/sp50 3.5 ms · p99 6.9 msnextjs: 5,924 req/s (p50 9.8 ms · p99 19.8 ms)nextjs5,924 req/sp50 9.8 ms · p99 19.8 msastro: 4,949 req/s (p50 12.2 ms · p99 21.6 ms)astro4,949 req/sp50 12.2 ms · p99 21.6 msborgo: 4,903 req/s (p50 12.7 ms · p99 20.7 ms)borgo4,903 req/sp50 12.7 ms · p99 20.7 ms
table view
frameworkvaluelatency
hono21,157 req/sp50 2.7 ms · p99 5.0 ms
elysia21,111 req/sp50 2.7 ms · p99 5.1 ms
fastify18,972 req/sp50 3.2 ms · p99 6.6 ms
express17,301 req/sp50 3.5 ms · p99 6.9 ms
nextjs5,924 req/sp50 9.8 ms · p99 19.8 ms
astro4,949 req/sp50 12.2 ms · p99 21.6 ms
borgo4,903 req/sp50 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

hono: 38.5 MBhono38.5 MBelysia: 44.9 MBelysia44.9 MBexpress: 51.2 MBexpress51.2 MBborgo: 53.6 MBborgo53.6 MBfastify: 57.2 MBfastify57.2 MBastro: 66.3 MBastro66.3 MBnextjs: 86.3 MBnextjs86.3 MB
table view
frameworkvalue
hono38.5 MB
elysia44.9 MB
express51.2 MB
borgo53.6 MB
fastify57.2 MB
astro66.3 MB
nextjs86.3 MB

per held connection

elysia: 5.3 KB (5.1 MB for 1000)elysia5.3 KB5.1 MB for 1000hono: 6.9 KB (6.7 MB for 1000)hono6.9 KB6.7 MB for 1000fastify: 20.1 KB (19.7 MB for 1000)fastify20.1 KB19.7 MB for 1000express: 21.6 KB (21.1 MB for 1000)express21.6 KB21.1 MB for 1000astro: 55.2 KB (54.0 MB for 1000)astro55.2 KB54.0 MB for 1000borgo: 65.0 KB (63.5 MB for 1000)borgo65.0 KB63.5 MB for 1000nextjs: 134.9 KB (131.8 MB for 1000)nextjs134.9 KB131.8 MB for 1000
table view
frameworkvalue
elysia5.3 KB
hono6.9 KB
fastify20.1 KB
express21.6 KB
astro55.2 KB
borgo65.0 KB
nextjs134.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.