JavaScript Closures Cheat Sheet
Covers how closures capture variables from their enclosing scope, with practical patterns like private state, memoization, and a classic loop pitfall.
What Is a Closure
A function remembers the scope it was created in.
function makeCounter() { let count = 0; // "Private" variable return function () { count += 1; // Inner function "closes over" count return count; };}const counter = makeCounter();counter(); // 1counter(); // 2counter(); // 3 -- count persists between calls, isolated per counter instanceconst counter2 = makeCounter();counter2(); // 1 -- independent state from `counter`
Private State / Module Pattern
Expose only the operations you want, hide the rest.
function createBankAccount(initialBalance) { let balance = initialBalance; // Not accessible from outside 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 = createBankAccount(100);account.deposit(50); // 150// account.balance is undefined -- no direct access
The Classic Loop Pitfall
var vs let inside async callbacks.
// Bug: `var` is function-scoped, shared across all callbacksfor (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // Logs 3, 3, 3}// Fix 1: use `let`, which is block-scoped (new binding per iteration)for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // Logs 0, 1, 2}// Fix 2: create a new scope with an IIFEfor (var i = 0; i < 3; i++) { ((i) => setTimeout(() => console.log(i), 100))(i);}
Memoization with Closures
Cache expensive results in a closed-over Map.
function memoize(fn) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn(...args); cache.set(key, result); return result; };}const slowSquare = (n) => { for (let i = 0; i < 1e6; i++); return n * n; };const fastSquare = memoize(slowSquare);fastSquare(5); // ComputedfastSquare(5); // Returned from cache
Key Concepts
Core vocabulary for closures.
- Lexical scope- A function's scope is determined by where it's defined in the source, not where it's called
- Closure- A function bundled with references to its surrounding variables, which stay alive as long as the closure exists
- Free variable- A variable used in a function but not declared locally, captured from an outer scope
- IIFE- Immediately Invoked Function Expression, historically used to create isolated scope
- Garbage collection- Variables captured by a closure aren't collected until the closure itself is no longer reachable
Closures as an Alternative to Classes
Factory functions can express encapsulation without `this` or `new`.
function createStore(initialState) { let state = initialState; const listeners = new Set(); return { getState: () => state, setState(patch) { state = { ...state, ...patch }; listeners.forEach((fn) => fn(state)); // Closure captures `state` and `listeners` }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); // Returned closure captures `fn` for cleanup }, };}const store = createStore({ count: 0 });const unsubscribe = store.subscribe((s) => console.log("state:", s));store.setState({ count: 1 });unsubscribe(); // No `this` binding issues, unlike class methods passed as callbacks
Currying & Partial Application
Each returned function closes over the arguments accumulated so far.
function curry(fn) { return function curried(...args) { if (args.length >= fn.length) return fn(...args); return (...more) => curried(...args, ...more); // Closes over `args` from the outer call };}const add3 = (a, b, c) => a + b + c;const curriedAdd = curry(add3);curriedAdd(1)(2)(3); // 6curriedAdd(1, 2)(3); // 6curriedAdd(1)(2, 3); // 6const addFive = curriedAdd(5); // Partially applied, reusableaddFive(10, 20); // 35
once / debounce / throttle
Closures hold the timer handle and call-state between invocations.
function once(fn) { let called = false, result; return (...args) => { if (!called) { result = fn(...args); called = true; } return result; };}function debounce(fn, wait) { let timer; return (...args) => { clearTimeout(timer); // Closure over `timer` across calls timer = setTimeout(() => fn(...args), wait); };}function throttle(fn, limit) { let inCooldown = false; return (...args) => { if (inCooldown) return; fn(...args); inCooldown = true; setTimeout(() => { inCooldown = false; }, limit); };}window.addEventListener("resize", debounce(() => console.log("resized"), 200));
How Closures Interact with Garbage Collection
Only variables actually referenced inside the closure body are retained by modern engines.
function outer() { const bigArray = new Array(1_000_000).fill("x"); // Large, unused by inner const small = "kept"; return function inner() { return small; // V8's optimizing compiler can drop `bigArray` from the closure scope };}// To force retention (e.g. for debugging), reference the variable somewhere in the closure:function outerDebug() { const bigArray = new Array(1_000_000).fill("x"); return function inner() { if (false) console.log(bigArray); // Keeps bigArray alive even though this branch never runs return "kept"; };}// Practical implication: closures in long-lived event listeners should avoid// capturing large objects they don't need, or null them out explicitly when done
Scope Mechanics Deep Dive
Precise vocabulary for how the engine implements closures.
- Variable environment- The internal record mapping identifiers to values for a given execution context; closures keep a reference to this, not a copy
- Shared vs per-call scope- Each invocation of an outer function creates a fresh variable environment, so closures from different calls never share state unless explicitly passed a common reference
- TDZ inside closures- A closure referencing a `let`/`const` declared later in the same block still throws ReferenceError if invoked before that line executes (temporal dead zone)
- Closures over loop block scope- With `let`, each loop iteration gets its own lexical environment, so closures created per-iteration capture distinct bindings automatically
- Function.prototype.bind- Produces a closure-like bound function that fixes `this`/leading args, but it is not a true closure over local variables -- it wraps the original function
- WeakRef/FinalizationRegistry- Advanced escape hatch to observe an object's collection without the closure itself keeping it alive, useful for caches that shouldn't prevent GC
Closures keep their entire enclosing scope alive, not just the variables they reference — holding a long-lived closure over a large object (e.g. inside an event listener you never remove) is a common source of memory leaks.