Why do closures built in a loop behave correctly when called inside it and wrongly afterwards?
answer
- read when it runs, not when made
- creation and invocation are two moments
- the loop is already over
- storing or registering exposes it
- one-row fixtures hide it entirely
basics
~20 sA closure resolves its captured binding when it runs, not when it is made. Called during its own pass, the shared variable still holds that pass's value; called after the loop, all of them read the one value left behind.
solid answer
~40 sCreation and invocation are two different moments, and only invocation reads the variable. During the pass that built it, the shared binding still holds that pass's value, so the closure is right. Once the loop has finished, the binding holds a single value and every closure that runs later reads it. The practical consequence is that this bug is **latent**: the code is broken from the day it is written and stays silent until invocation moves past the end of the loop — which happens the day someone collects the closures into a list, registers them as handlers, or hands them to something that runs them later. It is also why a test that calls each closure inside the loop passes while production fails.
code
pseudocode · 11 linesstored = []
index = 0
while index < 3:
handler = function() { report(index) }
handler() // reports 0, then 1, then 2 - the pass is current
stored.add(handler)
index = index + 1
// the loop ended when index became 3
for each h in stored:
h() // reports 3, 3, 3go deeper
Hold on to the two moments: a closure is built at one point and reads its captured variable at another, and only the second one decides what it sees.
Explain why correctness flips exactly at the loop's final assignment, and why closures called mid-loop disagree while closures called afterwards all agree.
Show how you would write a test that actually catches this — several rows, build first, invoke after, assert the closures disagree — and why the obvious test does not.
Treat it as a refactor hazard: moving invocation later is a common, apparently unrelated change, so the guard belongs in the test shape rather than in reviewer memory.
## Creation time and call time are different moments Building a closure does two things: it fixes **which** variables the body refers to, and it does not read them. Reading happens when the closure is invoked. Those two moments can be microseconds apart or hours apart, and everything about this bug follows from which side of the loop's end the second moment falls on. Inside the pass that created it, the shared variable still holds that pass's value, so the closure answers correctly. After the loop, the variable holds exactly one value, so every closure answers with that. Nothing about the closure changed in between — only the contents of the variable it points at. ## Three ways invocation drifts past the loop 1. **Collect then run.** The loop appends the closures to a list, and a second loop after it invokes them. This is the version that shows up in a code sample, and it is where most people first meet the bug. 2. **Register then wait.** The closures are attached to rows, menu entries or subscriptions, and something outside the code invokes them when a person or an event triggers them. The loop finished long before. 3. **Hand off for later.** The closures are queued, scheduled or passed to a component that owns when work runs. The elapsed time is irrelevant; what matters is that the loop is over. A refactor that moves code from the first shape to any of the others does not look like it touches capture at all, which is why the bug so often appears in a change that "only" made things asynchronous or moved work out of the render path. ## What each moment sees | when the closure is invoked | what the shared binding holds then | result | |---|---|---| | during the pass that created it | that pass's value | correct, and reassuring | | during a later pass, before the loop ends | that later pass's value | wrong, and different per call — the clearest proof the binding is shared | | after the loop has finished | the value the loop left behind | wrong, and identical for every closure | The middle row is the useful diagnostic. Call a closure built on the first pass, let one more pass run, call the same closure again: if the answer changed, nothing was copied and the binding is shared. ## Why tests miss it - A test that **builds and calls in the same pass** exercises the one arrangement in which the broken code is right. - A fixture with **one row** makes the broken and the correct versions produce identical output, because there is only ever one value in the binding. - Asserting only that the right **number** of closures exists says nothing about what any of them reads. - Asserting on the **last** closure passes in both versions, since the last one is the one that happens to be right. ## Making it visible - Use at least **two** rows in the fixture — ideally three, so an off-by-one story cannot explain the result. - Build all the closures first, let the loop finish, and only then invoke them; that is the arrangement production uses. - Assert that closures built for different rows **disagree with each other**. That assertion fails loudly on the shared binding and cannot be satisfied by accident. - If the loop counts, assert on a specific early row rather than the last one. ## Why the delay's length is irrelevant There is a tempting but wrong story in which the closure "goes stale" over time, and it leads people to hunt for scheduling or ordering problems. The variable does not decay. A closure invoked one statement after the loop is exactly as wrong as one invoked an hour later, and one invoked mid-pass is exactly right. The boundary is the loop's final assignment, not a duration — which is also why adding a delay to a test changes nothing and why "it only happens under load" is never the correct diagnosis for this particular failure.
- Does the length of the delay before invocation matter?No. What matters is whether the loop has finished, not how much time has passed. A closure invoked on the statement after the loop is as wrong as one invoked an hour later, and one invoked during its own pass is right. Adding a delay to a test proves nothing.
- What does it tell you if two closures built in the same loop disagree?That they were invoked at different points while the loop was still running, and therefore that they are reading a variable something is still writing. Once the loop ends the disagreement stops, because the binding stops changing — which is the shared-binding signature in motion.
saying these in an interview costs you the question
- Says a closure is evaluated at the point where it is written
- Thinks the bug depends on how long the delay before invocation is
- Treats a test that calls each closure immediately as proof of correctness
- Blames scheduling or timing rather than the shared binding
- Believes invoking the closures in creation order restores the right values