JavaScript

JavaScript Closures: Every Use Case Explained (Live Demos)

W
W3Tweaks Team
Frontend Tutorials
Jun 13, 2026 24 min read
JavaScript Closures: Every Use Case Explained (Live Demos)
Every closures tutorial gives you the same one-line definition and a counter example. This one has live demos — watch a closure's 'backpack' keep variables alive after the outer function returns, run the var-vs-let-vs-IIFE loop trap, and execute five real patterns. Plus what most tutorials skip: #private class fields as the modern alternative, WeakMap memoization for object keys, stale closures in async setInterval (not just React), the interview-question toolkit (debounce + curry + compose), and WeakRef + Symbol.dispose for closure cleanup.

JavaScript closures are functions that remember the variables from the scope where they were defined — and they keep access to those variables even after that outer scope has finished executing. That’s the whole concept. The reason closures confuse people isn’t the definition; it’s that you can’t see the captured variables. They live in an invisible “backpack” the function carries around.

This guide makes them visible. The live demo below shows a closure holding onto a variable after its outer function has returned, lets you run the infamous var-vs-let loop trap and watch each closure’s captured value, and executes five real-world patterns you’ll actually use — memoization with a live cache, the module pattern, once(), and the over-capture memory leak with its fix.

Beyond the basics, we cover what most tutorials skip: #private class fields as the modern alternative for encapsulation, WeakMap memoization for object keys (and the JSON.stringify key footgun), stale closures in vanilla setInterval (not just React), the closure interview-question toolkit (debounce + curry + compose), closure over this (arrow vs regular vs .bind()), React Hooks ARE closures (why exhaustive-deps exists), and WeakRef + Symbol.dispose for resource cleanup.

Closures are the mechanism behind the memory leaks and the debounce/throttle patterns covered elsewhere — understanding them deeply makes those topics click.

Live Demo

Live Demo Open in tab

Tab 1: watch a closure keep a variable alive. Tab 2: run the var/let/IIFE loop trap. Tab 3: execute five real closure patterns including the memory-leak trap.

What Is a Closure in JavaScript? The Backpack Mental Model

What is a closure in JavaScript? A closure is the combination of a function and the lexical scope it was declared in. When a function is created, it packs every variable from its surrounding scope into an invisible “backpack.” It carries that backpack wherever it goes, so it can read those variables long after the outer function has returned.

function outer() {
  let name = 'Ana';          // lives in outer's scope

  function inner() {
    console.log(name);       // inner packs `name` into its backpack
  }

  return inner;               // outer returns, but `name` survives
}

const greet = outer();        // outer() has finished executing
greet();                      // 'Ana' — inner still has `name`

Normally name would be garbage-collected when outer() returns. But because inner still references it, JavaScript keeps it alive. That is a closure: inner closed over name.

Why Closures Exist: Lexical Scope

Closures fall out of one rule — lexical scoping. A function can access variables from the place where it was written (defined), not where it is called. The scope is determined by position in the source code.

const x = 'global';

function a() {
  const x = 'inside a';
  return function b() {
    return x;          // looks UP the scope chain from where b was written
  };
}

const fn = a();
fn();                  // 'inside a' — not 'global'
// b was written inside a, so it sees a's x first

Each function’s backpack contains its own scope plus a reference to its parent’s scope, forming a scope chain all the way up to global. When you read a variable, JavaScript walks up this chain until it finds it.

var let Closure Loop — The Classic Bug

This is the most famous closure gotcha, asked in nearly every interview. The classic var vs let closure loop bug is the reason let was added to the language. The demo’s second tab runs all three versions live so you can see the captured values.

// ❌ var — prints 3, 3, 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// All three callbacks share ONE `i` (var is function-scoped).
// By the time they run, the loop is done and i === 3.
// ✅ let — prints 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// let is block-scoped — each iteration gets a NEW binding of `i`.
// Each callback closes over its own copy.
// ✅ IIFE — the pre-ES6 fix, prints 0, 1, 2
for (var i = 0; i < 3; i++) {
  (function(captured) {
    setTimeout(() => console.log(captured), 100);
  })(i);  // pass i in as an argument — each call gets its own `captured`
}

