Go's sync.Pool Reset Trap: The Response Buffer That Leaks Yesterday's Data

2026-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?

The Bug

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:

  1. Alice's handler gets a fresh buffer from New, writes her JSON, calls String(), defers Put.
  2. Bob's handler runs, calls Get, and receives Alice's buffer — still containing her JSON.
  3. 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.

The Fix

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.

Key Takeaway: 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.

All newsletters