VibeWarden — Performance benchmarks and latency budget¶
This document explains how much latency VibeWarden adds to requests, what the latency budget is for each middleware layer, and how to reproduce the measurements yourself.
Latency budget¶
| Layer | Target P50 overhead | Target P99 overhead |
|---|---|---|
| Direct passthrough (no middleware) | baseline | baseline |
| Security headers only | < 1 ms | < 2 ms |
| Rate limiting only | < 1 ms | < 2 ms |
| WAF only | < 2 ms | < 5 ms |
| All middleware combined | < 3 ms | < 10 ms |
The sidecar target is < 1 ms P50 and < 5 ms P99 overhead for a simple proxy (no WAF). With all middleware enabled the target is < 5 ms P50 and < 10 ms P99. The benchmark numbers below are single-machine in-process measurements and represent the middleware cost in isolation, not end-to-end network round-trip time.
Running the benchmarks¶
# Run all benchmarks with memory allocation stats (5-second run per bench)
go test -bench=. -benchmem -benchtime=5s ./test/benchmarks/
# Run a single benchmark
go test -bench=BenchmarkProxy_WithWAF -benchmem ./test/benchmarks/
# Increase iterations for more stable numbers
go test -bench=. -benchmem -count=3 -benchtime=5s ./test/benchmarks/
Benchmarks live in test/benchmarks/proxy_bench_test.go. They use
net/http/httptest so no network stack is involved. The numbers measure pure
middleware CPU and allocation cost against a benign request with no matching
WAF rules.
Benchmarks come in two shapes. Both carry two query parameters
(?page=2&sort=created_at) and a 117-byte browser User-Agent, because those
are what the WAF actually inspects on a bodyless request:
- No-body (
GET /api/resource?page=2&sort=created_at) — a browser-style GET. Baseline:BenchmarkProxy_DirectPassthrough. - With body (the same request as a
POSTwith a 37-byte JSON payload) — the only shape that exercises the WAF body scan. Baseline:BenchmarkProxy_DirectPassthrough_WithBody.
Always compare a _WithBody benchmark against the _WithBody baseline. A POST
with a body costs more to construct than a bodyless GET, and that construction
cost must not be attributed to the middleware.
Benchmark results¶
Machine: darwin/arm64, Apple M1 Max, Go 1.27. Median of -count=3,
-benchtime=3s.
goos: darwin
goarch: arm64
pkg: github.com/vibewarden/vibewarden/test/benchmarks
cpu: Apple M1 Max
BenchmarkProxy_DirectPassthrough-10 2584827 1394 ns/op 5827 B/op 16 allocs/op
BenchmarkProxy_DirectPassthrough_WithBody-10 2489506 1448 ns/op 5891 B/op 19 allocs/op
BenchmarkProxy_WithSecurityHeaders-10 1994570 1798 ns/op 6627 B/op 22 allocs/op
BenchmarkProxy_WithRateLimiting-10 2298481 1560 ns/op 5835 B/op 17 allocs/op
BenchmarkProxy_WithWAF-10 128472 27929 ns/op 14523 B/op 21 allocs/op
BenchmarkProxy_WithWAF_WithBody-10 101457 35348 ns/op 14730 B/op 29 allocs/op
BenchmarkProxy_AllMiddleware-10 125074 28715 ns/op 15346 B/op 28 allocs/op
Interpretation¶
No-body requests, measured against DirectPassthrough:
| Benchmark | ns/op | Overhead vs baseline | B/op | allocs/op |
|---|---|---|---|---|
| DirectPassthrough | 1 394 | — (baseline) | 5 827 | 16 |
| WithSecurityHeaders | 1 798 | +404 ns (+0.4 µs) | 6 627 | 22 |
| WithRateLimiting | 1 560 | +166 ns (+0.2 µs) | 5 835 | 17 |
| WithWAF | 27 929 | +26 535 ns (+26.5 µs) | 14 523 | 21 |
| AllMiddleware | 28 715 | +27 321 ns (+27.3 µs) | 15 346 | 28 |
Body-bearing requests, measured against DirectPassthrough_WithBody:
| Benchmark | ns/op | Overhead vs baseline | B/op | allocs/op |
|---|---|---|---|---|
| DirectPassthrough_WithBody | 1 448 | — (baseline) | 5 891 | 19 |
| WithWAF_WithBody | 35 348 | +33 900 ns (+33.9 µs) | 14 730 | 29 |
All values are well below their respective latency budget targets. The WAF is the only layer whose cost varies by more than a rounding error with the request, and it is the one to size against your own traffic — see below.
Key observations¶
-
SecurityHeaders adds ~0.4 µs per request. The cost comes from constructing and setting six HTTP response header strings. There is headroom to add more headers without approaching the 1 ms P50 budget.
-
RateLimiting with a no-op in-memory limiter adds ~0.2 µs. A real in-memory token-bucket limiter (
golang.org/x/time/rate) will be slightly more expensive due to mutex contention and time-package calls; a Redis-backed limiter will incur a full network round-trip (typically 0.2–1 ms on localhost, 1–5 ms over LAN). -
WAF is by far the most expensive middleware, roughly two orders of magnitude above the other two layers. Its cost is driven by how many bytes it inspects, not by which part of the request they came from.
ScanRequestevaluates the entire default ruleset once per query-parameter value, once for each of the three inspected headers that is present (Cookie,Referer,User-Agent), and once over the first 8 KB of the body.
The two benchmarks make the byte cost readable. They differ only by the
37-byte body, and that body costs +7.4 µs, so on this hardware a full ruleset
sweep runs at roughly 0.2 µs per inspected byte. The bodyless GET inspects
128 bytes (two short query values plus a 117-byte browser User-Agent) for
+26.5 µs, which is consistent with the same rate.
Size the WAF against the bytes your requests carry, not against the presence
or absence of a body. The body is not automatically the dominant term: at
that rate the 117-byte User-Agent accounts for most of the +26.5 µs bodyless
number, several times what the 37-byte payload costs. A bodyless GET with a
long User-Agent or a handful of query parameters can easily cost more than a
small POST. At 1 000 RPS of the body-bearing shape above the WAF consumes
about 34 ms of CPU per wall-clock second (~3.4% of one core).
Extrapolating the per-byte rate, a request that fills the 8 KB body-scan limit lands in the low milliseconds — the same order as the < 2 ms P50 WAF budget. That extrapolation is not benchmarked here; measure it against your own payload sizes if you accept large request bodies.
- AllMiddleware stacks all three layers for +27.3 µs on the bodyless shape:
the WAF number plus the ~0.6 µs the other two layers cost in isolation. It uses
newBenchRequest, so add roughly the body-scan increment above for a JSONPOST.
Benchmark scope and limitations¶
- No network I/O: benchmarks use
httptest.NewRecorder()andhttptest.NewRequest(). Real request latency includes TCP/TLS overhead. - No auth middleware: authentication (Ory Kratos session validation) is excluded because it requires an HTTP round-trip to Kratos. Expect 1–10 ms additional overhead depending on session-cache hit rate.
- No-op rate limiter: the in-process limiter used here has no mutex contention. A Redis-backed limiter adds a network round-trip per request.
- No-op metrics collector: the Prometheus registry write path is omitted so the WAF and rate-limit numbers isolate the middleware logic only.
- Benign requests only: benchmarks send clean requests. WAF rule matching on malicious inputs (many regex matches before a block) is more expensive than the benign-request path shown here.
- One request shape only: the benchmarks use two short query parameters, one
inspected header, and (for
_WithBody) a 37-byte JSON payload. WAF cost scales with the total inspected bytes, so a request with more query parameters, aCookie, or a payload approaching the 8 KB scan limit costs proportionally more regex work. - Upstream does not drain the body: the benchmark upstream handler never reads the request body, so the cost of reading back the body the WAF restored is not included. It is a few hundred nanoseconds for small payloads.
Regression tracking¶
To detect performance regressions, run the benchmarks with -count=5 and
compare results using the benchstat tool:
# Capture a baseline on the main branch
go test -bench=. -benchmem -count=5 ./test/benchmarks/ > old.txt
# After your change
go test -bench=. -benchmem -count=5 ./test/benchmarks/ > new.txt
# Compare
benchstat old.txt new.txt
Any increase in ns/op greater than 10% for a given benchmark should be investigated before merging to main.