A junior on my team filed a bug last spring that took me longer to triage than it should have. The form on their page wired up six "delete row" buttons in a for loop, and every button deleted row five. Not row zero, not the row matching the button, always row five. They had read about closures, watched a video, even drawn a scope-chain diagram. Their loop variable was declared with var. That single keyword, three letters, was the whole bug.
The argument I want to make in this piece is that closures are not a clever trick or an obscure language corner. Closures are how every function in JavaScript already works. The thing that makes the loop bug surprising is not closures themselves; it is the fact that closures capture variable bindings, not values, and that var and let produce different bindings even when the surface syntax looks identical. Once that one sentence clicks, the whole topic stops being intimidating and starts being useful.
What a closure actually is
A closure is the pairing of a function with the lexical environment it was defined in. When you write a function inside another function, the inner function gets a hidden reference to the outer function's variables. That reference survives even after the outer function has returned. The inner function sees the same variable bindings later that it would have seen at the moment it was defined.
The "lexical" part matters. JavaScript decides which variables a function can see by looking at where the function was written in the source code, not where it was called from. A function defined inside outer always sees outer's variables, no matter where you eventually invoke it. This is true for every function in the language. There is no opt-in.
makeCounter returns and its stack frame is gone, but the count variable lives on because increment still has a reference to it. That is the closure: not the function, not the variable, the pair.
What gets captured: bindings, not values
This is the sentence that fixes the loop bug from the opening anecdote, and it is the one most explanations skip. A closure does not snapshot the values of the variables it sees. It captures a reference to the variable bindings themselves. If those bindings change later, the closure sees the new value.
The closure captured message as a binding. By the time the returned function ran, message had been reassigned to "goodbye". The closure read the current value, which is the only value the binding has at any given moment.
This is the entire reason the classic loop bug exists.
var declares a single binding scoped to the whole function. All three pushed functions captured the same i. By the time any of them ran, the loop had finished and i was 3. There is no way the closures could log 0, 1, 2: they all point at the same box, and the box now holds 3.
Switch var to let and the bug evaporates.
let creates a fresh binding for each iteration of the loop. Each pushed function captures its own i. The reason this works is in the spec, not in our heads: per-iteration bindings for let and const in for loops are mandated by the language, specifically because the old var behavior caused enough bugs to justify the change.
The scope chain
A function defined three levels deep can see its own locals, the variables of the function it was defined in, the variables of the function above that, and so on out to the global scope. That nested ladder is the scope chain. Lookup is one-directional: inner sees outer, outer cannot see inner.
When the inner function reads a name, the engine starts in the inner scope, walks outward, and returns the first match. If nothing matches by the time it hits the global object, you get a ReferenceError. Writes that lack a let/const/var declaration follow the same outward walk and end up clobbering the first binding they find, which is one of the reasons strict mode forbids implicit globals.
This is the part of "what is a closure" that I think gets over-mystified. The chain is just the lexical nesting from the source code, frozen at function-definition time. That is it.
Patterns that are closures in disguise
Once I started naming closures, I noticed I had been using them for years without the label.
The module pattern, before ES modules existed, was a closure. Wrap state and helpers in an immediately-invoked function, return the public surface, and the private bits are inaccessible from outside.
count is unreachable from outside the IIFE. The two methods retain access through closure. A modern equivalent is private class fields (#count), which the engine implements with the same trick under different syntax.
Currying is closures all the way down. Each returned function captures the arguments accumulated so far.
In React, the dependency-array trap that useCallback and useEffect warn about is also closure capture. If a callback references a piece of state and that state is missing from the dependency array, the callback closes over a stale binding from a previous render. The lint rule that demands exhaustive deps is essentially a closure-capture audit.
When the user types and query updates, fetchResults keeps calling the old value. The fix is to include query in the dependency array so React rebuilds the callback with a fresh closure each time the binding changes.
Where closures cost you
I have seen two real-world bills come due from closures, and neither is "closures are slow". The engine optimizes them aggressively; the per-call overhead is in the noise compared to anything network-bound or DOM-bound.
The first bill is memory retention. A closure keeps every captured variable alive as long as the closure itself is reachable. If a giant array is in the enclosing scope and the closure never reads it, V8 can usually eliminate it from the captured environment, but only when the analysis is straightforward. When the captured set is opaque, large objects can stay alive for the lifetime of an event handler. I have debugged a memory leak where a single closure registered as a resize listener held a reference to a fifty-megabyte data buffer for the entire session, because the listener was defined inside the same function that built the buffer.
The fix is to scope intentionally. If you are returning a closure from a function that builds large local data, free the references you do not need before returning, or restructure the code so the closure is defined in a tighter scope.
The second bill is mutation of captured state. Closures over a shared mutable variable are an excellent way to write a function that returns different answers on different calls. That is sometimes the point (a counter, a memoizer, a singleton). It is sometimes a bug nobody notices in tests because the tests reset state by accident. The defense is the standard one: prefer immutable closures (capture once, never reassign), and if you do need shared mutable state, name the function so the mutation is obvious.
Closures are how every function works, full stop
Whenever a junior asks me a closures question, the answer almost always reduces to one of these two rules.
First, closures capture variables, not values. If you want a snapshot, copy the variable into a fresh local binding inside the inner function (or use let in a loop, which gives you a fresh binding for free). If you want a live view, do nothing; the default is already a live view.
Second, a function's scope chain is fixed when it is defined, not when it is called. The "where am I being invoked from" question is irrelevant for variable lookup; only "where was I written" matters. The thing that changes per call is this and the arguments, neither of which has anything to do with closures.
If those two rules feel obvious now, you have already internalised the model. The rest of the closure literature is application: currying, the module pattern, React useCallback, hot-reload edge cases, debugger surprises, leak hunts. All of it falls out of the same mechanic: a function plus the scope it was defined in, kept alive together for as long as the function is reachable.
