Kyrux
Back

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 to benchmark/).

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/GOMAXPROCS as the server — which is why these numbers are lower than the ab table above, which runs in a separate process), APP_ENV=production, a real Postgres on the same machine. Reproduce with go run main.go benchmark (or go 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 DELETE on 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-Length on JSON responses, which eliminates the overhead of chunked transfer encoding on HTTP/1.1.
  • Degradation under 500 connections: an expected ~14% drop — with 4 workers, more connections compete for the same goroutine pool. Increasing SERVER_WORKERS mitigates 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 development are ~9× slower (they render the full debug HTML template). In production, 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 .env configuration. 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:

  1. APP_ENV=production.
  2. A strong SECRET_KEY (≥ 32 characters). PASSWORD_PEPPER set. If you use kyrux:"encrypt" fields, also FIELD_ENCRYPTION_KEY. Generate each with openssl rand -base64 32. Without FIELD_ENCRYPTION_KEY, field encryption fails on write (fail-closed — it never encrypts with a weak key).
  3. ALLOWED_HOSTS set to the real domains (blocks a forged Host header).
  4. Behind a reverse proxy with TLS: set TRUSTED_PROXY_HEADER=X-Forwarded-For and make sure the proxy overwrites that header (otherwise the client can forge the IP and bypass RateLimit). Without a proxy, leave it blank.
  5. Apply RateLimit to sensitive routes. Login already has a built-in brute-force throttle (10 failures/minute per account+IP; adjust with auth.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 TestThroughputReal client used to close the response body without draining it first — this prevents net/http from 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 with io.Copy(io.Discard, resp.Body) before Close().