sync.Pool Reset Trap: The Response Buffer That Leaks Yesterday's Data2026-09-04
This helper renders a small JSON response. To avoid allocating a fresh bytes.Buffer per request, it pools them with sync.Pool. Handlers hammer it under load, latency looks great, and unit tests pass. Then a security engineer files a ticket: users are occasionally seeing other users' data prepended to their responses.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
// renderJSON returns a JSON payload for the given user.
func renderJSON(u *User) string {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
fmt.Fprintf(buf, `{"id":%d,"name":%q,"email":%q}`,
u.ID, u.Name, u.Email)
return buf.String()
}
func handler(w http.ResponseWriter, r *http.Request) {
u := lookupUser(r)
io.WriteString(w, renderJSON(u))
}
Alice hits /me, gets {"id":1,"name":"Alice","email":"[email protected]"}. Bob hits it right after and sees:
{"id":1,"name":"Alice","email":"[email protected]"}{"id":2,"name":"Bob","email":"[email protected]"}
PII from another user, leaked into Bob's response. What went wrong?
sync.Pool.Get() makes no promise about the state of the object it returns. It hands back whatever was put in — with all its bytes, length, and capacity intact. The New function only fires when the pool is empty; on a hot path, Get almost always returns a recycled object, which means it comes back with the previous caller's data still in it.
The idiomatic bytes.Buffer gives you Write*, which appends. So the flow is:
New, writes her JSON, calls String(), defers Put.Get, and receives Alice's buffer — still containing her JSON.Fprintf appends Bob's JSON to Alice's. String() returns both, concatenated.It's not just a cosmetic bug. The buffer's underlying byte array is user-controlled state that survives across goroutines and across requests. This is exactly the class of bug that leaked memory contents in Cloudbleed — reused buffers spilling one tenant's data into another's response.
Tests miss it because pools appear empty in isolation: the first call always hits New, returning a pristine buffer. You need concurrent load — enough traffic that Put gets called before the next Get — before the recycled state shows up.
Reset the buffer immediately after Get, before any writes:
func renderJSON(u *User) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // ← wipe whatever the previous caller left
defer bufPool.Put(buf)
fmt.Fprintf(buf, `{"id":%d,"name":%q,"email":%q}`,
u.ID, u.Name, u.Email)
return buf.String()
}
Reset is O(1) — it just sets the length back to zero, keeping the capacity — so you get the allocation win without the data-leak risk. Wrap it in a helper if you use pools often:
func getBuf() *bytes.Buffer {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
return b
}
Two related landmines to avoid: don't return buf.Bytes() from a pooled buffer — the returned slice aliases storage that another goroutine will overwrite. And put an upper bound on buffer size before returning to the pool, or a single 50MB request pins 50MB forever.
sync.Pool.Get returns objects in whatever state the previous user left them — always reset pooled state before use, or you're serving stale bytes to the next request.
