This article is published in English.
Snapshot or Live Value? Deciding What Delayed JavaScript Callbacks See
Learn why closures keep access to bindings rather than copies, and how to choose between snapshot and live data in timers, React effects, listeners and async code.
A button acts on data from a previous render. A timer logs a value that did not exist when it was scheduled. Every handler created in a loop seems to belong to the last item. A slow response overwrites the screen with results for a page the user has already left. These bugs are usually filed under "closure problems," but that label rarely helps anyone fix them. This article replaces the label with a precise model: a closure keeps access to variable bindings, not copies of values, and every piece of delayed work needs an explicit decision about whether it should see a snapshot or the live value.
The rule behind every closure bug
Closures are not unpredictable. A JavaScript function keeps a reference to the lexical environment it was created in, and whenever its body mentions an outer variable, that name is looked up in the environment at the moment the code runs. The key word is access. The function does not receive a frozen copy of everything visible when it was defined; it holds on to the same bindings, and if one of those bindings is reassigned later, the function will read the new value.
There are two opposite ways to get this wrong. The first is expecting a snapshot when the code actually created a live link to a changing variable. The second, common in rendering frameworks, is expecting the latest value when the callback was created inside an older scope whose bindings will never change again. Both mistakes come from the same omission: nobody decided which moment the delayed work should belong to.
Once that decision is explicit, the behavior stops looking mysterious. The callback is doing precisely what the program told it to do. The program just said something different from what the developer meant.
Access, not a photograph
The textbook closure example has an inner function that keeps using an outer variable after the outer function returns. It demonstrates that the environment outlives the call, but it quietly encourages a wrong intuition: that the inner function stores the value it saw at creation time. A small variation exposes the difference. Here the logger is created while message is "Starting", and the variable is reassigned before the factory returns.
function createLogger() {
let message = "Starting";
const logMessage = () => {
console.log(message);
};
message = "Finished";
return logMessage;
}
const logger = createLogger();
logger();
Calling logger() prints Finished. The arrow function never held the string "Starting"; it held the message binding, and by the time it ran that binding pointed at a different string.
This is a feature, not a flaw. Private mutable state in counters, factory functions, memoization caches and the module pattern all depend on a closure being able to see updates to its own variables. The trouble begins only when someone believes the callback is tied to the earlier value instead of to the variable.
Primitives make the misunderstanding easy, because strings and numbers feel self-contained, and it seems natural that a function would just keep "the string." But the function body contains a name, not a value, and names are resolved when the code executes. If you want a deeper look at how that lookup walks outward through nested scopes, how JavaScript's scope chain actually resolves variables covers the mechanics.
The practical habit that follows is a single question you ask whenever a callback will run later and references anything from outside: should it use the value as it was when the callback was created, or as it is when the callback runs? The answer tells you whether to capture a snapshot, read a current reference, or pass the value in as an argument. Without the question, the code is still correct JavaScript, but its behavior is accidental.
The loop bug is about binding identity
The best-known closure bug involves a for loop declared with var that schedules several timeouts. Each callback appears to be tied to its own iteration.
for (var index = 0; index < 3; index++) {
setTimeout(() => {
console.log(index);
}, 100);
}
It prints 3 three times. A var declaration is function-scoped, so the whole loop shares exactly one index binding. All three callbacks close over that same binding, and by the time the timers fire, the loop has finished and left it at 3.
Switching the declaration to let changes the outcome, because the language creates a fresh binding for every iteration of a for loop with a let header and copies the current value into it.
for (let index = 0; index < 3; index++) {
setTimeout(() => {
console.log(index);
}, 100);
}
This version prints 0, 1 and 2, since each callback now refers to a different index.
The usual summary is "let fixes closures," but that hides the real lesson. The closures behaved identically in both loops. In the first, three callbacks were deliberately given one shared, changing variable; in the second, each received its own. What changed was binding identity, not closure behavior.
That distinction matters because the same bug survives without var. Declare a mutable variable with let outside the loop, update it on each iteration, and read it inside the callbacks, and every callback will again see only the final value. Swapping a keyword does not help if the code still points several callbacks at one mutable source.
The better habit is to ask whether the callbacks are supposed to share state. If they should all observe one evolving value, a single binding is correct. If each should remember data specific to its iteration, each needs its own binding or an explicit argument. Seeing several callbacks in the source makes it tempting to assume each one owns the variables it names; lexical scope only says where names are looked up, not who owns them.
When time separates creation from execution
Closure surprises get worse when there is a gap between creating a function and running it: a timer, a network round trip, a user action, a job waiting in a queue. Anything the function reads from outside may change during that gap. Consider a save routine that relies on a module-level project id.
let activeProjectId = 42;
async function saveChanges(changes) {
await saveProject(activeProjectId, changes);
}
activeProjectId = 84;
Whether this is correct depends on timing. Arguments are evaluated when a function is called, so if saveChanges runs before the reassignment, activeProjectId is read synchronously and the number 42 is what gets passed to saveProject, even though that call then waits. But any callback that reads activeProjectId after some delay will see whatever the variable holds by then, possibly 84, and save to a completely different project.
This is why "closures capture values" is a dangerous shorthand. In some code a value is copied into an argument before the pause; in other code a shared binding is read after it. Two functions can look almost identical while following different rules about time.
User interfaces are full of this pattern. Picture a confirmation dialog opened for one record. The user navigates to another record, then clicks confirm. If the handler reads the "current record" variable, it deletes the record now on screen rather than the one the dialog was opened for. The closure works perfectly; the product is wrong, because the action should have kept the context it started with.
The fix is not automatically "copy the variable." First decide which moment owns the operation:
- A destructive action usually belongs to the moment it was initiated and should use the id captured then.
- A live status indicator wants the newest value every time it updates.
- A retry scheduled for later can mix both: the original operation id, but whatever auth token is valid when it fires.
Closures make JavaScript developers reason about time explicitly. The important question is not just which variable a callback reads, but which version of it the workflow intends to use.
Objects keep the reference stable while the contents change
When the captured binding points to an object, a new layer of confusion appears. Making the binding const does not freeze anything except the binding itself. If the object is mutable, a delayed callback can still observe every change made through the shared reference.
const settings = {
retries: 2,
};
setTimeout(() => {
console.log(settings.retries);
}, 100);
settings.retries = 5;
The timer prints 5. The settings constant never changed which object it refers to, but the object's retries property was updated before the callback ran.
The browser console can add to the confusion. You log an object before starting some async work, expand it later in developer tools, and see fields that were changed after the log statement ran. Several consoles show a live view of the object when you expand it rather than a record of its state at log time, which makes the log look like it traveled forward in time. Logging JSON.stringify(obj) or a structured clone is a quick way to get a true snapshot while debugging.
Producing a real snapshot in code takes more than a new variable, and how deep to copy depends on the shape of what you are protecting. A shallow copy, via spread or Object.assign, detaches the top-level properties but still shares nested objects and arrays. A deep clone detaches more, but can be expensive, can drop class prototypes and methods, and can duplicate references that were meant to be shared.
Often the cleaner option is not to clone at all, but to pull out only the small immutable values the operation needs.
const retryLimit = settings.retries;
setTimeout(() => {
console.log(retryLimit);
}, 100);
The callback now reads a binding whose value will never change, and just as importantly, the code states which piece of historical context the timer depends on.
Treat delayed work that closes over large mutable objects with suspicion. Request contexts, configuration containers, component state objects and shared caches are common examples. A callback that reaches into one of them later will silently depend on every mutation that happened in between. Passing a narrow payload into the delayed work is a much clearer contract.
React shows the opposite failure
In React, closure bugs usually point the other way. The callback does not read a value that is too new; it keeps reading one that is too old.
Every render of a function component is a fresh function call with its own local bindings for props, state and derived values. A callback created during a render closes over that render's bindings. When state changes, React calls the component again and creates new bindings, but any callback from the earlier render that is still alive keeps pointing at the old ones.
The symptoms are familiar:
- An interval created in one render logs an outdated count forever.
- An event listener registered once keeps using a prop from the first render.
- An effect with an incomplete dependency list keeps calling a function that closes over stale state.
This is called a stale closure, but the closure is not broken. It is loyal to its original render, and nothing in the language moves it forward when React renders again.
This can seem to contradict the earlier examples, where closures happily observed updates. The difference, again, is binding identity. In the logger example there was one binding that was reassigned, and the closure saw the new value. React does not reassign the old bindings; it creates entirely new environments on each render. The old callback stays attached to the old environment, whose values never change.
Seen this way, the fixes follow from what the callback is meant to do:
- A state update that depends on the current value can use the functional form, such as
setCount(c => c + 1), so React supplies the latest state. - A subscription that needs the newest value can be recreated when its dependencies change, or can read from a deliberately maintained ref.
- A callback that is supposed to act on the values from the render that created it may already be correct as written.
The question is the same one as before: should this delayed behavior use historical state or current state? Many React bugs come from answering it by accident, by editing a dependency array, rather than by reasoning about what the feature is supposed to do. Newer React versions also offer useEffectEvent for reading the latest values inside an effect without re-running it; retiring the latest-value ref with useEffectEvent explains that pattern.
Dependency arrays describe reality, not preferences
A common way to fight stale closures is to adjust an effect's dependency array until the behavior looks right. A value goes in because the linter complains, comes out because the effect runs too often, and eventually the array becomes empty because the effect "should only run once."
That treats the array as a frequency knob. It is actually a declaration of which render values the effect's closure reads.
If an effect uses a variable from the surrounding render, that variable is part of its closure whether or not it appears in the array. Leaving it out does not remove the dependency. It only guarantees that the effect keeps using the version from whichever render last set it up.
Including every dependency can expose a different problem: the effect now tears down and recreates a subscription, restarts a timer, or refires a request far more often than intended. That is easy to read as the linter being too strict. More often it is a signal that the effect is doing more than one job, or that an object or function it depends on is being recreated on every render for no reason.
Typical remedies include:
- stabilizing a callback so it only changes when its own inputs change
- splitting one effect into several with narrower responsibilities
- moving a helper function inside the effect so it is no longer an external dependency
- using a functional state update instead of reading state directly
- questioning whether the logic needs to be an effect at all
The goal is not to coax React into running the effect at a preferred rate. It is to give the effect a closure whose lifetime matches the behavior it is responsible for. When an effect needs fresh values without being torn down each time they change, give it a deliberate way to read them, such as a ref or an effect event. If it should restart when a value changes, that value belongs in the array. And if the effect has no real use for a value, remove the read rather than the dependency.
Dependency trouble is design feedback. The haunted behavior starts when the code declares one lifetime for a closure while the feature needs another.
Event listeners outlive the context that created them
Listeners create a gap between registration and execution by design. You attach the handler once, and it runs whenever the event fires, possibly long after the surrounding variables have changed.
In plain JavaScript, a listener that reads a module-level variable sees its latest value. In a component framework, a listener attached during an early render keeps that render's bindings. Either way the relationship is easy to overlook, because the handler only runs when a user does something later.
Cleanup adds another dimension. If each render attaches a new listener without removing the previous one, several closures end up responding to the same event, each holding a different version of the state. A single click can then produce several outcomes drawn from different points in the application's history. The visible result might be a duplicated update, an old value flickering back, or a handler running after its component has unmounted. The root cause in each case is that the listener's lifetime was never aligned with the lifetime of the state it depends on.
Reliable listener code makes ownership explicit:
- Keep a reference to the exact function you registered, because
removeEventListenerneeds the same reference. - Recreate the listener when the behavior it depends on changes, or have it read current values through a deliberate channel such as a ref or a store.
- Remove the listener when its owner, whether a component, a module or a feature, goes away.
None of this is ceremony. Registering a callback creates a link between future events and the environment available right now. If that link is supposed to end, the code has to end it.
Async responses carry old intent into a new screen
Network requests produce some of the most costly stale-context bugs. A request starts while the user is looking at one search query, project or route. Before it completes, the user moves somewhere else. When the response arrives, its callback writes to shared state using the context it captured at the start.
Sometimes that historical context is exactly right. A request made for project 42 should stay associated with project 42 even after the active project becomes 84. The danger is letting that result update a screen that has since switched to project 84. Remembering the original request is the closure doing its job; assuming that a completed response is automatically still wanted is where the logic fails.
This is why closure reasoning and async ownership go together. A callback can hold perfectly correct historical values and still have no right to update the destination it targets. Common guards include:
- a request id or sequence number that is compared before applying a result
- an
AbortControllersignal that cancels work when the user moves on - a check that the current route or selection still matches the request
- storing results under a key for the resource they belong to, rather than in a single "current" slot
Simply rewriting the callback to read the latest active project can make things worse: the response for project 42 would be stored under project 84. Reading current state is not a universal cure. Keep the request's identity intact, and check that the destination still wants the result before writing it.
Keep two contexts separate. The operation context belongs to the data the request was made for. The interface context belongs to what the user is looking at now. The screen should only be updated when the two still match. Without that separation, callbacks appear to time-travel, delivering valid information from an earlier moment into a view that has already moved on.
Choosing between snapshot and live access
Most of these bugs become straightforward once the team names the relationship it wants.
A snapshot means the delayed work uses data as it was when the work was created. This fits transaction ids, the selected record id, submitted form values, audit context, and commands that must keep their original intent. Implement it by passing values as arguments, building immutable payloads, or copying just the narrow fields the operation needs.
Live access means the delayed work uses the newest value at the time it runs. This fits connection status, the latest configuration, some current UI state inside event handlers, and mutable coordination values. Implement it through a shared binding, a ref, a store accessor, or some other source that is explicitly "current."
Neither is safer in general. Bugs appear when the code implements one while the developer assumes the other.
Two habits make the choice visible in code. First, avoid closures that casually reach for everything in scope; a wide closure hides its dependencies, so a reader cannot see which of them are meant to be frozen and which are meant to stay current. Second, prefer small functions and narrow payloads, which reduce the number of variables whose timing semantics anyone has to reason about. Both improve async reliability for the same reason: fewer implicit relationships with time.
A quick checklist for any callback that runs later:
- Which outer variables does it read?
- For each one, should it see the value at creation or at execution?
- Is any of them a mutable object whose contents could change in between?
- What owns this callback, and when should it stop running?
- If it writes results somewhere, does that destination still belong to the same context?
Wrapping up
Closures in JavaScript are deterministic. They follow lexical scope, hold on to bindings, and keep environments alive for as long as a reachable function needs them. What makes them feel haunted is treating a variable as if it had a single meaningful value across time.
Every scenario above is a variation of one question: which moment should this callback belong to? A loop may give all its callbacks one shared binding. A timer may read an object after it has been mutated. A React callback may stay tied to an earlier render. A listener may outlive the state it was written for. An async response may carry correct historical intent into a view that has moved on.
Experienced developers still hit these bugs because modern applications defer work constantly. Timeouts, promise chains, DOM events, subscriptions, re-renders, job queues and HTTP responses each put distance between defining a function and running it, and the longer that gap, the more the world changes around it. The defense is not memorizing another definition of closures. It is making time and ownership explicit: choose snapshot or live access deliberately, keep only the historical values the work needs, give current state one intentional source, tie each callback's lifetime to whatever owns it, and stop obsolete work from writing to places it no longer controls. When those choices are visible in the code, closures stop surprising you, because they are finally connected to the moment you intended.