The function that remembered
·10 min read
You're debugging someone else's code. The task was simple: log the numbers zero through four, one per second. The loop looks fine. You hit run and watch the console.
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 1000);
}It prints . Not 0 1 2 3 4. Five fives. A second late, five times over.
Something in that loop remembered the wrong thing. To see what, you have to see what the five scheduled callbacks are actually pointing at when they eventually fire.
#Five callbacks, one cell
Flip the toggle below between var and let. In both modes, the loop has already finished by the time the timers fire — the five callbacks are sitting in the queue, each one pointing back at whatever it captured when it was scheduled. Hit play and watch them fire.
With var, there's only ever one i. The loop walked it from 0 to 5 and stopped. Each callback has a line out to the same cell. When the timers fire, they all look at the cell and they all see 5. Five fives.
With let, the picture is different: five lines, fanning out to five different cells, each frozen at the value i had that round. Same five callbacks. Different thing on the other end of the line.
#What the function is actually carrying
Call those lines tethers. They're the reason the post is titled the way it is. Every function in JavaScript, the moment it's created, gets a pointer back into the scope it was born in — a hidden field on the function object that the spec calls [[Environment]]. Wired at creation time; never retargeted after.
The reason this matters is that the function can be taken elsewhere — returned, passed to setTimeout, stored on an object — and the tether comes with it. Parameter frames that would normally vanish when the call returns don't vanish if a function born inside them is still holding the tether. Consider a factory:
function makeAdder(n) {
return (x) => x + n;
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);Each call to makeAdder creates its own frame, with its own n. The returned arrow function carries a tether to that frame. add5 and add10 aren't copies of n; they each own a live pointer to a different frame. Step through a single call and watch the mechanics:
One subtlety worth stating plainly, because it's the misconception every other closure bug traces back to: the tether is live. It doesn't freeze a copy of the value it reaches; it resolves the name when the function actually runs. If something mutates the cell on the other end, the function sees the new value. The function remembered the slot, not the contents.
#Why one letter fixed the loop
With that, scroll back up to LoopTrap and look at it again. The reason var prints 5 5 5 5 5 isn't that the loop copied i wrong. It's that var declares one binding for the whole function. The five arrow functions picked up five tethers. All five tethers pointed at the same cell. The loop walked the cell to 5. Nothing in the callbacks' world said “freeze what iis right now.” When they fired, they read what was there.
The one-letter fix isn't a snapshot, either. When you write for (let i = 0; …), the language does something specific: every iteration of the loop gets its own fresh i. A new binding, an actual new cell on the heap, each round. The five scheduled arrow functions now pick up five tethers to five different cells. Same mechanism. Different topology.
#The same bug, a decade later, in React
Fast-forward to a React component. You've written this before, and you remember being surprised the first time:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}It goes from 0 to 1 and stops. The interval keeps firing, but the number on the screen never moves past one.
This is the loop bug. Same mechanism, different decade. Every time Counter runs, React executes the function body from the top — a new call, a new frame, a new count binding. The first render produces a frame where count is 0; the effect runs in that frame, the callback handed to setIntervalis born in that frame, and its tether is wired back to that frame's count. Empty-array dependencies tell React not to re-run the effect, so the interval never gets re-created. The callback ticking inside setInterval is the same callback, forever, still tethered to render zero.
Flip the toggle to c => c + 1. The callback no longer reads anything from the enclosing render — it just asks React for the current count at call time. No tether to a stale frame. No frame to go stale.
Dan Abramov made the stronger version of this argument: the capture-per-render behaviour is the feature, not the trap. Class components used to read this.propslive; an async handler that started on render five could silently read render eight's props halfway through. Hooks traded that for closures tethered to the render that birthed them. The bugs you hit now are louder, more local, and a lot easier to reason about.
#What the garbage collector has to honor
One more consequence, and then we're done. If a closure is holding a tether, the garbage collector can't clean up what's on the other end. That's how closures leak memory. The classic pattern — first spotted in Meteor a decade back — looks like this:
let theThing = null;
function replaceThing() {
const prev = theThing;
theThing = {
big: new Array(1_000_000).join("x"),
link: () => { if (prev) { /* never called */ } },
};
}
setInterval(replaceThing, 1000);Every tick, theThing gets replaced with a new megabyte-sized object that carries a closure — link — tethered back to prev. And prev is last tick's theThing, which has its own link, which holds on to the tick before that, which holds on to the tick before that. A linked list of megabyte strings grows by one every second. The closure is never called. It doesn't have to be — the tether keeps its promises either way.
Fix: don't create the closure you don't need. Null references when you finish with them. Return the cleanup from useEffect. Pair addEventListener with an AbortController.
Next time your setTimeoutprints the wrong number, or your React counter sticks at one, or your heap snapshot lights up a chain that shouldn't still exist — you'll know where to look. Closures aren't about memory. They're about where a function is still rooted — and whether that ground still holds what you need.