skip to content

Captured Lifetimes & Leaks

How an innocent capture pins a whole object graph in memory, and why long-lived callbacks are where it bites. Interviewers ask it as the production consequence of a feature candidates treat as free.

on this pageshow

questions

4

When a function that captured local values is stored and called long after its defining call returned, what keeps those values alive?

level: juniorimportance: must knowfreq 58%

answer

  1. a function value carrying state
  2. the frame ends, the data does not
  3. lifetime follows the function value
  4. stored callback, stored environment
  5. released when the closure is unreachable

basics

~20 s

The stored function itself keeps them alive. Captured state is moved out of the short-lived call frame into storage the function value owns, so it lives exactly as long as that function value stays reachable.

solid answer

~40 s

A closure is a function bundled with the environment it captured, and that environment is not part of the caller's frame. When the defining call returns its frame is discarded, but anything the returned function still needs has been placed in longer-lived storage the function value owns. So the lifetime of every captured value becomes the lifetime of the closure: put the closure in a field, a registry or a callback list that lives for the whole process, and the captured values live that long too. They are released when the closure itself becomes unreachable, not when the call that created it returns. Implementations differ in how they relocate the state; every one of them extends its lifetime this way.

code

pseudocode · 9 lines
pseudocode
function makeProgressReporter(jobLabel)
    startedAt = now()
    return function (percent)
        show(jobLabel, percent, now() - startedAt)
    end
end

reporter = makeProgressReporter("import")   // the outer call has returned
registry.add(reporter)                      // jobLabel and startedAt live on

go deeper

for a junior

Recall that a closure bundles code with the values it captured, and that the bundle keeps working after the call that created it has returned.

for a middle

Explain that captured state is relocated into storage the function value owns, so its lifetime tracks the closure's reachability rather than the call stack.

for a senior

Show where this bites in a running system: a callback parked in a long-lived list holds its whole captured environment for as long as that list does.

for a principal

Treat it as a policy question: which parts of the codebase may create functions that outlive their creator, and what the default shape for those call sites is.

## What a closure carries A **closure** is a function value bundled with the *environment* it needs: the **free variables** its body names, which are neither its own parameters nor declared inside it. If the body computes `now() - startedAt` and `startedAt` belongs to the enclosing call, the function simply cannot run without access to `startedAt`. The function value is therefore not just code — it is code **plus** a hold on that state. That bundle is exactly what makes a returned or stored function still work later, and it is also why lifetime is a question at all. A function that captures nothing raises none of this: there is no environment, so storing it forever costs only the function value. ## Why the end of the call is not the end of the data A call frame is short-lived storage: the arguments and locals of one activation, discarded when that activation returns. If captured state lived only there, every closure that outlived its defining call would be reading discarded memory. Implementations avoid that in different ways — copying the value into storage the function value owns, moving the variable into a small separately-allocated cell that both the frame and the closure refer to, or keeping an entire environment record alive — but the observable rule is the same in all of them: - captured state is **not** released because the defining call returned; - it is released when the function value that needs it is no longer reachable; - until then it is as alive as any other object you are still holding. | | a plain local | a captured local | |---|---|---| | lives in | the call frame | storage the closure owns | | released when | the call returns | the closure becomes unreachable | | bounded by | the call's duration | the closure's lifetime | | ongoing cost | none after the call | its own size, while held | ## Lifetime follows reachability, not time The useful way to state it is that **the lifetime of the captured environment equals the reachability of the closure**. Nothing about elapsed time, about the job being finished, or about the callback having already run changes it. Three shapes follow directly: 1. A closure built, called and dropped inside one function: its environment dies almost immediately, and the capture costs nothing worth thinking about. 2. A closure returned to a caller that uses it and lets it go: the environment dies when that caller drops the last reference. 3. A closure handed to a registry, a scheduler or a subscription list that lives for the whole process: the environment lives for the whole process. Only the third shape is dangerous, and it is dangerous in proportion to what was captured, not to how long the code is. ## Where this turns into a leak A retention problem here is not a special mechanism; it is this ordinary rule applied to a long-lived holder. A progress callback captured what it needed when it was created. The work it reported on finished an hour ago. Nobody dropped the callback. Everything it captured is therefore still there, and will be until the process ends. The same reasoning holds in both families of language. Where memory is reclaimed automatically once nothing can reach it, a reachable closure keeps its environment out of reach of reclamation. Where memory is freed deterministically by an owner, the closure *is* an owner and will not release what it owns while it is alive. Neither family gives you the release you were implicitly expecting from "the function returned". ## Four questions to ask as you write one 1. **Who will hold this function, and for how long?** If the answer is "a list nobody empties", everything below matters. 2. **What did the body name that it did not declare?** That set — not the set you intended — is the environment. 3. **Is any of it large, or a door into something large?** A handle to a container is a handle to everything in it. 4. **Could the value be taken out and copied before the function is built?** Narrowing what is captured is the cheapest lever you have, because it works even when the closure is never released. Answering those four at the moment you write the function is far easier than reconstructing them later from a memory graph, and it is what separates an engineer who treats closures as free from one who treats them as storage with a lifetime.

  • If the same closure is added to three different registries, is the captured environment duplicated?
    No. The environment belongs to one function value, and the three entries are three references to that value. There is one environment, and it is released only when the last of the three entries is dropped. Adding entries multiplies references, not captured state.
  • Does a function that captures nothing raise the same concern?
    No. With no free variables there is no environment to keep alive, so storing it indefinitely costs only the function value itself, and some implementations reuse a single instance of it. The retention question begins only once something is captured.

saying these in an interview costs you the question

  • Says captured locals die when the enclosing function returns.
  • Thinks the closure reads the discarded frame of its defining call.
  • Assumes a function value is just a code pointer with no state.
  • Believes only declared parameters are retained, not free variables.
  • Thinks storing a callback costs only the size of the function.
open as a page

A callback created inside a screen object reads one unqualified field of that object — what does the callback retain?

level: middleimportance: must knowfreq 66%

basics

~10 s

The whole enclosing object, not the field. An unqualified member read resolves through the instance that owns it, so the closure captures that instance and transitively keeps everything reachable from it alive.

open as a page

A long-lived callback is rewritten to capture two values copied into locals beforehand — what does that release?

level: middleimportance: should knowfreq 40%

basics

~20 s

Everything that was alive only through the enclosing instance. The closure now holds two locals, so the instance, its collections and its caches become unreachable — unless one of the copied values leads back into that same graph.

open as a page

A process-lifetime registry holds one progress callback per job, yet memory grows by megabytes per job — what is retained?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Each entry's captured environment, not the callback. Per entry the cost is the object graph reachable only through that closure — the instance it was created inside plus its buffers and caches — multiplied by every entry never dropped.

open as a page