JavaScript Closures: The Definitive Guide (with 7 Production Patterns)

Link copied
JavaScript Closures: The Definitive Guide (with 7 Production Patterns)

JavaScript Closures: The Definitive Guide (with 7 Production Patterns)

JS Tutorial Module 2: Functions Lesson 2.5

Closures are the most-asked JavaScript interview question on Earth and also the concept most people memorize a definition of without truly understanding. They are not a quirk, not a trick, not a syntactic feature. Closures are simply what happens when lexical scope meets first-class functions.

In this article, we will pin down what closures actually are, watch the engine create one, and walk through seven production patterns where closures save you real code.

This is Part 4 of the JavaScript Fundamentals series. Read Part 1 (execution contexts) and Part 3 (lexical scope) first.

The definition that actually helps #

A closure is a function bundled with a reference to its outer lexical environment. The function "closes over" the variables it uses from its surrounding scope — and those variables stay alive as long as the function does, even if the outer function that created them has already returned.

That is the whole concept. Everything else is consequences.

Watching a closure form #

function makeCounter() {
  let count = 0;
  return function inc() {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Step by step:

  1. makeCounter() is called. A function execution context is pushed onto the call stack (Part 1). Its lexical environment holds count = 0.
  2. makeCounter creates an inc function. By lexical scope, inc's outer environment is makeCounter's.
  3. makeCounter returns inc. Its execution context is popped off the stack.
  4. But count does not get garbage-collected! Why? Because inc (still alive via the counter variable) holds a reference to its outer environment, which holds count.
  5. Every call to counter() walks the scope chain, finds count, increments it, and returns the new value.

The outer function has finished executing, but its local variable lives on as long as the returned function exists. That is a closure.

The mental model #

Most people picture a closure as the function. It is actually the function and a snapshot reference to its surrounding scope. The function is the visible part; the captured environment is the iceberg under the water.

          ┌────────────────────┐
   inc ──▶│ function inc()     │ ────▶ outer env of inc
          │   count += 1;      │       ┌────────────────┐
          │   return count;    │       │ count: 0       │
          │ }                  │       │ outer: global  │
          └────────────────────┘       └────────────────┘

Each call to makeCounter() creates a new outer environment with its own count. Two different counters do not share state:

const a = makeCounter();
const b = makeCounter();
a(); a(); a(); // 3
b();           // 1 — independent

That independence is what makes closures the foundation of encapsulation in JavaScript.

Pattern 1: Private state without classes #

Before JavaScript had class (and even now, often a better fit), closures gave you encapsulation:

function createAccount(initial) {
  let balance = initial;
  return {
    deposit(amount) { balance += amount; },
    withdraw(amount) { balance -= amount; },
    get balance() { return balance; },
  };
}

const acct = createAccount(100);
acct.deposit(50);
acct.balance; // 150
acct.balance = -1; // setter not defined — silently ignored
// no way to reach the inner `balance` directly

The inner balance is genuinely inaccessible from outside. Classes with private fields (#balance) achieve the same thing now, but closure-based factories are still common because they compose better and have no this binding to worry about.

Pattern 2: Function factories #

When you find yourself writing several near-identical functions that differ only in one constant, return them from a factory:

function multiplyBy(factor) {
  return (n) => n * factor;
}

const double = multiplyBy(2);
const triple = multiplyBy(3);
double(5); // 10
triple(5); // 15

Each returned arrow function closes over its own factor. This is the foundation of currying — see our currying deep-dive.

Pattern 3: Memoization #

A closure is the cleanest place to keep a memoization cache:

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.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const slowFib = (n) => (n < 2 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize(slowFib);
fastFib(40); // instant

The cache is private to this wrapper and shared across every call to the returned function. No global Map, no class, no boilerplate.

Pattern 4: Debounce and throttle #

Debounce — "only fire after the user stops typing" — is a closure capturing a timer ID:

function debounce(fn, ms = 300) {
  let t;
  return function (...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), ms);
  };
}

const search = debounce((q) => fetchResults(q), 400);
inputEl.addEventListener('input', e => search(e.target.value));

The inner function closes over t and fn. Each input event resets the timer; the wrapped function only runs after a quiet period. We have a whole article on debouncing with switchMap for the RxJS-flavored variant.

Pattern 5: Iterators and generators (the manual version) #

Closures can carry iteration state without needing the formal iterator protocol:

function makeRange(start, end) {
  let current = start;
  return {
    next() {
      if (current >= end) return { done: true };
      return { value: current++, done: false };
    },
  };
}

const it = makeRange(0, 3);
it.next(); // { value: 0, done: false }
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { done: true }

The current variable persists across next() calls — same mechanism as makeCounter. Generators (Part 15) give you a cleaner syntax for this same idea.

Pattern 6: Once-only initialization #

A classic helper: "call this expensive function at most once, cache the result forever":

function once(fn) {
  let called = false;
  let result;
  return function (...args) {
    if (!called) {
      result = fn.apply(this, args);
      called = true;
    }
    return result;
  };
}

const getConfig = once(() => fetch('/config').then(r => r.json()));
getConfig(); // fetches
getConfig(); // returns cached promise instantly

Libraries like lodash ship this as a one-liner because every codebase eventually needs it.

Pattern 7: Module pattern (the IIFE classic) #

Before ES modules existed, the only way to get a private namespace was an immediately-invoked function expression:

const Counter = (function () {
  let count = 0;
  return {
    inc() { return ++count; },
    reset() { count = 0; },
  };
})();

Counter.inc(); // 1

The outer function runs once, returns the public API, and its count lives inside the closure of inc and reset. ES modules made this obsolete for top-level code, but the pattern still appears in single-file widgets and library bundles.

The classic for loop trap #

The most-asked closure interview question:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3

Why three 3s and not 0, 1, 2? Because var is function-scoped (Part 2). There is exactly one i for the whole loop. All three arrow functions close over the same binding. By the time the timers fire, the loop has finished and i is 3.

The fix:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Logs: 0, 1, 2

let is block-scoped, so each iteration creates a new i. Each callback closes over a different i. Each binding's value freezes at the moment that iteration ran.

This one example explains why we replaced var with let in modern JavaScript.

What closures cost #

Closures keep their captured environment alive as long as the closure exists. That is power but also a memory consideration. If you create thousands of closures that each capture large objects, those objects stay in memory.

function handler(bigData) {
  // bigData is captured in EVERY closure created below
  document.querySelectorAll('button').forEach(btn => {
    btn.addEventListener('click', () => {
      console.log(bigData.id); // closure holds bigData alive
    });
  });
}

If bigData was a multi-megabyte structure and you only needed bigData.id, extract just what you need first:

function handler(bigData) {
  const id = bigData.id; // bigData can now be GCed once handler returns
  document.querySelectorAll('button').forEach(btn => {
    btn.addEventListener('click', () => console.log(id));
  });
}

We go deeper into this in Part 9 (Memory and Garbage Collection) and Part 10 (WeakMap / WeakSet / WeakRef).

Common gotchas #

Gotcha 1: this is not closed over (unless you use arrow functions).

function Timer() {
  this.seconds = 0;
  setInterval(function () {
    this.seconds++; // "this" is global / undefined, not the Timer
  }, 1000);
}

Use arrow functions (which inherit this lexically) or .bind(this). We cover this in detail in Part 5.

Gotcha 2: Closures + mutable shared state = subtle bugs.

const handlers = [];
let config = { ready: false };
for (let i = 0; i < 3; i++) {
  handlers.push(() => console.log(config.ready));
}
config.ready = true;
handlers.forEach(h => h()); // true true true — they see live config

Closures capture variables by reference (for object types — the same reference). If you mutate the object, every closure sees the change. Sometimes desired, sometimes a bug.

Gotcha 3: Don't accidentally close over event from a loop.

buttons.forEach(btn => {
  btn.addEventListener('click', event => {
    setTimeout(() => doSomething(event), 1000); // capturing the event
  });
});

The event object is fine to capture inside event handlers, but holding it across async boundaries means it can't be GCed. For a long-running app, prefer extracting event.target.value (or whatever you actually need) and capturing that.

Gotcha 4: Performance — too many closures in hot loops.

for (let i = 0; i < 1_000_000; i++) {
  arr.push((x) => x + i); // 1M closures, 1M captured i's
}

Usually fine. Sometimes a perf issue. Modern engines are very good at optimizing closures — but if profiling fingers a hot loop creating millions of closures, that is a place to refactor.

Recap #

  • A closure = a function + a reference to its outer lexical environment.
  • Outer variables stay alive as long as the closure does.
  • Each invocation of an outer function creates a new closure with its own captured environment.
  • Closures enable: private state, factories, memoization, debounce/throttle, iterators, once-only initialization, the IIFE module pattern.
  • The classic for var i bug is a closure issue — solved by let.
  • Closures hold their captured environment alive; for big captures, extract just what you need.

Next up: The this Keyword: 5 Binding Rules — what this actually refers to in every situation, why arrow functions are different, and the rules that resolve every edge case.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *