In Go, how can a stored callback closure keep a large captured []byte alive for the process's lifetime?
answer
- a stored closure is a root
- it holds what its body names
- one field needed, whole struct retained
- slicing a big array does not copy it
- narrow the capture, then delete the entry
basics
~20 sA closure keeps every variable it references reachable for as long as the closure value itself is reachable. Registering a callback that mentions a large slice in a long-lived table therefore retains that slice, and everything its backing array holds, indefinitely.
solid answer
~50 sA function value carries references to the variables its body mentions, so a closure stored in a long-lived registry — a scheduler's callback table, a handler map, a retry queue — keeps those variables reachable for as long as the entry stays there. If the closure mentions a whole request struct, or a `[]byte` sliced out of a much larger read, the entire struct and the whole backing array stay live even though the callback needs one field or a few bytes. The fix is to narrow what is referenced: copy the small piece into its own variable before the literal and capture that, or pass it as a parameter so the capture appears in a signature. Capture is per variable, so referencing fewer variables retains less. Then make sure entries are actually removed when the work completes: a registry that never deletes turns every closure into a permanent root, which shows up as steadily growing live heap with no single obvious owner.
code
go · 6 lines// retains the whole *Request
s.onRetry[req.ID] = func() { s.retry(req.ID) }
// retains only the id
id := req.ID
s.onRetry[id] = func() { s.retry(id) }go deeper
Know the underlying fact: a closure keeps the variables it mentions alive as long as the closure itself is alive, which is what makes returning a closure over a local safe.
Explain that capture is per referenced variable, and show the narrowing fix — copy the one field or the few bytes into a local before the literal and capture that instead.
Diagnose it end to end: read a live-heap profile in steady state, separate too-fat closures from a registry that never deletes, and state the review rule you apply to any stored function literal.
Set the standard for callback-holding APIs your teams own — whether registration returns an unregister handle, what bounds the table, and how you keep hidden retention out of long-lived services by convention rather than by heroics.
## What actually holds the memory A closure is a function value plus references to the variables its body mentions. Reachability follows those references: as long as the function value is reachable, so is every variable it captured, and so is everything those variables point to. A closure sitting in a long-lived table is therefore a garbage-collection root for its captured state. This is entirely a language-level fact — no runtime interior needed. The captured variable outlives the frame that declared it, by design, because that is what makes returning a closure safe. The cost is that it also outlives the *work* that created it if nothing drops the closure. ## The two shapes that retain far more than intended **Capturing a container to use one field.** A scheduler registers a retry callback: ```go func (s *Scheduler) register(req *Request) { s.onRetry[req.ID] = func() { s.retry(req.ID) } } ``` The literal mentions `req`, so the whole `*Request` — headers, decoded body, whatever else it holds — stays reachable while the entry lives, even though only `req.ID` is used. Copying the id out first fixes it: ```go id := req.ID s.onRetry[id] = func() { s.retry(id) } ``` Now the closure references an `int` (or a string), and the request can be collected as soon as the request handling ends. **Capturing a slice of a much bigger array.** Slicing does not copy. A `[]byte` taken from a 100 MB read is a window onto that same backing array, so a closure that captures the window retains all 100 MB. If the closure needs the window for a long time, copy it: `hdr := append([]byte(nil), buf[:32]...)` gives a small array of its own, and the big one becomes collectible. The same reasoning applies to a substring of a huge string, a small map derived from a big one by reference, and any struct field pointing back into bulk data. ## Capture is per variable, and that is the lever A closure does not retain the whole enclosing frame or every local in scope — only the variables its body actually references. That is precisely why the fixes above work: they change *which variables the body mentions*, and everything else becomes unreachable at the usual time. Two habits follow. 1. **Introduce the narrow variable immediately before the literal.** One line, obvious intent, and reviewable. 2. **Prefer parameters over capture for long-lived callbacks.** A closure built by a helper whose parameter list names exactly what it needs makes retention visible in a signature instead of hidden in a body. A third habit, easy to forget: because capture is by variable, you can also release from the outside. If the enclosing code sets the captured variable to nil after registration, the closure and the enclosing code are looking at the same variable, so the big object really does become collectible — at the price of the closure now seeing nil. Use it only where the closure genuinely no longer needs the value. ## Then check the registry itself Narrowing the capture reduces how much each entry holds; it does not help if the table grows without bound. Ask three questions of any structure holding closures: * **Is there a delete?** A callback map keyed by request id with no deletion on completion, cancellation and error is a leak whatever the closures capture. * **Is the slot cleared, not just skipped?** Marking an entry done while leaving the function value in the slice keeps it reachable. Set it to nil or drop the element. * **Is registration bounded?** A daemon that registers per attempt and retries forever accumulates entries as fast as it retries. ## Diagnosing it Start from a heap profile taken during steady state and look at `inuse_space`, not `alloc_space`: the question is what is still live, not what was ever allocated. Two heap profiles taken minutes apart, diffed, show the growing class. The retained type usually points straight at the container being held — request structs, byte slices, decoded documents — while the *reason* it is held is the closure, which is why the allocation site alone can be misleading. Pair it with a count of entries in the registry: if entries are flat and live bytes climb, each closure is holding too much; if entries climb, the registry never deletes. ## Reviewing for it The review question is short: for every function literal that is stored rather than called immediately, what does its body name, and who drops it? A callback that captures one small identifier and is deleted on completion has none of these problems. A callback that captures the whole context of the operation that created it and lives in a package-level map is a slow leak waiting for enough traffic. ## What not to say Do not claim the closure copies what it needs, or that the captured variables are freed when the enclosing function returns — both are the opposite of Go's rule. And do not answer this with a story about where the compiler decides to put the variable; the retention is caused by reachability from a long-lived value, and that is true regardless of any placement decision.
- How would you confirm that a registry of closures is what is holding the memory?Take heap profiles minutes apart in steady state and diff `inuse_space` to find the growing live type. Then instrument the registry with a count of entries. Flat entry count with climbing live bytes means each closure retains too much; climbing entries means nothing deletes. The allocation site alone will not tell you which.
- Does a Go closure retain the whole enclosing frame or only what it references?Only the variables its body actually references. Locals it never mentions are not captured and are collectible on the normal schedule. That is exactly why introducing a narrow variable just before the literal, and referencing only that, changes what stays alive.
- Why does capturing a small []byte cut from a large read still retain the large one?Because slicing shares the backing array rather than copying it. The small slice is a window with its own length, but it points into the original array, so retaining the window retains the whole allocation. Copy the bytes into a fresh slice when the reference will outlive the read.
Leaving a sticky note that says see the box in aisle 12 means the warehouse can never clear aisle 12, no matter how little of the box you actually wanted.
saying these in an interview costs you the question
- Says the closure copies what it needs, so the original is collectible
- Claims captured variables are freed when the enclosing function returns
- Assumes a slice expression copies the bytes it selects
- Reads alloc_space instead of inuse_space when hunting retention
- Fixes the capture but never deletes the registry entry