Why var fails: function-scoped var creates a single binding shared by all iterations. All three closures point at the same i. Why let works: block-scoped let creates a fresh binding every iteration, so each closure captures a different i. This single difference is the entire bug — and the reason let exists.

Use Case 1: Private Variables JavaScript with Closures

A JavaScript closure counter is the canonical private-variable demo. Closures are JavaScript’s original way to create truly private state — variables that cannot be accessed or mutated from outside.

function createCounter() {
  let count = 0;             // private — no outside access

  return {
    increment() { count++; return count; },
    decrement() { count--; return count; },
    get value()  { return count; },
  };
}

const counter = createCounter();
counter.increment();   // 1
counter.increment();   // 2
counter.value;         // 2
counter.count;         // undefined — cannot touch `count` directly

count exists only inside the closure. The returned methods can read and modify it, but no outside code can reach it. This is encapsulation without classes or #private fields — but if you’re already using a class, see the next section.

Closure vs Class JavaScript — When to Use #private Fields

Closure vs class in JavaScript: both can hide state — here’s when to pick each. #private class fields shipped in Chrome 74 (April 2019) and are now Baseline across all evergreens. They’re the idiomatic answer when you already have a class.

// Closure-based private state
function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    get value() { return count; },
  };
}

// Class with #private field — modern equivalent
class Counter {
  #count = 0;
  increment() { return ++this.#count; }
  get value() { return this.#count; }
}
Closure#private class field
Browser supportUniversalBaseline (Chrome 74+, Safari 14.1+, Firefox 90+)
instanceof works❌ no class identity
Multiple instances✅ each call creates one
Memory per instanceOne closure scopeOne field per instance
this semanticsNone (you choose)Standard class this
TypeScript inference✅ structural type from return✅ class type
Idiomatic forFunctional code, factories, decoratorsOO code, instanceof checks

Rule of thumb: use closures for factory functions, functional composition, and when you don’t need class identity. Use #private when you already have a class and want instance methods, instanceof, or inheritance.

Use Case 2: JavaScript Currying with Closures

JavaScript currying with closures lets you build specialized functions from general ones. A closure lets you “pre-fill” some arguments and return a specialised function.

function multiplier(factor) {
  return function(num) {
    return num * factor;     // `factor` captured from the outer call
  };
}

const double = multiplier(2);   // factor is permanently 2
const triple = multiplier(3);   // factor is permanently 3

double(5);   // 10
triple(5);   // 15

Each returned function carries its own factor in its backpack. double and triple are independent — they do not share state. This is the foundation of currying and partial application.

Use Case 3: Memoize JavaScript with Closures

Memoization in JavaScript uses a closure to cache results across calls so expensive computations run only once per input.

function memoize(fn) {
  const cache = new Map();    // private cache, survives across calls

  return function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);  // cache hit — instant
    }

    const result = fn(...args);
    cache.set(key, result);   // store for next time
    return result;
  };
}

const slowSquare = (n) => {
  // imagine this is expensive
  return n * n;
};

const fastSquare = memoize(slowSquare);
fastSquare(4);   // computes → 16
fastSquare(4);   // cache hit → 16 (no recomputation)

The cache Map lives in the closure — it is private and persistent. Each call checks the cache before computing.

WeakMap Memoization for Object Keys

Map keys are strong references — if you memoize a function that takes objects, those objects are pinned in memory forever. WeakMap lets the garbage collector reclaim the entry when the object key has no other references:

function memoizeObject(fn) {
  const cache = new WeakMap();   // keys can be garbage-collected

  return function(obj) {
    if (cache.has(obj)) return cache.get(obj);
    const result = fn(obj);
    cache.set(obj, result);
    return result;
  };
}

const expensiveCompute = memoizeObject((user) => {
  return user.posts.reduce((sum, p) => sum + p.score, 0);
});

JSON.stringify Key Gotchas

The JSON.stringify(args) key from the first example has several footguns you should know about:

  • NaN becomes nullmemoize(fn)(NaN) collides with memoize(fn)(null)
  • undefined is droppedmemoize(fn)(1, undefined) keys to the same string as memoize(fn)(1)
  • Functions become undefined — function args aren’t serializable
  • Circular references throwJSON.stringify({a: someObj, b: someObj}) works; JSON.stringify(circular) throws
  • Key order matters for objects{a:1, b:2} and {b:2, a:1} are different strings

For object args, use WeakMap. For mixed primitive args, use a tuple-style key like args.join('|') only when you control the inputs.

Use Case 4: Run Once (once)

A closure remembers whether a function has already run, so it never runs twice — useful for one-time initialisation.

function once(fn) {
  let called = false;
  let result;

  return function(...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);  // run once, cache the result
    }
    return result;                     // always return the first result
  };
}

const initialize = once(() => {
  console.log('Setting up...');
  return { ready: true };
});

initialize();   // 'Setting up...' → { ready: true }
initialize();   // (no log) → { ready: true }  — never runs again

The called flag lives in the closure, persisting between calls. This is how libraries implement one-time setup and how lodash.once works.

Module Pattern JavaScript — The Legacy IIFE (and ES Modules)

Before ES modules, closures were how JavaScript created modules with public and private members. The module pattern in JavaScript predates ES modules but still appears in legacy code.

const bankAccount = (function() {
  // ── Private ──
  let balance = 0;
  function validate(amount) {
    return amount > 0;
  }

  // ── Public API ──
  return {
    deposit(amount) {
      if (validate(amount)) balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (validate(amount) && amount <= balance) balance -= amount;
      return balance;
    },
    getBalance() { return balance; },
  };
})();  // IIFE runs immediately, returns the public object

bankAccount.deposit(100);    // 100
bankAccount.withdraw(30);    // 70
bankAccount.balance;         // undefined — private!

ES Module Rewrite (the Modern Way)

IIFE modules are legacy — useful to recognize in old codebases, but write ES modules in new code. The closure mechanics are identical; the syntax is just standardized:

// bank-account.js
let balance = 0;                              // private (module scope)
function validate(amount) { return amount > 0; } // private

export function deposit(amount) {
  if (validate(amount)) balance += amount;
  return balance;
}
export function withdraw(amount) {
  if (validate(amount) && amount <= balance) balance -= amount;
  return balance;
}
export function getBalance() { return balance; }
// main.js
import { deposit, withdraw, getBalance } from './bank-account.js';
deposit(100);     // 100

Module-level let and const are private to the module just like closure variables — only exported names are visible. Use ES modules for all new code. Recognize the IIFE pattern when you encounter it in pre-2015 codebases.

JavaScript Closure Memory Leak — Over-Capture

A JavaScript closure memory leak happens when a long-lived function holds a reference to a large object it doesn’t need. Closures capture the entire scope they were defined in — not just the variables they use. If a long-lived closure captures a large object it never touches, that object cannot be garbage-collected.

// ❌ LEAK — the closure captures largeData but never uses it
function createHandler() {
  const largeData = new Array(1_000_000).fill('*');  // ~8MB

  return function handler() {
    console.log('clicked');   // never touches largeData
    // ...but largeData is captured anyway and can't be freed
  };
}

button.onclick = createHandler();
// largeData stays in memory as long as the handler is attached
// ✅ FIX — extract what you need, let the rest be collected
function createHandler() {
  const largeData = new Array(1_000_000).fill('*');
  const summary = largeData.length;   // keep only what you need
  // largeData is now eligible for GC once this function returns

  return function handler() {
    console.log('clicked, size was', summary);
  };
}

