When allocation gets expensive
You are writing a high-throughput service. Every request allocates a few structs, parses some JSON, and returns a response. Under normal load, the garbage collector handles the churn without breaking a sweat. Then traffic spikes. The heap fills up. The GC pauses get longer. Your p99 latency jumps from 12 milliseconds to 80. The bottleneck is not your algorithm. It is the constant creation and destruction of short-lived objects. You need a way to reuse memory without writing your own cache from scratch.
What sync.Pool actually does
sync.Pool is Go's built-in object cache. Think of it like a rack of reusable containers at a construction site. Instead of buying a new bucket for every job and throwing it away when the paint dries, you grab a bucket from the rack, use it, wipe it clean, and put it back. The next worker grabs that same bucket. The pool holds onto these objects so you can reuse them across requests, function calls, or goroutines. The tradeoff is simplicity over strict guarantees. The pool does not promise you will get the exact same object back. It promises you will get something that costs less than allocating fresh memory.
Pool objects are temporary by design. They exist to smooth out allocation spikes, not to serve as long-term storage. The runtime treats them as disposable cache entries. You hand them back when you are done, and the runtime decides whether to keep them or discard them.
Cache aggressively. Reset ruthlessly.
The minimal pattern
Here is the simplest way to set up a pool for a custom struct.
package main
import (
"fmt"
"sync"
)
// RequestData holds a pre-allocated buffer and a request identifier.
type RequestData struct {
Buffer [1024]byte
ID int
}
// pool caches RequestData instances to avoid repeated heap allocations.
var pool = &sync.Pool{
// New is called only when the pool is completely empty.
New: func() any {
return &RequestData{}
},
}
func main() {
// Get retrieves a cached object or triggers New if empty.
data := pool.Get().(*RequestData)
data.ID = 42
fmt.Println(data.ID)
// Put slides the object back into the per-P cache.
pool.Put(data)
}
The New function acts as a factory. It runs exactly once per empty state, not on every Get. When you call Get, the runtime checks the local cache first. If it finds a cached object, it hands it back immediately. If not, it checks the global pool. If both are empty, it calls New. The type assertion .(*RequestData) is necessary because sync.Pool stores everything as any. You are responsible for casting it back to the concrete type you expect. After you finish using the object, Put returns it to the cache. The object is not cleaned up automatically. You must reset its fields before returning it, or the next caller inherits stale data.
Go convention prefers any over the older interface{} spelling. The compiler treats them identically, but any reads cleaner in modern codebases.
Reset state before returning. Never hand back a dirty object.
How the runtime manages it
Under the hood, sync.Pool is designed for raw speed, not strict safety. It uses a slice of pointers for each logical processor. When a goroutine calls Get, it grabs from its local P's slice without taking a lock. This makes retrieval extremely fast because it avoids mutex contention. The global pool acts as an overflow bucket. When a P's local cache is empty, it steals from the global pool. When a P's cache is full, it spills over to the global pool. This two-tier design keeps the hot path lock-free.
The garbage collector interacts with the pool in a specific way. When a GC cycle runs, it clears the entire pool. Yes, the runtime wipes it clean. This is intentional. Short-lived pools are meant to be ephemeral. If your objects survive a GC cycle, they were probably not worth caching anyway. The runtime rebuilds the pool lazily on the next Get by calling New again. This design means you never have to worry about the pool growing unbounded and eating all your RAM. The GC acts as a hard ceiling.
You do not need to manually drain the pool. You do not need to implement eviction logic. The runtime handles lifecycle management for you. Your only job is to provide a New function and call Put when you are done.
Let the GC sweep the cache. Do not fight the lifecycle.
A realistic HTTP handler example
Object pooling shines in hot paths where you allocate the same shape repeatedly. HTTP handlers are a classic example. Here is how you might pool a byte buffer to accumulate request payloads without triggering heap allocations on every call.
package main
import (
"bytes"
"net/http"
"sync"
)
// bufferPool caches byte buffers to avoid repeated heap allocations.
var bufferPool = &sync.Pool{
// New allocates a fresh buffer when the pool is drained.
New: func() any {
return new(bytes.Buffer)
},
}
// handleUpload demonstrates reusing a buffer across requests.
func handleUpload(w http.ResponseWriter, r *http.Request) {
// Get pulls a buffer from the per-P cache without locking.
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
// Reset clears the length but keeps the underlying capacity.
buf.Reset()
// ReadFrom copies the request body into the reused buffer.
if _, err := buf.ReadFrom(r.Body); err != nil {
http.Error(w, "read failed", http.StatusInternalServerError)
return
}
// Process the buffer contents here.
fmt.Fprintf(w, "received %d bytes", buf.Len())
}
The handler grabs a buffer, resets it, reads the request body, and returns the buffer to the pool. The defer ensures the buffer goes back even if an error occurs. The Reset() call is critical. It zeroes the logical length while preserving the underlying byte slice capacity. This is exactly what makes pooling valuable. You pay the allocation cost once, then reuse the pre-sized memory for every subsequent request. The ReadFrom method efficiently copies data into the existing slice without resizing it, as long as the payload fits within the cached capacity.
Notice the receiver naming convention if you were to attach methods to a custom struct: use one or two letters matching the type, like (b *Buffer) Write(...), not (this *Buffer). Go style favors brevity in method signatures.
Pool the capacity, not the data. Reset before reuse.
Pitfalls and runtime surprises
The biggest trap is forgetting to reset state. sync.Pool hands you a dirty object. If you leave fields populated, the next caller sees ghost data from a previous request. You must zero out pointers, clear slices, or call Reset() before returning the object to the pool. The compiler will not catch this. It is a runtime logic error that manifests as data races or corrupted responses.
Another pitfall is pooling the wrong thing. Small structs that fit on the stack do not benefit from pooling. The compiler already optimizes them away through escape analysis. Pooling only makes sense for heap-allocated objects that are large enough to trigger GC work, or objects that hold pre-allocated capacity like slices or buffers. If you pool a 16-byte struct, you are adding synchronization overhead to save a trivial allocation. The net result is slower code.
You might also run into type assertion panics. If you put a *ResponseData into a pool and later call Get().(*RequestData), the program crashes with interface conversion: interface {} is *ResponseData, not *RequestData. The compiler cannot verify pool contents because sync.Pool uses any. You are responsible for keeping the contract consistent.
There is also a subtle interaction with memory profiling. Objects sitting in a pool are technically live, but they are not reachable by your application logic. Tools like pprof may show inflated memory usage because the pool holds onto capacity. This is normal. The pool is trading peak memory for allocation speed. If you need strict memory bounds, you must cap the pool size yourself or use a different caching strategy.
Finally, do not pool objects that hold references to request-scoped data. If a pooled struct contains a pointer to a request body or a context, you risk holding onto memory long after the request finishes. Always strip external references before calling Put.
The worst pool bug is the one that silently corrupts the next request. Audit your reset logic.
When to reach for a pool
Use a sync.Pool when you allocate the same heap object thousands of times per second and the GC pauses are measurable. Use a plain make() or new() when the object is small, short-lived, or allocated infrequently. Use a dedicated cache with eviction policies when you need to guarantee object survival across GC cycles. Use a worker pool when you need to bound concurrency rather than reuse memory. Trust the garbage collector for the 90 percent case. Pool only when profiling proves allocation is the bottleneck.
Profile first. Pool second. Never optimize blind.