Skip to main content
JavaScript intermediate Lesson 14 of 24

Closures in JavaScript

Learn what closures are, how lexical scoping works, and how to use closures for private state, memoization, and module patterns.

A closure is one of the most fundamental — and most useful — concepts in JavaScript. Once you truly understand it, patterns like module encapsulation, memoization, and partial application click into place naturally. Closures aren’t a special syntax or keyword; they’re a consequence of how JavaScript resolves variable names.

Lexical Scoping

JavaScript uses lexical (static) scoping: a function’s scope is determined by where it is written in the source code, not where it is called at runtime. This means inner functions always have access to variables declared in any outer function, simply because of their position in the code. The engine looks up the scope chain at definition time, not at call time.

function outer() {
  const message = "hello from outer";

  function inner() {
    // `message` is accessible here because inner is written inside outer
    console.log(message);
  }

  inner(); // "hello from outer"
}

outer();

What Makes a Closure

A closure forms when an inner function is returned (or passed elsewhere) and continues to reference variables from its outer scope after the outer function has finished executing. Normally, local variables are discarded when a function returns. But when an inner function closes over them, the JavaScript engine keeps those variables alive as long as the inner function exists.

function makeGreeter(greeting) {
  // `greeting` lives in makeGreeter's local scope
  return function (name) {
    // This inner function closes over `greeting` —
    // it will still be accessible after makeGreeter returns
    return `${greeting}, ${name}!`;
  };
}

const sayHello = makeGreeter("Hello");
const sayHi    = makeGreeter("Hi");

console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHi("Bob"));     // "Hi, Bob!"

// makeGreeter has long since returned, but each closure keeps its own `greeting` alive

Each call to makeGreeter produces an independent closure with its own captured greeting. The two closures don’t share state — they each close over a separate variable.

Practical Pattern: Counter with Private State

Closures are the classical way to create private state in JavaScript without classes. The counter variable is completely inaccessible from outside — only the returned methods can read or modify it. This gives you the same encapsulation guarantee as a class with private fields, built purely from function scope.

function createCounter(initialValue = 0) {
  let count = initialValue; // private — no external code can reach this directly

  return {
    increment() { count += 1; },
    decrement() { count -= 1; },
    reset()     { count = initialValue; },
    value()     { return count; },
  };
}

const counter = createCounter(10);
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.value()); // 11

// `count` is unreachable from outside
console.log(counter.count); // undefined — not exposed

This is structurally identical to what classes do with private fields (#count), but built purely from closures.

The Module Pattern

Before ES modules existed, developers used an IIFE (Immediately Invoked Function Expression) to create a module-like scope. The IIFE runs once, sets up private state, and returns a public API object — a closure over the private variables. You’ll encounter this pattern in older libraries and bundled code.

const userStore = (() => {
  // private state — inaccessible from outside
  const users = new Map();
  let nextId = 1;

  // public API — the returned object is what the outside world sees
  return {
    add(name, email) {
      const id = nextId++;
      users.set(id, { id, name, email });
      return id;
    },
    get(id) {
      return users.get(id) ?? null;
    },
    count() {
      return users.size;
    },
  };
})();

const id = userStore.add("Alice", "alice@example.com");
console.log(userStore.get(id));   // { id: 1, name: 'Alice', email: 'alice@example.com' }
console.log(userStore.count());   // 1
console.log(userStore.users);     // undefined — the Map is private

Memoization

Because closures keep their outer scope alive, they’re perfect for caching. A memoization wrapper closes over a Map that persists between calls. On the first call with a given set of arguments, the result is computed and cached. On subsequent calls with the same arguments, the cached result is returned immediately — no recomputation.

function memoize(fn) {
  const cache = new Map(); // this Map persists for the lifetime of the memoized function

  return function (...args) {
    const key = JSON.stringify(args); // serialize args as the cache key
    if (cache.has(key)) {
      return cache.get(key); // cache hit — skip recomputation
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// Expensive recursive computation without memoization
function slowFibonacci(n) {
  if (n <= 1) return n;
  return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}

const fastFibonacci = memoize(slowFibonacci);

console.time("first call");
console.log(fastFibonacci(40)); // 102334155
console.timeEnd("first call");  // ~800ms — computed from scratch

console.time("cached call");
console.log(fastFibonacci(40)); // 102334155
console.timeEnd("cached call"); // ~0ms — returned from cache

The Classic Loop Closure Bug

This trips up nearly every JavaScript developer at least once. The root cause is var’s function scoping: all iterations of the loop share the exact same variable. By the time the setTimeout callbacks run (after the loop finishes), the shared variable holds its final value.

// BUG: var is function-scoped — all callbacks share the same `i`
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3  — NOT 0, 1, 2

Fix 1: use let (creates a new block-scoped binding per iteration — the simplest fix):

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

Fix 2: use an IIFE to capture the value explicitly (useful when you must support older environments without let):

for (var i = 0; i < 3; i++) {
  (function (captured) {
    // `captured` is a new local variable per iteration — not shared
    setTimeout(() => console.log(captured), 100);
  })(i);
}
// Prints: 0, 1, 2

Partial Application with Closures

Partial application means pre-filling some of a function’s arguments and returning a new function that accepts the rest. Closures make this trivial: the pre-filled arguments are captured in the closure and merged with the later arguments when the function is eventually called.

function multiply(a, b) {
  return a * b;
}

function partial(fn, ...presetArgs) {
  return function (...laterArgs) {
    return fn(...presetArgs, ...laterArgs); // presetArgs is closed over here
  };
}

const double = partial(multiply, 2);
const triple = partial(multiply, 3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

// Real-world use: pre-configure a fetch helper with a base URL
const apiGet = partial(fetch, "https://api.example.com");
// apiGet("/users") is equivalent to fetch("https://api.example.com", "/users")

Common Pitfalls

Accidental memory leaks — closures keep their outer scope alive as long as the closure itself is referenced. If a large object is in scope and the closure is never cleaned up (e.g., an event listener that’s never removed), that object can’t be garbage-collected even after it’s no longer needed.

function processLargeData() {
  const hugeArray = new Array(1_000_000).fill(0); // ~8 MB

  // This closure captures hugeArray. If handleClick is never removed,
  // hugeArray stays in memory for the entire lifetime of the page.
  document.addEventListener("click", function handleClick() {
    console.log(hugeArray.length);
  });
}

// Fix: remove event listeners when no longer needed
// document.removeEventListener("click", handleClick);

Fix: remove event listeners when no longer needed, or restructure to avoid closing over data you don’t actually need inside the callback.

Key Takeaways

  • A closure captures references to outer variables, not their values at a point in time — the variable can still change.
  • Each closure call gets its own independent set of closed-over variables.
  • Use let in loops to give each iteration its own binding.
  • Closures enable private state, memoization, partial application, and the module pattern without any special syntax.

Frequently Asked Questions

What is a closure?
A closure is a function that retains access to variables from its enclosing (outer) scope even after that outer function has returned. The inner function 'closes over' the variables it references.
Why does the classic loop closure bug happen with var?
var is function-scoped, so all loop iterations share the same variable. By the time the callbacks run, the loop is done and the variable holds its final value. let is block-scoped, creating a new binding per iteration, which fixes the bug.
Is a closure the same as a higher-order function?
Not exactly. A higher-order function is a function that accepts or returns another function. A closure is a specific capability: a function that captures variables from its surrounding scope. Many higher-order functions return closures, but the terms describe different concepts.