Back to News & Insights
JavaScript August 25, 2026 · 12 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.

JavaScript Closures: The Complete Guide (with Cheat Sheet)

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.

Contents 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:

Key concept: each call to makeCounter() creates a brand-new count and a brand-new inner function closing over that count. counterA and counterB don't share state — closures are scoped per invocation of the outer function, not per function definition.

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 = 1000000 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:

Key concept: cache lives in exactly one place — the closure created by the single call to memoize(slowSquare) — and every call to fastSquare shares that same cache by reference. If you called memoize(slowSquare) a second time, you'd get a second, independent cache, for the same reason counterA and counterB didn't share state in Stage 1.

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation