A process-lifetime registry holds one progress callback per job, yet memory grows by megabytes per job — what is retained?
answer
- the function is small, the capture is not
- entries times unique graph
- held for the holder's lifetime
- reachable only through this closure
- narrow captures survive nobody removing them
basics
~20 sEach 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.
solid answer
~40 sThe function value is tiny; what it captured is not. Per registered callback the retained cost is the set of objects reachable through its environment and reachable from nowhere else — typically the instance it was built inside, plus that instance's buffers and cached data. Because the registry lives for the whole process, that set is held for the whole process, so the total grows with the number of jobs and never falls. Two numbers explain the curve: how much unique graph one capture pins, and how many captures accumulate. Shrinking either one works, and shrinking what each closure captures is usually cheaper, because an entry holding two small values costs almost nothing even if nobody ever removes it.
go deeper
Recall that storing a function stores everything it captured, so a growing list of callbacks is a growing list of captured environments.
Explain the multiplication — unique graph per entry times entries held — and why the entries' lifetime is the holder's rather than the job's.
Reason it out before reaching for a tool: name what each capture uniquely pins, whether the count is bounded, and which number you can actually shrink.
Set the expectation for long-lived holders in the system: bounded entry counts, a capture budget at creation, and who owns it when neither holds.
## Two numbers, not one Growth of this shape is always a product of two quantities, and naming both is what turns a vague "we leak memory" into a decision: 1. **Entries held** — how many closures the long-lived holder accumulates, and whether that count is bounded by anything. 2. **Unique graph per entry** — how much memory each closure's captured environment keeps alive that nothing else keeps alive. If entries are bounded and each capture is narrow, the shape is fine. If either number is unbounded, memory grows, and the megabytes-per-job number in the question tells you immediately which one is large: a few bytes per job would point at the entry count alone, while megabytes per job says each capture is pinning a whole working set. ## What "retained" means here The useful measure is not the size of the captured objects but the size of what is kept alive **only** by this closure. Two corrections follow from that: - shared structures that other live code still refers to are not this entry's cost — dropping the entry would not release them; - objects the callback never reads are absolutely its cost if nothing else reaches them, because reachability, not usage, decides. That is why the honest estimate is "the instance it was created inside, minus whatever else still points at that instance", and why a callback created inside an object that has otherwise been discarded is the expensive case: after the screen or the job context is dropped everywhere else, the closure becomes the *only* thing keeping it alive, and the entire graph behind it converts into pure overhead. | what one entry holds | unique graph pinned | growth with N jobs | |---|---|---| | two scalar values | a few bytes | negligible | | a handle to a shared, still-used service | none of it | negligible | | a handle to that job's decoded payload | the payload | linear in N | | the instance the job ran in | that instance's whole graph | linear in N, large constant | ## Why the number never falls Three assumptions quietly fail at once in this shape, and each one on its own would have been enough: 1. **"The work finished, so its data is gone."** Finishing changes nothing about reachability; only dropping references does. 2. **"The callback already ran, so it is spent."** Invocation executes the body and leaves the environment exactly where it was. A closure kept "in case it is needed again" keeps everything it captured. 3. **"It is one small function per job."** The function value is small. The environment behind it is sized by the job, not by the function. The curve is therefore monotonic: up on every job, flat in between, never down. A memory graph that rises in steps and never recovers, with the step size proportional to the size of the work rather than to the number of requests, is the signature. ## Which of the two numbers to shrink Both work, but they are not equally robust. - **Fewer entries** relies on something happening at the right moment for every entry, every time, on every path including failures. It is correct when it holds and it holds until someone adds a path that skips it. - **Narrower captures** changes the constant to something you can afford to hold forever. An entry that captured an identifier and one small collaborator costs bytes, so the worst case of nobody ever removing it is bytes. The second is why the fix in this material is expressed at the point of *creation*: build the function from values copied out beforehand, so that even an entry which outlives everything costs nearly nothing. When the callback genuinely needs a large structure to do its work, the retention is real rather than accidental, and the bounded-count argument is the only one left. ## Estimating before you reach for anything A useful habit is to price a capture at the keyboard: name the environment, name the biggest thing reachable from it, multiply by the plausible entry count, and compare that with the memory the service is given. It takes a minute, it is usually right within an order of magnitude, and it is the difference between designing the hold and discovering it.
- How do you tell whether the growth comes from the entry count or from what each entry captures?Compare the growth per job with the size of one job's working set. If each job adds roughly that much, the captures are pinning working sets; if each adds a near-constant small amount, the captures are already narrow and the unbounded entry count is the problem.
- Does invoking a stored callback release what it captured?No. Invocation runs the body and returns; the function value and its environment stay exactly as reachable as before. Only dropping the last reference to the closure releases the environment, so callbacks kept for possible reuse retain everything.
- Two entries were created inside the same context object — do they cost twice?No. They reach one instance, so its graph is pinned once, and it is released only when both entries are gone. Counting shared structures once per entry overstates the growth, sometimes badly.
saying these in an interview costs you the question
- Estimates the cost as the size of the stored function value.
- Assumes memory returns once each job completes.
- Thinks a leak requires growing data, not growing callbacks.
- Counts a shared, still-referenced structure against every entry.
- Believes a callback that is never invoked retains nothing.