Skip to content

Repository files navigation

HTTP Benchmarks

Side-by-side benchmarks. Same API.
Real databases. No toy endpoints.

GrafanaNode.jsBunDenoGoPythonTypeScriptPostgreSQLMongoDBRedisCassandraExpressFastifyNestJSHonoElysiaOakChiGinFiberFastAPI

Results (200 Concurrency, 5s per endpoint) 📊

Benchmark Results

Stack Map 📦

Inside every container the server listens on the canonical port 8080 (baked as PORT in the Dockerfile, PLAN §6 rule 1); the benchmark client maps a dynamic host port. The port below is each server's assigned 2LRFF host port (dev_port in its bench.json, PLAN §6), used by its .env.example and as the code default in the Go/Rust/Kotlin/Zig servers; Node/Deno just dev binds the canonical 8080 unless PORT is set (e.g. via cp .env.example .env).

FolderRuntimeFrameworkDev port
benchmarkGo 1.27rc1--
servers/go-stdlibGo 1.27rc1net/http (stdlib)21001
servers/go-chiGo 1.27rc1Chi 5.3.021002
servers/go-ginGo 1.27rc1Gin 1.12.021003
servers/go-fiberGo 1.27rc1Fiber 3.4.021004
servers/go-echoGo 1.27rc1Echo 4.13.421005
servers/ts-expressNode 26.4.0Express 5.2.122001
servers/ts-nestjsNode 26.4.0NestJS 11.1.2722002
servers/ts-fastifyNode 26.4.0Fastify 5.9.022003
servers/ts-honojsNode 26.4.0Hono 4.12.2722005
servers/ts-bun-honojsBun 1.3.14Hono 4.12.2722105
servers/ts-bun-elysiaBun 1.3.14Elysia 1.4.2922106
servers/ts-deno-oakDeno 2.9.1Oak 17.2.022204
servers/ts-deno-honojsDeno 2.9.1Hono 4.12.2722205
servers/py-fastapiPython 3.14.6FastAPI >=0.12823001
servers/py-djangoPython 3.14.6Django >=6.023002
servers/py-flaskPython 3.14.6Flask 3.x23003
servers/rs-axumRust 1.96.1Axum 0.8.924001
servers/rs-actixRust 1.96.1Actix Web 4.14.024002
servers/kt-ktorKotlin 2.3.21Ktor 3.5.125001
servers/kt-spring-bootKotlin 2.3.21Spring Boot 4.1.025002
servers/zigZig 0.16http.zig26001

Pinned dependencies 📌

Deliberate prerelease pins (PLAN §10), exempt from the blanket just update bump; each tracks the newest release of its dist-tag channel via scripts/update.mts, never the stable latest line:

PackagePinned atChannelWhy
typescript7.0.1-rcrcTypeScript 7 native tsc (latest is still 6.x)
drizzle-orm1.0.0-rc.4rcdrizzle 1.0 RC (latest is still 0.45.x)

Held back: bson is pinned to 7.2.0 for the whole workspace via pnpm-workspace.yamloverrides. bson 7.3.x calls v8.startupSnapshot.isBuildingSnapshot() at import time, which Bun 1.3.14 ships as a throwing stub (NotImplementedError), crashing the server on boot. Since PR #26 mongodb is a single shared dependency (@bench/shared), and pnpm resolves one bson version for every consumer (overrides are graph-path scoped, not per-member), so the Node servers pin to 7.2.0 too — pnpm cannot scope an override to just the Bun members. 7.2.0 sits inside mongodb's own ^7.2.0 range; drop the override once Bun implements the stub.

NestJS dev mode tradeoff: under TS7 nest start --watch is gone, so ts-nestjs's dev compiles with tsc then runs node --watch dist/main.js. That restarts the server on rebuild but does not watch-recompile sources — edit, re-run tsc (or just dev ts-nestjs) to pick up changes.

Quick Start 🚀

just benchmark # Run benchmark (interactive mode)
just benchmark --servers=a,b # Run benchmark for specific servers only
just dev ts-bun-honojs # Start dev server (ts-bun-honojs, ts-express, go-chi, etc.)

First pnpm install over a checkout that predates the pnpm workspace can want to purge the old top-level node_modules. pnpm-workspace.yaml sets confirmModulesPurge: false, so the install proceeds non-interactively (no CI=true or TTY needed) instead of aborting with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.

Configuration ⚙️

