Reusing Memory with sync.Pool – A Go Performance Trick
Reducing allocations in high‑throughput Go services
When I was building a logging service that handled thousands of requests per second, each log entry was allocated a fresh bytes.Buffer. The GC logs quickly filled up, and CPU time was spent scavenging short‑lived objects rather than doing useful work. The pattern is familiar: any code that builds strings, JSON, or binary payloads often creates temporary buffers that could be safely reused.
The problem with one‑off buffers
In a typical handler you might see something like:
func LogRequest(r *http.Request) {
var b bytes.Buffer
json.NewEncoder(&b).Encode(r)
logger.Write(b.Bytes())
}
Each call allocates a new bytes.Buffer, a []byte slice, and the JSON encoder’s internal structures. Even though the buffer is tiny, the cumulative effect under load is noticeable. The Go runtime must scan and eventually free these objects, causing pauses and higher memory pressure.
sync.Pool – a lightweight reusable cache
The standard library already provides a primitive for exactly this scenario: sync.Pool. A pool holds a set of reusable objects that belong to the current goroutine or a small set of goroutines. When you Get an item, you receive either a live object or a newly allocated zero value. After you finish, you Put it back so another caller can reuse it.
Using a pool is simple, but the why is what makes it powerful. By recycling memory we:
- Cut allocation overhead – the allocator can satisfy many requests from the same slab.
- Reduce GC pressure – fewer objects means less work for the garbage collector.
- Improve cache locality – the same memory regions stay hot in CPU caches.
These benefits compound in services that process many similar, short‑lived objects.
A production‑ready example
Below is a small helper I moved into a logging package after the GC metrics started screaming. The pool is defined at the package level; it stores a *bytes.Buffer. The buffer’s underlying slice grows as needed, but most log lines fit into a modest capacity, so the pool rarely needs to allocate a new slice.
var bufferPool = &sync.Pool{
New: func() any {
// Allocate a buffer with a reasonable initial capacity.
return &bytes.Buffer{}
},
}
// LogRequest encodes the HTTP request into JSON and writes it to the logger.
func LogRequest(r *http.Request) {
// Get a buffer from the pool.
buf := bufferPool.Get().(*bytes.Buffer)
// Reset ensures we reuse the existing slice without holding old data.
buf.Reset()
// Encode the request. If encoding fails we still return the buffer.
if err := json.NewEncoder(buf).Encode(r); err != nil {
// In a real service you might want to log the error separately.
// For now we just discard the buffer.
bufferPool.Put(buf)
return
}
// Use the buffer contents.
logger.Write(buf.Bytes())
// Return the buffer to the pool for future use.
bufferPool.Put(buf)
}
The key steps are:
- Get an object from the pool.
- Reset it –
bytes.Buffer.Reset()clears the buffer’s internal slice without freeing memory. - Perform the work.
- Put it back so another goroutine can reuse it.
Because the pool is global, concurrent calls can safely share the same underlying buffers. The runtime ensures that objects are distributed across a small set of per‑goroutine caches, reducing contention.
Important gotchas
Never assume a pooled value is zeroed. The pool may hand you a buffer that still contains data from a previous use.
The documentation warns about this, and it is the most common mistake. In the example above we call buf.Reset() immediately after retrieval, which discards any stale contents. If we omitted that line, log lines would be concatenated, causing corruption.
Another pitfall is storing a pooled reference longer than intended. If you assign the buffer to a closure and close over it, the buffer will never be returned to the pool unless you explicitly call Put. This can lead to memory leaks. Always keep the lifecycle of pooled objects within the function that uses them.
Finally, be mindful of the object’s size. A pool that holds large buffers can cause memory bloat if the objects are rarely reused. Choose a reasonable initial capacity and consider using separate pools for different sizes if your service has varied workloads.
When to reach for a pool
sync.Pool shines when you allocate many instances of a type that is cheap to reset and typically fits into a small portion of memory. Typical candidates include:
bytes.Bufferfor temporary string/JSON building.- Custom structs that hold a slice or a few primitive fields.
- goroutine‑local caches like token buckets or rate‑limiter counters.
If your object holds external resources (files, network connections) or is large, a pool is not appropriate. The goal is to reduce allocations, not to manage complex lifetimes.
Wrapping up
After integrating the buffer pool, the GC rate dropped by more than 40 % and latency stabilized. The change was minimal – just a few lines – yet the impact was measurable in production. sync.Pool is a deceptively simple primitive that, when used correctly, gives you a powerful lever for performance tuning.
Try profiling your own code. If you see spikes in allocation or GC pause times, look for reusable objects that could live in a pool. The habit of reusing memory pays off quickly, especially in Go where allocation is cheap but not free. Happy optimizing!