Performance
Benchmarks measured with ab (real HTTP, TCP, keep-alive) and Go's testing.B suite.
Hardware: Intel Core i5-1235U · Go 1.26.2 · Linux · SERVER_WORKERS=4 (per .env).
Real HTTP — ab (keep-alive, 100 concurrent connections)
| Scenario | Req/s | Avg. latency | Failures |
|---|---|---|---|
GET /ping/ — static route |
127,474 | 0.784 ms | 0 |
GET /usuarios/42/ — path param |
126,454 | 0.791 ms | 0 |
GET /busca/?q=kyrux&page=3 — query string |
109,302 | 0.915 ms | 0 |
| 500 concurrent connections (peak) | 99,110 | 5.045 ms | 0 |
Zero failures across 200,000 total requests.
Latency — percentiles (100 connections, 50k req)
| Percentile | Time |
|---|---|
| P50 | 0.89 ms |
| P90 | 1.34 ms |
| P95 | 1.72 ms |
| P99 | 2.44 ms |
| P100 (worst case) | 6.36 ms |
A single measurement on specific hardware — treat it as an order of magnitude, not as an official benchmark reproducible on any machine. For current numbers on your own setup:
go run main.go benchmark(runs the framework's 3 test layers — microbenchmark, framework without TCP, and real throughput — and saves the result tobenchmark/).
Real routes — database, admin and HTML rendering
The table above uses static JSON responses, never touching the database — it
measures the router's raw cost. The TestThroughputReal test
(core/router/benchmark/) measures the rest: it registers a real model in
the admin, seeds 200 rows into a real Postgres database, logs in for real
(session + CSRF cookie, not a forged token), and hits every route discovered
via fw.Router.Routes().
| Route | What it measures | Req/s | Failures |
|---|---|---|---|
GET /ping/ |
Baseline: static route, no DB | 39,250 | 0 |
GET / |
Framework's default welcome page, no DB | 37,914 | 0 |
GET /admin/login/ |
Login form (HTML), no session | 24,957 | 0 |
GET /admin/<slug>/new/ |
Empty creation form (HTML, authenticated) | 14,162 | 0 |
GET /admin/ |
Admin dashboard (HTML, authenticated) | 17,878 | 0 |
GET /admin/<slug>/<pk>/ |
One row by primary key via the ORM (HTML, authenticated) | 9,812 | 0 |
GET /admin/<slug>/ |
Real paginated list via the ORM — 200 seeded rows, SELECT + COUNT (HTML, authenticated) |
1,920 | 0 |
Zero failures across all 7 routes. The paginated list is the most expensive
(two real queries per request, plus the cost of html/template iterating
the rows) — the same conclusion as the Django comparison
page: it's not the database that's heavy (the query is fast), it's the
template rendering.
Measured via a Go client (
net/http, in the same process/GOMAXPROCSas the server — which is why these numbers are lower than theabtable above, which runs in a separate process),APP_ENV=production, a real Postgres on the same machine. Reproduce withgo run main.go benchmark(orgo test ./core/router/benchmark/ -run TestThroughputReal -v -count=1) — the test is self-contained: it creates and seeds its own table, creates its own staff user, and cleans up both at the end (defer DROP TABLE+defer DELETEon the user). It's not part of this docs site (which runs with no database,DB_ENABLED=false) — it belongs to the framework itself.
Native Go benchmarks — testing.B (GOMAXPROCS=4, no TCP overhead)
| Scenario | ns/op | Estimated req/s | Allocs/op |
|---|---|---|---|
| Static route | 1,104 | ~906,000 | 15 |
| Path param | 1,313 | ~762,000 | 18 |
| Query string | 1,715 | ~583,000 | 22 |
| 1 middleware | 878 | ~1,139,000 | 12 |
| 3 middlewares | 908 | ~1,101,000 | 12 |
| Static, parallel (4 cores) | 923 | ~4,334,000 | 15 |
| Path param, parallel (4 cores) | 697 | ~5,743,000 | 18 |
Notes
- +13% in static and path-param throughput compared to the previous version — the main gain comes from
Content-Lengthon JSON responses, which eliminates the overhead ofchunked transfer encodingon HTTP/1.1. - Degradation under 500 connections: an expected ~14% drop — with 4 workers, more connections compete for the same goroutine pool. Increasing
SERVER_WORKERSmitigates this. - Middlewares have low cost: 3 chained middlewares cost less than 30 ns more than 1 — the chain is compiled once before the request arrives.
- Query string cached per request:
ctx.Query(),ctx.QueryInt(), etc. parse the URL only once — subsequent calls in the same view reuse the result. - Expected bottleneck: 404 routes in
developmentare ~9× slower (they render the full debug HTML template). Inproduction, the static page is faster. - Reflection-free ORM hot path: struct metadata is computed once per type and cached in a
sync.Map— the hot path does no reflection at all.
Measured on localhost with
SERVER_WORKERS=4, honoring the.envconfiguration. Results vary depending on hardware and the view's workload.
Production checklist (security)
Bootstrap refuses to start under APP_ENV=production if any secret is
missing or set to its example value. Before deploying:
APP_ENV=production.- A strong
SECRET_KEY(≥ 32 characters).PASSWORD_PEPPERset. If you usekyrux:"encrypt"fields, alsoFIELD_ENCRYPTION_KEY. Generate each withopenssl rand -base64 32. WithoutFIELD_ENCRYPTION_KEY, field encryption fails on write (fail-closed — it never encrypts with a weak key). ALLOWED_HOSTSset to the real domains (blocks a forged Host header).- Behind a reverse proxy with TLS: set
TRUSTED_PROXY_HEADER=X-Forwarded-Forand make sure the proxy overwrites that header (otherwise the client can forge the IP and bypass RateLimit). Without a proxy, leave it blank. - Apply
RateLimitto sensitive routes. Login already has a built-in brute-force throttle (10 failures/minute per account+IP; adjust withauth.SetLoginThrottle).
In production, the security headers (HSTS, CSP, X-Frame-Options DENY,
nosniff), the Secure session cookie, and the stack-trace-free error page
are enabled automatically. The debug page only exists in development and
only for localhost.
Deployment and HTTP/2
Kyrux serves HTTP/1.1 (no TLS of its own). The recommended deployment is
behind a reverse proxy (nginx, Caddy, Traefik) that terminates TLS and speaks
HTTP/2/3 with the browser — the proxy talks to Kyrux over HTTP/1.1 with
keep-alive, which doesn't limit throughput. Configure TRUSTED_PROXY_HEADER
(above) so RateLimit and the login throttle see the client's real IP.
Regarding SERVER_WORKERS: omit it in production — the Go runtime uses
all CPUs by default, which is fastest. Set it only to cap CPU usage (e.g. on
a shared machine); values below the number of CPUs reduce throughput.
Health check
Bootstrap already registers GET /health automatically — without touching
the database, cache, or queue, it just confirms the process is up and
responding (200 ok, Cache-Control: no-store). Ready for Docker's
HEALTHCHECK or any orchestrator's liveness probe, with no extra
configuration:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
Running the performance tests
All benchmarks live in core/router/benchmark/ and are organized into three layers.
Layer 1 — Microbenchmark (route registration cost)
go test ./core/router/benchmark/ -bench='^Benchmark(Router|Handle)' -benchmem -benchtime=3s -run='^$'
Measures only the cost of registering routes via the public API — no request, no TCP.
Layer 2 — Framework benchmark (router + middleware, no TCP)
go test ./core/router/benchmark/ -bench='^Benchmark(Route|Middleware|Parallel)' -benchmem -benchtime=3s -run='^$'
Uses httptest.NewRecorder — eliminates network overhead. A reference for the relative cost between route types and middleware chains.
Layer 2 — Automatic regression
go test ./core/router/benchmark/ -run TestRegressionCheck -v -count=1
Fails if any scenario regresses more than 5% relative to the baseline. Update the constants in bench_regression_test.go after intentional optimizations or a hardware change.
Layer 3 — Throughput via Go client
# Pure router (no bootstrap, no templates)
go test ./core/router/benchmark/ -run TestThroughputRouter -v -count=1
# Full stack (bootstrap + apps + templates) — requires at least one app with a GET / route
go test ./core/router/benchmark/ -run TestThroughputStack -v -count=1
# Real DB + authenticated admin — self-contained, no installed app needed
# (registers its own model, seeds and cleans up its own data)
go test ./core/router/benchmark/ -run TestThroughputReal -v -count=1
All three spin up a real server on a random port, fire requests for 5 s with multiple goroutines, and report req/s. TestThroughputStack and TestThroughputReal force APP_ENV=production automatically.
Never use
./...for the throughput tests — packages running in parallel artificially split CPU time and skew the results.
Layer 3 — Throughput via ab (peak capacity)
# 1. Start the benchmark server
go run /tmp/kyrux_bench_server.go &
# 2. Run the tests
ab -n 50000 -c 100 -k http://127.0.0.1:8000/ping/
ab -n 50000 -c 100 -k http://127.0.0.1:8000/usuarios/42/
ab -n 50000 -c 100 -k "http://127.0.0.1:8000/busca/?q=kyrux&page=3"
ab -n 50000 -c 500 -k http://127.0.0.1:8000/ping/ # peak test
# 3. Stop the server — ALWAYS at the end
kill $(lsof -ti :8000) 2>/dev/null
The benchmark server (/tmp/kyrux_bench_server.go) uses runtime.GOMAXPROCS(4) and registers three routes (static, path param, query string). The full template is in .claude/performance_testing.md.
| Layer | Typical req/s | What it measures |
|---|---|---|
| Layer 1 — registration | sub-µs per route | Primitive cost, no request |
Layer 2 — framework (-bench) |
~433k–592k | Router + handler, no network syscall |
| Layer 2 — regression | ~620k–1.2M | Same code, warmer context |
Layer 3 — Go client (TestThroughputReal) |
~1,900–39,000 (real DB + authenticated admin — see the table above) | Real throughput with net/http, database and template overhead |
Layer 3 — ab |
~120k–220k | Peak capacity with an optimized C client |
Don't compare numbers across layers — each one measures something different (process/client, presence of a database, response format).
Fix: the
TestThroughputRealclient used to close the response body without draining it first — this preventsnet/httpfrom reusing the connection via keep-alive, forcing a brand-new TCP connection on every request (competing for CPU with the server itself, since client and server run in the same process/GOMAXPROCS). Fixed withio.Copy(io.Discard, resp.Body)beforeClose().