The garbage collector cannot tell that handler never reads largeData — the reference exists, so the memory stays. The fix is to be explicit: pull out only the values you need before returning the closure. This is the memory leak pattern #4 in action.

WeakRef and using for Resource Cleanup

For resources that need explicit cleanup, WeakRef (ES2021) and the using declaration (Stage 4, broadly shipping 2025) give you tools the closure-extract trick can’t:

// WeakRef — hold a reference that doesn't prevent GC
const cache = new WeakRef(largeObject);
// Later: const obj = cache.deref(); if (obj) { ... }

// using declaration — auto-disposes a resource when the scope exits
function process() {
  using db = openDatabase();  // db[Symbol.dispose]() runs on scope exit
  return db.query('SELECT 1');
}  // db.close() automatically called here, even on throw

Use using when the resource has a clear lifetime (file handles, DB connections, locks). Use WeakRef when you want the GC to decide whether to keep an optional cache alive.

Stale Closure React — useEffect and setInterval

A stale closure in React happens when a useEffect callback captures an old render’s state. A closure captures variables by reference to their binding, but for state values that get reassigned across renders, the closure sees the value at definition time.

// A closure captures the variable from when it was created
function setup() {
  let message = 'first';

  const printer = () => console.log(message);

  printer();              // 'first'
  message = 'second';
  printer();              // 'second' — same binding, sees the update
}

In React, every render creates new closures over that render’s props/state snapshot. The classic stale closure happens when an event handler or effect captures a state value from a previous render:

// ❌ Stale closure — count is captured from the render when the effect ran
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);   // always logs the count from the FIRST render
  }, 1000);
  return () => clearInterval(id);
}, []);  // empty deps — effect runs once, closure freezes `count`

// ✅ Fix — functional updater always sees current state
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);  // functional update — always current
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ Or: use a ref for the latest value
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current);  // always the latest
  }, 1000);
  return () => clearInterval(id);
}, []);

JavaScript Closure async — setInterval and Polling Loops

The same stale-closure trap hits JavaScript closures and async/await in vanilla polling loops, not just React. If your poller captures a token at setup time, it’ll keep using the old token after refresh:

// ❌ Stale closure — token captured at setup, never updates
async function startPolling(token) {
  setInterval(async () => {
    const res = await fetch('/api/data', {
      headers: { Authorization: `Bearer ${token}` }
    });
    // ...stale token after refresh
  }, 5000);
}

// ✅ Fix — read the token from a shared ref-like object on each tick
const auth = { token: initialToken };

setInterval(async () => {
  const res = await fetch('/api/data', {
    headers: { Authorization: `Bearer ${auth.token}` }
  });
}, 5000);

// When you refresh:
async function refreshToken() {
  auth.token = await getNewToken();  // every future tick reads the new token
}

It’s the same bug as React: the callback closes over the value at definition time, not at call time. The fix is the same shape — read from a mutable container instead of capturing a primitive.

React Hooks ARE Closures (Why exhaustive-deps Exists)

The single highest-traffic modern angle on closures: React Hooks are built on closures. Every render creates new closures over that render’s state and props. The dependency array tells React when to rebuild the closure:

// On render 1: count = 0 → effect creates a closure over count = 0
// On render 2: count = 1 → effect creates a NEW closure over count = 1
// Without count in deps, render 2's effect doesn't re-run → stale closure
useEffect(() => {
  console.log(count);  // reads count from THIS render's closure
}, [count]);  // ← dependency makes React rebuild the closure when count changes

Forget a dep → React reuses the old closure → you keep reading old data. The react-hooks/exhaustive-deps ESLint rule exists specifically to catch this.

Escape hatches when you genuinely want a value that survives renders without rebuilding the closure:

  • useRefref.current is the same object across renders; mutate it without re-rendering
  • useCallback(fn, [deps]) — stable function identity when deps haven’t changed
  • useMemo(() => value, [deps]) — stable value identity

Once you internalize “every render is a new closure,” the React Hooks rules stop feeling arbitrary.

Closure Over this — Arrow vs Regular vs .bind()

The single most-Googled JS problem is fundamentally a closure question: closure over this. Arrow functions close over this lexically; regular functions don’t.

class Timer {
  constructor() {
    this.seconds = 0;
  }

  // ❌ Regular function — `this` is dynamic, becomes undefined / window in setTimeout
  startBroken() {
    setTimeout(function() {
      this.seconds++;   // TypeError: undefined.seconds (in strict mode)
    }, 1000);
  }

  // ✅ Arrow function — closes over `this` lexically from the enclosing scope
  startArrow() {
    setTimeout(() => {
      this.seconds++;   // ✓ this is the Timer instance
    }, 1000);
  }

  // ✅ .bind(this) — pre-arrow fix, still works
  startBind() {
    setTimeout(function() {
      this.seconds++;
    }.bind(this), 1000);
  }
}

Performance: arrow functions and .bind() are equivalent in modern V8 for the lexical this use case. Use arrow for readability; .bind() only when you need to keep the function detachable (e.g., adding/removing event listeners).

JavaScript Closure Interview Questions

Three JavaScript closure interview questions you’ll actually be asked: implement debounce, curry, and compose. Each demonstrates a distinct closure pattern.

Implement debounce

function debounce(fn, delay) {
  let timeoutId;    // captured in the closure across calls

  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  };
}

const search = debounce((query) => console.log('search:', query), 300);
search('a'); search('ap'); search('app');   // only 'app' fires after 300ms

Implement curry

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);   // all args provided — call
    }
    // not enough args — return a closure that captures collected args
    return (...more) => curried.apply(this, [...args, ...more]);
  };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3);      // 6
add(1, 2)(3);      // 6
add(1)(2, 3);      // 6

Implement compose and pipe

// compose: f(g(h(x))) — right-to-left
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

// pipe: h(g(f(x))) — left-to-right (Lodash flow / fp pipe)
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

const trim       = (s) => s.trim();
const lowercase  = (s) => s.toLowerCase();
const exclaim    = (s) => s + '!';

const shout = pipe(trim, lowercase, exclaim);
shout('  HELLO  ');   // 'hello!'

Each closure captures the fns array and applies it to whatever you call the result with. This is the foundation of functional composition.

Key Takeaways

  • A closure is a function bundled with the variables from where it was defined — it keeps access to them even after the outer function returns
  • Closures exist because of lexical scoping: a function sees variables from where it was written, walking up the scope chain
  • The var-vs-let loop trap prints 3,3,3 with var (one shared binding) but 0,1,2 with let (a fresh binding per iteration). This single difference is why let exists
  • Closures enable private variables — state outside code cannot read or mutate. #private class fields (Baseline since 2022) are the modern alternative when you have a class
  • Function factories (currying) pre-fill arguments and return specialised functions, each carrying its own captured values
  • Memoization uses a closure-held cache. Use Map for primitive keys, WeakMap for object keys (lets the GC reclaim entries). Watch the JSON.stringify key footguns (NaN, undefined, functions, circular refs)
  • The module pattern (IIFE + returned object) predates ES modules. Write ES modules in new code — module-level let/const are private just like closure variables
  • Closures capture the entire scope, not just variables they use — a closure holding a large unused object causes a memory leak; extract only what you need
  • WeakRef (ES2021) and the using declaration (Stage 4) give you explicit cleanup options the extract trick can’t
  • Stale closures happen in React useEffect AND in vanilla setInterval polling loops — both capture a value at setup and never see updates. Fix with functional updaters, refs, or shared mutable containers
  • React Hooks ARE closures — every render creates new closures over that render’s state. The dependency array tells React when to rebuild. The exhaustive-deps ESLint rule exists to prevent stale-closure bugs
  • Closure over this — arrow functions close lexically; regular functions don’t. Use arrow for callbacks that need the surrounding this; .bind(this) is the pre-arrow fix
  • Closure interview toolkit: debounce (captured timeoutId), curry (captured args), compose/pipe (captured fns array)

