How do I cut GC pressure in Go with sync.Pool and escape analysis under load?
Question
We have a Go data ingest service that takes 50,000 small JSON packets per second, processes them, and writes to a database. Under heavy load, when GC kicks in the CPU maxes out and the "Stop-the-World" pauses spike our API response times. How do I apply `sync.Pool` and escape analysis in this scenario to reduce object allocations in memory?
Answer
Short answer: GC pauses are a symptom; the real enemy is allocation rate. Cut allocations first and GC relaxes on its own.
Short answer
Your spikes aren’t because GC is “bad” — 50k packets per second means hundreds of thousands of short-lived objects per second, so GC runs constantly and aggressively to collect that garbage, and the Stop-the-World pauses bleed into your response times. The fix isn’t to disable GC, it’s to give it less work. I covered the concurrency side of this path in concurrency in Go: goroutines and channels in practice.
Why
-
Pause frequency follows allocation frequency. The more garbage you produce, the more often GC runs; changing its settings doesn’t change your production rate.
-
The cheapest allocation is the one you never make. If a value can stay on the stack, GC never sees the object at all — cheaper than pooling it.
-
Pooling can backfire. Pooling objects that hold pointers to other heap data keeps that large graph alive too, and memory swells more than you expect.
What to do
-
Don’t fly blind: measure before/after with
pprof. Thealloc_spaceprofile tells you the line allocating the most;GODEBUG=gctrace=1shows GC frequency and pause duration. Measure under the same load before and after. -
Cut allocations at the root. Pre-size slices/maps with
make([]T, 0, n)when you know the count; reusebytes.Bufferand the JSON decoder; avoid needless[]byte↔stringcopies. A single copy multiplied 50k times becomes a mountain. -
Run escape analysis, keep it on the stack. The
go build -gcflags=-moutput tells you which values escape to the heap. Returning a pointer out of a function or boxing a value into an interface causes most escapes. -
Reuse on the hot path with
sync.Pool. Take short-lived buffers, structs and decoders from a pool and return them. Reset the object before youPutit so stale data doesn’t leak. Keep only flat, self-contained buffers/structs in the pool. -
Leave
GOGC/GOMEMLIMITfor last. They make GC less frequent but won’t fix a path allocating 50k times a second; final tuning, not the first move.
Bottom line: the order is clear — profile → kill the top allocator → pool the survivors → then tune GOGC/GOMEMLIMIT. The real win comes from producing no garbage on the hottest path at all.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.