Benchmarking real services: percentiles, coordinated omission, and the optimization loop
Microbenchmarks do not predict service latency. Load-test open-loop at a fixed arrival rate, read p99/p999 instead of averages, beware coordinated omission, profile under load, and gate CI on benchstat deltas across many runs — a single noisy run proves nothing.
Before the launch, the load test said the new pricing endpoint was fine: 5,000 rps sustained, mean latency 9 ms, p99 a comfortable 31 ms. Launch day, support tickets: “checkout hangs for seconds.” The dashboards agreed with the users, not with the load test — real p99 was 2.4 s in bursts. The load tool wasn’t lying, exactly. It was a closed-loop runner with 50 virtual users, each waiting for a response before sending the next request. Every time the service paused — a GC assist storm, a connection-pool stall — the tool slowed down with it, politely sending fewer requests during exactly the windows where latency was worst, then recording mostly the good times in between. Coordinated omission: the measurement and the failure coordinated to hide each other. Re-run with an open-loop generator firing at a fixed 5,000 rps regardless of responses, the stalls had nowhere to hide: p99 2.1 s, right where the users were. Same service, same load — an honest clock.
By the end of this lesson you’ll understand why your load test can lie by a factor of 100, what “coordinated omission” means and how to fix it, and what a defensible CI benchmark gate actually looks like.
Microbenchmarks: precise answers to small questions
testing.B is excellent at what it actually measures: the cost of a code path in a warm cache, with no neighbors, no GC pressure from the rest of the service, and no network. That makes it the right tool for comparing two implementations of the same function — and the wrong tool for predicting service latency. Even within its scope there are two classic self-deceptions. First, dead-code elimination: if the benchmarked result is unused, the compiler may delete the work; keep results alive (assign to a package-level sink, or use the modern b.Loop() pattern which keeps its body’s results live). Second, missing allocation truth: always run with b.ReportAllocs() — a function that “got 10% faster” while doubling allocs/op is usually a regression in service context, where allocations are the GC tax from previous lessons.
var sink int // package-level: defeats dead-code elimination
func BenchmarkParsePrice(b *testing.B) {
data := loadFixture()
b.ReportAllocs()
for b.Loop() { // Go 1.24+; replaces for i := 0; i < b.N; i++
v, _ := ParsePrice(data)
sink = v
}
}
// $ go test -bench=ParsePrice -count=10 > old.txt
// … apply the change …
// $ go test -bench=ParsePrice -count=10 > new.txt
// $ benchstat old.txt new.txt
// │ old.txt │ new.txt │
// ParsePrice 412.0n ± 2% 389.5n ± 1% -5.46% (p=0.000 n=10)The numbers a microbenchmark gives are real — for the microworld. A service adds queueing, contention, GC interference between requests, and cold caches. The bridge between worlds is the rule: microbenchmark to choose between implementations; load-test to make latency claims.
Load-testing honestly: percentiles and the open loop
Mean latency is the most useless number in a latency report: it is dominated by the fast majority and structurally blind to the tail your users actually feel. Report percentiles — p50, p99, p99.9 — and respect how tails compound: a page that fans out to 100 backend calls experiences a backend’s p99 on most page loads (the chance all 100 calls dodge the worst 1% is 0.99^100 ≈ 37%). Tail latency is not an edge case; at fan-out it is the median experience.
Then there is the Hook’s trap, coordinated omission. Closed-loop tools (a fixed pool of virtual users, each waiting for a response before the next request) measure service time but quietly stop arriving during stalls — precisely when arrivals would have queued and suffered. The recorded histogram omits the worst seconds in coordination with their occurrence. Open-loop tools (wrk2, vegeta, anything with a fixed arrival rate) keep firing on schedule; a request that arrives during a stall waits, and its full waiting time lands in the histogram. The distinction is not pedantry — it routinely changes p99 by 10–100×, which is the difference between shipping and paging. Rules of engagement: load the system at a fixed rate derived from real traffic (plus headroom), run long enough to cross several GC cycles and pool recycles (minutes, not seconds), and measure from the client side, including connection setup, the way users do.
A closed-loop load tool with 50 virtual users reports p99 = 31 ms, but production users see multi-second hangs. What is the most likely measurement flaw?
The optimization loop: measure, hypothesize, change one thing, measure
Optimization without a loop degenerates into folklore. The senior workflow is mechanical: (1) measure under realistic load — open-loop, fixed rate, percentiles, with profiles captured during the run (a CPU or mutex profile from an idle service describes a different program); (2) form a falsifiable hypothesis — “p99 is GC assist during marking, driven by allocations in the serializer,” not “it’s probably the GC”; (3) change exactly one thing — the moment two changes ship together, the win is unattributable and the next regression undebuggable; (4) re-measure the same way, and let the numbers veto the narrative. Profiles under load are the hypothesis factory: the CPU profile says where cycles go at 5,000 rps, the mutex profile explains latency that CPU can’t, the heap alloc profile names the GC pressure. Two honesty rules from hard experience: beware optimizations that move cost rather than remove it (a sync.Pool that trades alloc churn for retention; a cache that fixes p50 and poisons p999 via eviction storms), and keep the load generator on separate hardware — a saturated generator measures itself.
Performance regressions in CI: benchstat or it didn’t happen
A single benchmark run is a coin flip wearing a lab coat. Run-to-run noise on shared CI runners is routinely ±5–10% — thermal state, neighbors, frequency scaling — so a pipeline that fails on “3% slower than last run” fails randomly, and a pipeline that compares single runs misses real 10% regressions hiding in noise. The honest setup: run each benchmark with -count=10 (or more), compare distributions with benchstat, which reports the delta with a p-value — and gate only on statistically significant changes beyond a threshold you chose deliberately (e.g., “fail on ≥5% with p < 0.05”). Pin what you can: dedicated or labeled runners, fixed CPU frequency where possible, GOMAXPROCS set explicitly, benchmarks isolated from parallel test jobs. For the numbers that actually page people — service p99 under load — a nightly open-loop load test against a staging deploy with archived profiles catches what per-function benchmarks structurally cannot: cross-cutting regressions in GC pressure, lock contention, and connection management. CI benchmarks guard functions; scheduled load tests guard the service.
A CI job runs each benchmark once and fails the build because a function is 4% slower than the previous run. What is the correct assessment?
- 01Explain coordinated omission: the mechanism, the symptom, and the fix.
- 02Design a CI setup that catches real performance regressions without flapping on noise.
testing.B answers small questions precisely — which implementation is faster in a warm, isolated world — provided you defeat dead-code elimination with a sink or b.Loop and watch allocs/op via b.ReportAllocs, because an alloc regression is a service regression even when nanoseconds improve. Latency claims about services need different machinery: percentiles, since means are blind to the tail and fan-out makes p99 the median page experience (0.99^100 ≈ 37% of pages dodge a 100-call fan-out’s worst 1%); and open-loop load at a fixed arrival rate, because closed-loop tools commit coordinated omission — they slow down with the service and omit exactly the stall windows, hiding 10–100× of p99, which is how the Hook’s pricing endpoint passed at 31 ms and shipped at 2.4 s. Optimization is a loop, not an act: measure under load with profiles captured during the run, write a falsifiable hypothesis naming a mechanism, change one thing, re-measure identically — and distrust wins that relocate cost into retention or tail behavior. In CI, single benchmark runs are coin flips under ±5–10% runner noise: run -count=10, compare with benchstat, gate on significant deltas past a chosen threshold, and pair the per-function gate with a scheduled open-loop load test whose archived profiles guard the service-level numbers that actually page people. Now when you set up or review a load test, your first question is: open-loop or closed? If the answer is closed-loop, the p99 it reports is not the one your users see.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.