FAQ

What is a closure in simple terms?

A closure is a function that remembers the variables from the place where it was created, even after that place has finished running. Think of it as a backpack: when a function is defined, it packs up all the variables it can see from its surrounding scope and carries them wherever it goes. So an inner function returned from an outer function can still read the outer function’s variables long after the outer function has returned.

Why does my loop with var print the same number every time?

Because var is function-scoped, not block-scoped — the entire loop shares a single i variable. Every callback you create in the loop closes over that same i, and by the time the callbacks actually run (after a setTimeout, for example), the loop has finished and i holds its final value. Fix it by using let instead of var, which creates a new block-scoped binding of i for each iteration, so each closure captures its own copy.

Are closures bad for performance or memory?

Closures themselves are cheap and fundamental to JavaScript — you cannot avoid them. The risk is memory: a closure keeps every variable in its scope alive as long as the closure exists. If a long-lived closure (an event handler, a timer callback) captures a large object, that object cannot be garbage-collected even if the closure never uses it. The fix is to capture only what you need — extract the specific values into smaller variables before returning the closure. For optional caches that should be reclaimable, use WeakRef.

What is the difference between a closure and a regular function?

Every function in JavaScript technically forms a closure — the term specifically describes the function plus its captured lexical environment. A “regular function” that uses only its own parameters and local variables has an empty or unused backpack. A function becomes a meaningful closure when it references variables from an enclosing scope and outlives that scope, keeping those variables alive. So the distinction is about whether the function captures and depends on outer-scope variables.

How do closures create private variables?

You define a variable inside a function and return an inner function (or object of functions) that uses it, without returning the variable itself. The variable lives only in the closure’s scope — outside code has no reference to it and cannot read or change it directly. The returned functions become the only interface to that private state. This is how the module pattern and factory functions implement encapsulation without classes.

What is a stale closure in React?

A stale closure happens when a function captures a state or prop value from a particular render and then runs later, after that value has changed — so it sees the old, “stale” value. The classic case is a setInterval inside a useEffect with an empty dependency array: the callback closes over the state from the first render and never sees updates. Fix it with the functional updater form of setState, a useRef that always holds the current value, or by including the value in the dependency array. The same trap exists in vanilla JavaScript — any polling loop that captures a token at setup will keep using the stale token after refresh.

When should I use closures vs #private class fields?

Both hide state. Use closures for factory functions, functional composition, and code that doesn’t need class identity. Use #private class fields (Baseline since 2022, Chrome 74+, Safari 14.1+, Firefox 90+) when you already have a class and want instance methods, instanceof checks, or inheritance. The class-with-#private form is more idiomatic in OO code; the closure form is more idiomatic in functional code. Both have universal modern support — pick whichever matches your codebase’s style.

Can I memoize a function that takes an object?

Yes, but don’t use Map — Map keys are strong references, so memoized object keys are pinned in memory forever. Use WeakMap for object keys: const cache = new WeakMap(). When the object key has no other references, the GC reclaims the entry automatically. If you must use Map (e.g., for complex tuple keys), watch out for JSON.stringify footguns: NaN becomes null, undefined is dropped, functions become undefined, and circular references throw. For mixed primitive args you control, prefer a tuple-style key like args.join('|').

Why isn’t my variable updating in setInterval?

You’ve hit a stale closure. Your setInterval callback captured the variable at setup time and is still using that snapshot — it never sees later updates. Three fixes: (1) use a functional updater if it’s React state — setX(prev => prev + 1) always reads the latest. (2) Use a mutable container like an object or useRefconst ref = { value: initial }, then read ref.value on each tick (because objects are captured by reference, mutations are visible). (3) Recreate the interval every time the value changes — useEffect(() => { setInterval(...) }, [value]). The bug isn’t setInterval itself; it’s that primitive values are captured by value, not by reference.