Taming Tail Latency in Go Services
Averages lie. A service with a 20ms average latency can still make a meaningful fraction of users wait a full second. The number that matters is p99, and in Go there are a handful of usual suspects behind a bad tail.
Measure before you touch anything
You cannot fix what you cannot see. Instrument every handler with histogram metrics, not just counters and gauges. A single time.Since(start) fed into a Prometheus histogram gives you real percentiles.
func withLatency(name string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
latency.WithLabelValues(name).Observe(time.Since(start).Seconds())
})
}
The usual suspects
1. Garbage collection
Large short-lived allocations force more frequent GC cycles, and every cycle steals CPU from request handling. Reuse buffers with sync.Pool, avoid allocating in hot loops, and prefer value types where they make sense.
2. Connection pool starvation
A database pool sized for the average load falls apart under a burst. When every connection is checked out, new requests queue — and that queue time lands squarely in your tail. Size the pool for the burst, and add a timeout so a starved request fails fast instead of hanging.
3. Goroutine scheduling
Spawning an unbounded number of goroutines under load causes scheduler contention. Bound concurrency with a worker pool or a semaphore so the runtime is never overwhelmed.
The fix is usually backpressure
Most tail-latency problems are really "too much work in flight" problems. Bounding concurrency, timing out aggressively, and shedding load when saturated does more for p99 than any micro-optimization.
Takeaway
Watch p99, not the average. Instrument first, guess never. And when in doubt, the answer is almost always backpressure.