← blog
JavaScriptAugust 25, 2026 · 14 min read

JavaScript Closures: The Complete Guide (with Cheat Sheet)

Learn how JavaScript closures really work — lexical scope, private state, memoization, and the classic loop bug — with worked examples and a cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
JavaScript Closures: The Complete Guide (with Cheat Sheet)
  1. 1JavaScript Event Loop Explained: The Complete Guide13 min
  2. 2TypeScript Generics: The Complete Guide (with Cheat Sheet)15 min
  3. 3CORS Explained: The Complete Guide (with Cheat Sheet)14 min
  4. 4JavaScript Proxy and Reflect: The Complete Guide15 min
  5. 5JavaScript Closures: The Complete Guide (with Cheat Sheet)you are here

Open five browser tabs, click a button in each, and every single one logs 5. Not 1, 2, 3, 4, 5 — five identical 5s, as if the loop that created the buttons never ran at all. This is one of the oldest, most reliable JavaScript bugs there is, and it isn't a bug in JavaScript — it's a closure doing exactly what it's supposed to do, with a variable you didn't realize you were sharing.

By the end of this guide you'll be able to:

  • Explain what a closure actually is — a function bundled with a live reference to its scope, not a copy of it
  • Use closures to build private state without a class
  • Write a memoization cache and a "run once" guard using nothing but closures
  • Diagnose the classic loop-variable-capture bug and fix it three different ways
  • Recognize the memory-retention gotchas closures introduce, including the "stale closure" bug in React hooks

Who this is for: you're comfortable declaring functions and using var/let, and you've either been bitten by a callback that saw the "wrong" value or you've used useState in React and wondered why an old value showed up inside a useEffect.

Here's the naive way to wire up five buttons, each meant to log its own position:

JavaScript
1
2
3
4
5
6
7
8
9
// the wrong way — every button logs the same number for (var i = 1; i <= 5; i++) { const button = document.createElement("button"); button.textContent = `Button ${i}`; button.addEventListener("click", function () { console.log(`You clicked button ${i}`); // always logs 5 }); document.body.appendChild(button); }

Click any of the five buttons and the console prints You clicked button 6. Not 1, not the button's own number — always the same value, one past the last iteration. Nothing crashed, no error was thrown, and yet the code clearly doesn't do what it looks like it does. That gap between "what the code appears to say" and "what it actually does" is the entire reason closures deserve a real mental model instead of a shrug and a workaround.

The fix — spoiler, it's one keyword — comes in Stage 4. But the fix only makes sense once you know what a closure is actually holding onto, so that's where we start.

The mental model: a closure is a function bundled together with a live reference to the variables that were in scope where the function was defined — not the values those variables held at that moment, and not a copy. Every function you write in JavaScript carries this bundle with it, always; "closure" isn't a special kind of function, it's a name for a function's normal relationship to its surrounding scope.

Picture the function as a hiker who packs a backpack the moment they're created, and the backpack holds references to the variables visible around it — not photographs of their current values. If someone back at camp changes a variable in the backpack after the hiker leaves, the hiker's copy of that reference still points at the same variable, so they see the new value too. The hiker doesn't carry a snapshot from the moment they left; they carry a line back to the original.

JavaScript
1
2
3
4
5
6
7
8
function makeGreeter(name) { return function greet() { console.log(`Hello, ${name}`); // `greet` closes over `name` }; } const greetAda = makeGreeter("Ada"); greetAda(); // "Hello, Ada" — greet still has access to `name`, long after makeGreeter returned

makeGreeter finishes executing and its call frame would normally be discarded. But greet still references name, so the JavaScript engine keeps that specific name alive for as long as greet exists. That's the whole mechanism — every stage below is this one idea, applied to a slightly different problem.

The clearest way to see "reference, not copy" is to close over a variable and then change it after the closure was created:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function makeCounter() { let count = 0; return function () { count += 1; return count; }; } const counterA = makeCounter(); const counterB = makeCounter(); counterA(); // 1 counterA(); // 2 counterB(); // 1 — a completely separate `count`, from a separate call to makeCounter

This is also the answer to "why didn't count get garbage-collected when makeCounter returned?" It would have, if nothing still referenced it. Something does: the returned function. The variable's lifetime is now tied to the closure's lifetime, not to the block that declared it.

A plain object exposes its fields to anyone holding a reference to it — there's no way to stop account.balance = 1_000_000 from outside. A closure gives you a place to keep state that literally cannot be reached except through the functions you choose to expose:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function createAccount(initialBalance) { let balance = initialBalance; // not returned, not attached to anything public return { deposit(amount) { balance += amount; return balance; }, withdraw(amount) { if (amount > balance) throw new Error("insufficient funds"); balance -= amount; return balance; }, getBalance() { return balance; }, }; } const account = createAccount(100); account.deposit(50); // 150 account.balance; // undefined — there is no such property; `balance` only exists inside the closure

There's no #balance private field syntax here, no WeakMap trick, no convention like a leading underscore that other code can ignore. balance simply isn't reachable from outside the three functions that closed over it — it never became a property of the returned object at all. This pattern (sometimes called the module pattern) predates JavaScript's class syntax and its #private fields, and it's still the right tool when you want a handful of functions to share hidden state without the ceremony of a class.

A closure is also just a convenient place to keep a cache between calls, with no global variable and no class:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function memoize(fn) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key); // skip re-computing — the closure remembered } const result = fn(...args); cache.set(key, result); return result; }; } function slowSquare(n) { for (let i = 0; i < 1e8; i++); // pretend this is expensive return n * n; } const fastSquare = memoize(slowSquare); fastSquare(5); // slow the first time — computes and caches fastSquare(5); // instant — the closure's `cache` already has the answer

Runs right in your browser — poke at it and watch the concept react live.

Back to the opening example. The bug is that var i creates exactly one binding for the entire loop — not one per iteration — because var is function-scoped (or global-scoped), not block-scoped. All five click handlers close over that same single i, and by the time anyone clicks a button, the loop has already finished and i is 6.

Fix 1 — use let instead of var. This is the fix that shipped in ES2015 specifically to solve this problem: let creates a fresh binding for i on every iteration, so each closure captures its own copy of the loop variable.

JavaScript
1
2
3
4
5
for (let i = 1; i <= 5; i++) { button.addEventListener("click", function () { console.log(`You clicked button ${i}`); // correct — each i is its own binding }); }

Fix 2 — wrap the body in an IIFE to force a new scope per iteration. This is the pre-ES2015 fix, and it's worth knowing because you'll still see it in older code: an immediately-invoked function expression creates a new function scope on every pass, and you pass the current i in as an argument, snapshotting its value at that instant.

JavaScript
1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) { (function (capturedI) { button.addEventListener("click", function () { console.log(`You clicked button ${capturedI}`); // correct — capturedI is a fresh parameter each time }); })(i); }

Fix 3 — pass the value as a parameter to the handler factory. The same idea as Fix 2, expressed as a named helper instead of an inline IIFE — often the most readable option in real code:

JavaScript
1
2
3
4
5
6
7
8
9
function makeHandler(n) { return function () { console.log(`You clicked button ${n}`); // n is this call's own parameter }; } for (var i = 1; i <= 5; i++) { button.addEventListener("click", makeHandler(i)); }

All three fixes do the same thing: give each iteration its own variable to close over, instead of letting every closure share one. let just does it automatically, which is why it's the default choice today.

  • Closures capture by reference, not by value. If a closure captures an object or array, mutating that object later — from anywhere, not just inside the closure — is visible to the closure the next time it runs. This is the same mechanism as the loop bug, and it cuts both ways: it's occasionally exactly what you want (Stage 2's balance), and occasionally a bug (Stage 4's i).
  • Closures can retain more than you intend. A closure keeps its entire enclosing scope reachable, not just the variables it uses — practically, engines are good at only keeping what's actually referenced reachable, but a closure that captures a large object (a big array, a DOM subtree) alongside a small value you meant to use will keep that large object alive for as long as the closure exists. Long-lived closures — an event listener that's never removed, a timer that never clears — are the usual place this becomes a real memory leak.
  • this is not part of a closure the way ordinary variables are. A regular function gets its own this, determined by how it's called, regardless of what it closes over. An arrow function has no this of its own and looks it up in its enclosing scope like any other closed-over variable — which is exactly why arrow functions are the usual choice for callbacks that need the outer this.
  • React's "stale closure" bug is this exact mechanism. Every render creates a fresh closure over that render's props and state. An event handler or a useEffect callback with a missing dependency closes over the values from the render it was created in — not the latest ones — which is why it can log or use an outdated piece of state even though the component clearly re-rendered since. The exhaustive-deps ESLint rule exists specifically to catch this.
  • Closures aren't free, but they're rarely the bottleneck you'd guess. Each closure that captures variables holds a reference to its enclosing scope for the engine to manage. This matters if you're creating millions of closures in a hot loop over a huge dataset; it does not matter for ordinary UI event handlers, memoization caches, or module-pattern objects — the memory and cost are negligible at that scale.

Reach for a closure when you want state shared by a small, fixed set of functions without exposing it (Stage 2), a cache or "computed once" value that should persist between calls (Stage 3), or a factory that produces several independent instances of the same behavior (Stage 1's makeCounter).

Avoid it when you have many methods that all need the same shared state — a class with private #fields expresses that more clearly and with less nesting than a pile of closures returned from one factory function. Closures and classes solve the same problem; classes read better once you're past three or four methods.

Watch it when the closure lives a long time — a global event listener, a setInterval that never clears, a cache with no eviction. The closure will keep everything it references alive for exactly that long, so audit what it's actually capturing, not just what you meant it to capture.

Because var creates one binding for the whole loop, and every closure created inside the loop shares that single binding — by the time any callback runs, the loop has finished and the variable holds its final value. See Stage 4 for three fixes.

No — it's a live reference to the variable itself, in the scope where it was declared. If that variable changes after the closure was created, the closure sees the new value, because it was never holding a snapshot in the first place.

They close over ordinary variables the same way. The difference is this: an arrow function has no this of its own, so it looks this up in its enclosing scope like any other closed-over variable, while a regular function's this depends on how it's called.

Yes, indirectly. A closure keeps its enclosing scope's variables reachable for as long as the closure itself is reachable. A closure attached to an event listener that's never removed, or stored in a cache that never evicts, holds onto whatever it captured for that entire time — that's a real, common source of leaks in long-running pages.

They're different concepts that usually overlap. A callback is a function passed to be called later by something else; a closure is the property that lets that function still see the variables from where it was defined. Almost every callback you write is also a closure — the two ideas describe different aspects of the same function.

Yes. An async function or a .then() callback closes over its surrounding scope exactly like a synchronous one — including the loop-variable bug, if you write var inside a loop that awaits or schedules something. let fixes it the same way.

PatternCodeNotes
Private statefunction make(){ let x; return {get(){return x}} }x is unreachable except through the returned methods
Independent instancescall the factory againeach call creates a new scope; closures don't share across calls
Memoizeconst cache = new Map() inside the factorycache persists across calls to the returned function
Run oncelet ran = false; return () => { if (ran) return; ran = true; ... }guards side effects that must happen exactly once
Fix loop captureuse let i, not var igives every iteration its own binding
Pre-ES2015 loop fixwrap in an IIFE, pass i as a parametermanually recreates a fresh binding per iteration
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// The whole pattern, copy-paste ready: private state + memoized method + run-once init function createWidget(id) { let ready = false; const cache = new Map(); function init() { if (ready) return; // run-once guard ready = true; console.log(`widget ${id} initialized`); } function computeExpensive(key) { if (cache.has(key)) return cache.get(key); // memoized const value = key.toUpperCase(); // stand-in for real work cache.set(key, value); return value; } return { init, computeExpensive }; // id, ready, and cache stay private } const widget = createWidget("nav-1"); widget.init(); // logs once widget.init(); // no-op — ready is already true widget.computeExpensive("hero"); // computes and caches widget.computeExpensive("hero"); // returns the cached value

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

  • A closure is a function bundled with a live reference to its enclosing scope — not a snapshot of the values at creation time.
  • Each call to an outer function creates a fresh scope; closures created in separate calls don't share state, but closures created in the same call do.
  • The classic loop bug happens because var creates one binding for the whole loop; let (or a manual IIFE) gives each iteration its own.
  • Closures are how you get private state and memoization without a class — but a long-lived closure keeps everything it captured alive for exactly as long as it exists.
  • React's "stale closure" bug is the same mechanism as the loop bug: a handler closing over a value from the render it was created in, not the latest one.

Back to the five buttons from the top: swap that var for a let, and each one finally logs its own number — not because the bug fixed itself, but because each iteration now gets its own binding for a closure to hold onto. What's the first place in your own code you'd bet a var-in-a-loop closure bug is still hiding? Tell me in the comments.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.