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.

- 1JavaScript Event Loop Explained: The Complete Guide13 min
- 2TypeScript Generics: The Complete Guide (with Cheat Sheet)15 min
- 3CORS Explained: The Complete Guide (with Cheat Sheet)14 min
- 4JavaScript Proxy and Reflect: The Complete Guide15 min
- 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.
- Why closures matter — the loop that logs 5 five times
- The mental model: a function with a backpack
- Stage 1: a closure is just a function that remembers
- Stage 2: private state without a class
- Stage 3: memoization — a cache that lives in a closure
- Stage 4: fixing the loop bug, three ways
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Here's the naive way to wire up five buttons, each meant to log its own position:
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.
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:
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:
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:
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.
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.
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:
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'si). - 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.
thisis not part of a closure the way ordinary variables are. A regularfunctiongets its ownthis, determined by how it's called, regardless of what it closes over. An arrow function has nothisof 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 outerthis.- 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
useEffectcallback 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. Theexhaustive-depsESLint 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.
| Pattern | Code | Notes |
|---|---|---|
| Private state | function make(){ let x; return {get(){return x}} } | x is unreachable except through the returned methods |
| Independent instances | call the factory again | each call creates a new scope; closures don't share across calls |
| Memoize | const cache = new Map() inside the factory | cache persists across calls to the returned function |
| Run once | let ran = false; return () => { if (ran) return; ran = true; ... } | guards side effects that must happen exactly once |
| Fix loop capture | use let i, not var i | gives every iteration its own binding |
| Pre-ES2015 loop fix | wrap in an IIFE, pass i as a parameter | manually recreates a fresh binding per iteration |
Think it clicked? Take the 8-question quiz →
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
varcreates 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
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.