VariableDefaultDescription
ENVdevdev enables logger, prod disables it
HOST0.0.0.0IP or localhost (mapped to 0.0.0.0)
PORTSee Stack MapServer port

Benchmark config is JSON-only and lives at config/config.json.

API Surface 🌐

Base Routes

MethodRouteResponse
GET/{ "hello": "world"}
GET/healthOK (text/plain)

Params Routes (/params/*)

MethodRouteDescription
GET/params/searchQuery q (trim, default none) and limit (safe int, default 10)
GET/params/url/:valReturns { "dynamic": "<val>" }
GET/params/headerReads X-Custom-Header (trim, default none)
POST/params/bodyValidates JSON object (no array/null), returns { "body": <parsed> }
GET/params/cookieReads cookie foo (trim, default none), sets cookie bar
POST/params/formSupports urlencoded/multipart, returns { "name": "<trim>", "age": <int> }
POST/params/fileMultipart file (max 1MB, text/plain only), returns { "filename", "size", "content" }

Database Routes (/db/{database}/*)

Supported databases: postgres, mongodb, redis, cassandra

MethodRouteDescription
GET/db/{database}/healthDatabase health check
POST/db/{database}/usersCreate user { "name", "email", "favoriteNumber?" } (201)
GET/db/{database}/users/{id}Get user by ID (200 or 404)
PATCH/db/{database}/users/{id}Update user fields (200 or 404)
DELETE/db/{database}/users/{id}Delete user by ID (200 or 404)
DELETE/db/{database}/usersDelete all users (200)
DELETE/db/{database}/resetReset database (200)

Error Responses ⚠️

All errors return JSON { "error": "<message>" }.

StatusMessages
400invalid JSON body, invalid form data, invalid multipart form data, file not found in form data
404not found
413file size exceeds limit
415only text/plain files are allowed, file does not look like plain text
500internal error

Databases 🗄️

All servers connect to all 4 databases with the same user schema.

DatabaseID Type
PostgreSQLUUID v7
MongoDBObjectId (native)
RedisUUID v7
CassandraUUID v7

User schema:id, name, email, favoriteNumber (optional)

Grafana 📊

Metrics are exported to a dedicated metrics PostgreSQL (separate from the benchmarked postgres) and visualized in Grafana during benchmarks. Run history is durable: the metrics volume survives stack restarts, so runs can be compared across weeks.

ServiceURLUsernamePassword
Grafanahttp://localhost:20090admin123456
metrics-postgrespostgres://localhost:20091benchmarkbenchmark

Exported Metrics

Aggregate tables carry exact numbers computed from the full in-memory result set before any sampling; request_events is sampled raw drilldown only. Canonical queries live in infra/grafana/queries/.

TableContentsKey columns
runsone row per run: sample rate + write accountingrun_id, started_at, finished_at
endpoint_statsexact per-endpoint aggregates (rps, avg/p50/p95/p99/p99.9, open-mode fields)run_id, server, endpoint, method, source
sequence_statsexact per-sequence aggregates (full-sequence durations)run_id, server, sequence_id, database
resource_samplescontainer memory/CPU min/avg/max per server runrun_id, server, source, database (DB only)
request_eventssampled raw request/sequence events with real timestampsrun_id, server, endpoint, source, database

Development 🛠️

Per-Target Commands

All verification commands accept an optional target (default: all).

just typecheck go-chi # Type check only go-chi
just fmt ts-bun-honojs # Format only ts-bun-honojs
just lint py-fastapi # Lint only py-fastapi
just verify ts-express # Full verification for ts-express

Valid targets: ts-express, ts-nestjs, ts-fastify, ts-deno-oak, ts-bun-honojs, ts-bun-elysia, go-chi, go-gin, go-fiber, py-fastapi, zig, benchmark, root (the servers/*/bench.jsonname fields, plus benchmark and root)

Full Command Reference

just benchmark # Run benchmark (interactive mode)
just install # Install all dependencies
just typecheck # Type/compile check all projects
just fmt # Format all code
just lint # Lint all code
just verify # Non-mutating gate: typecheck -> format-check -> lint
just images # Build all Docker images
just clean # Remove build artifacts and node_modules
just remove-images # Remove Docker images
just grafana-up # Start Grafana/metrics-postgres stack
just grafana-down # Stop Grafana/metrics-postgres stack (volumes are kept)
just db-up # Start database stack
just db-down # Stop database stack

About

⚡️ A side-by-side HTTP performance comparison focused on identical behavior, real database flows, and metrics that enable fair cross-runtime evaluation.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages