Why does a sync.Pool of bytes.Buffers keep memory high after one huge payload, and what is the fix?
answer
- Reset keeps the memory on purpose
- buffers only ever grow
- one giant payload promotes the buffer
- busy pools never go idle enough to clear
- check Cap before putting it back
basics
~20 sA bytes.Buffer that grew to hold a huge payload keeps that backing array when it is put back, so the pool now recycles giant buffers for ordinary work. The fix is to skip Put for any buffer whose Cap exceeds a threshold.
solid answer
~50 s`bytes.Buffer.Reset()` sets the length to zero but deliberately keeps the allocated backing array — that retention is the whole point of pooling. So one 40 MB log line permanently upgrades a pooled buffer to a 40 MB buffer, and under steady traffic it is put back and taken out again forever, never becoming garbage. With several buffers in flight your resident memory reflects the largest payload you have ever seen, multiplied by how many were live at once, not the typical one. The standard fix is a guarded release: `if buf.Cap() > maxPooled { return }` before `Put`, letting the outsized buffer be collected while ordinary ones keep circulating. Pair it with `New` returning a buffer pre-grown to a sensible size so the common case never reallocates. You confirm it from an inuse_space heap profile showing live bytes held under the pool rather than under request handling.
code
go · 14 linesconst maxPooledCap = 64 << 10 // 64 KiB
func acquire() *bytes.Buffer {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
return buf
}
func release(buf *bytes.Buffer) {
if buf.Cap() > maxPooledCap {
return // one huge payload should not resize the pool forever
}
bufPool.Put(buf)
}go deeper
Know one fact here: Reset empties a bytes.Buffer's contents but keeps the memory it already allocated. That is why pooling helps, and why a buffer that grew stays big.
Explain why a busy pool's entries are never collected and how the footprint scales with the largest payload times peak concurrency. Be able to write the Cap check before Put.
Show the whole loop: notice it in an inuse_space heap profile, tie the number to the largest payload the service has handled, choose a ceiling from the real size distribution, and say when the guard is not worth adding.
Decide where the sizing rule lives. A ceiling buried in one package is a number nobody revisits; make it a documented limit with an owner, or refuse variable-sized pooling on that path entirely.
## Why the memory sticks The reason `sync.Pool` helps at all is that a recycled object keeps its allocated memory. `bytes.Buffer.Reset()` sets the length back to zero and keeps the backing array, so the next writer does not have to grow it. That is the feature. It is also the failure, because buffers only ever grow. If a request arrives with a payload two orders of magnitude larger than normal, the buffer serving it grows to fit, gets reset, and goes back into the pool at that size. From then on it is handed to ordinary requests, which use a few hundred bytes of it, reset it, and put it back — still huge. The buffer never shrinks and never becomes garbage, because something is always about to use it again. Multiply by concurrency. Buffers in flight at the same moment are separate objects, and a burst of large payloads promotes several of them at once. Steady-state footprint ends up proportional to *the largest payload ever seen* times *the peak number of concurrent users*, which is a number nobody sized the service for. It is stable, so it does not look like a leak; it just sits at a level far above what the workload should need, and it resets only when the process restarts. The usual complication: the garbage collector does clear pooled entries, so a truly idle service will eventually release them. A busy service does not go idle. Objects that are taken out, used, and put back are live at every collection, so the clearing you were counting on never touches them. ## The fix Guard the release with a capacity ceiling: ```go if buf.Cap() > maxPooledCap { return // do not pool a giant; let it be collected } buf.Reset() bufPool.Put(buf) ``` The outsized buffer is dropped, the next `Get` calls `New` and makes a normal one, and the huge allocation is a one-off cost paid by the request that actually needed it. This is a well-worn pattern — the standard library's own encoders apply the same ceiling to the buffers they recycle. Choosing the ceiling is a sizing judgment, not a constant you copy. Look at the distribution of what you actually write: pick something comfortably above the typical case so the common path never drops a buffer, and comfortably below "this is memory I would not want held per concurrent request". A ceiling in the tens of kilobytes is common for line-oriented encoders. A ceiling set too low is worse than none at all — you throw away every buffer and pay the allocation you were trying to avoid, plus the pool's bookkeeping. The complementary half is the floor. Have `New` return a buffer that is already big enough for typical work, for instance by calling `Grow` with a starting size, so the common path never has to reallocate at all. Together the floor and the ceiling keep every pooled buffer inside a known band, and the pool's memory footprint becomes something you can state in a sentence. ## Confirming it rather than guessing Take an inuse_space heap profile from the running process and look at what is holding live bytes. The signature is a large live allocation attributed to the buffer's growth path, retained through the pool rather than through anything doing work — memory that stays live across many collections while the request rate says nothing should be holding it. Compare the number against the largest payload the service has handled, and the shape of the problem is usually obvious. ## When to leave it alone If the sizes you write are bounded and uniform — fixed-width records, a line encoder whose output is always under a kilobyte — there is nothing to guard, and adding a ceiling is noise. This hazard belongs specifically to pools whose objects are variable-sized and unbounded from the outside: anything holding user-supplied content, an arbitrary JSON document, or a response body of unknown size. Those are the pools that need a ceiling written down next to them, along with the reason for the number.
- Doesn't the garbage collector clear the pool and release those big buffers anyway?Only if they stop being used. Pooled entries are cleared by the collector, but a busy service takes each buffer out and puts it back between collections, so it is live every time and never dropped. An idle service does recover; a service under continuous load holds the high-water mark indefinitely.
- How do you pick the capacity ceiling?From the distribution of what you actually write, not from a round number. Put it comfortably above the typical write so the common path never discards a buffer, and low enough that holding one per concurrent user is memory you are willing to spend. Set it too low and every release throws the buffer away, paying the allocation the pool existed to avoid.
- Is there anything to do at the other end, when buffers start too small?Yes — have `New` return a buffer already grown to a typical size rather than an empty one, so the first writes never trigger a reallocation and a copy. A floor in `New` plus a ceiling in the release path keeps every pooled buffer inside a band you can describe, which is what makes the pool's footprint predictable.
saying these in an interview costs you the question
- Assumes Reset frees the buffer's backing array
- Expects the collector to reclaim buffers a busy pool keeps recycling
- Adds a capacity ceiling so low that every buffer is discarded
- Calls this a memory leak rather than retained high-water capacity
- Pools variable-sized buffers with no ceiling and no measurement