Skip to main content
JavaScript · lesson 14 of 24
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.

By SkillByExample · Updated

A closure isn’t a keyword or special syntax — it’s what happens when a function keeps using variables from the scope it was written in. Every example below is a complete file you can run with node.

Lexical Scoping

A function can read the variables of the function it is written inside. Where it is called from doesn’t matter.

const message = "global message";

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

  function inner() {
    console.log(message);
  }

  return inner;
}

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

elsewhere();
$ node lexical.js
hello from outer

inner is called from inside elsewhere, which has its own message, yet it prints the one from outer. JavaScript resolves names by where the function sits in the source (lexical scope), not by the call stack at runtime (dynamic scope). That one rule is the whole mechanism behind closures.

What Makes a Closure

When an inner function outlives the call that created it, the variables it references stay alive with it.

function makeGreeter(greeting) {
  return function (name) {
    return `${greeting}, ${name}!`;
  };
}

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

console.log(sayHello("Alice"));
console.log(sayHi("Bob"));
console.log(sayHello.length, typeof sayHello);
$ node greeter.js
Hello, Alice!
Hi, Bob!
1 function

makeGreeter has returned by the time either greeter runs, so greeting “should” be gone — but each returned function carries its own greeting binding. The two calls produced two independent closures; neither can see the other’s value. length is 1 because the returned function declares only name; the captured variable is not a parameter.

Closures Capture Variables, Not Values

A common assumption is that a closure takes a snapshot. It doesn’t.

function makeCounterPair() {
  let count = 0;
  const inc = () => { count += 1; };
  const read = () => count;
  return { inc, read };
}

const { inc, read } = makeCounterPair();
console.log(read());
inc();
inc();
console.log(read());

let label = "before";
const show = () => console.log(label);
label = "after";
show();
$ node live.js
0
2
after

inc and read share the same count binding, so a change made through one is visible through the other. And show prints after even though label was "before" when the arrow function was created — it holds a reference to the variable and reads its current value when it runs.

Private State with a Counter

The captured variable is reachable only through the functions that close over it — no property on the returned object exposes it.

function createCounter(initialValue = 0) {
  let count = initialValue;
  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());
console.log(counter.count);
counter.count = 999;
console.log(counter.value());
console.log(Object.keys(counter));
$ node counter.js
11
undefined
11
[ 'increment', 'decrement', 'reset', 'value', 'count' ]

counter.count is undefined, and assigning counter.count = 999 just adds an unrelated property (it shows up at the end of Object.keys) — value() still returns 11. This is the same encapsulation guarantee a class gets from #count, built from nothing but function scope.

The Module Pattern

An IIFE (Immediately Invoked Function Expression) runs once and returns an object whose methods close over its private state — the pre-ES-modules way to build a module, still common in bundled code.

const userStore = (() => {
  const users = new Map();
  let nextId = 1;

  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");
userStore.add("Bob", "bob@example.com");
console.log(userStore.get(id));
console.log(userStore.get(42));
console.log(userStore.count());
console.log(userStore.users);
$ node store.js
{ id: 1, name: 'Alice', email: 'alice@example.com' }
null
2
undefined

There is exactly one users map and one nextId, because the IIFE ran exactly once. The public surface is the three methods; userStore.users is undefined. Unknown ids return null via ?? rather than undefined, so callers can distinguish “no user” from a typo in a property name.

Memoization

A memoizing wrapper closes over a cache that persists between calls.

function memoize(fn) {
  const cache = new Map();
  return function (n) {
    if (cache.has(n)) return cache.get(n);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
}

let calls = 0;
function slowSquare(n) { calls += 1; return n * n; }

const fastSquare = memoize(slowSquare);
console.log(fastSquare(9), fastSquare(9), fastSquare(4));
console.log("underlying calls:", calls);

let fibCalls = 0;
function fib(n) { fibCalls += 1; return n <= 1 ? n : fib(n - 1) + fib(n - 2); }
const memoFib = memoize(fib);
console.log(memoFib(25), "calls:", fibCalls);
console.log(memoFib(25), "calls:", fibCalls);
$ node memo.js
81 81 16
underlying calls: 2
75025 calls: 242785
75025 calls: 242785

slowSquare ran twice for three calls — the second fastSquare(9) was a cache hit. The Fibonacci lines show the trap: memoFib(25) still made 242,785 calls to fib, because fib’s recursive calls go to fib, not to the memoized wrapper. Only the outer call is cached; the second memoFib(25) adds zero calls. To memoize the recursion itself, the recursive calls must go through the cache (see Practice 3).

The Classic Loop Closure Bug

var is function-scoped, so every iteration of a for loop shares one variable. Callbacks that run after the loop all see its final value.

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

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 0);
}

for (var k = 0; k < 3; k++) {
  ((captured) => {
    setTimeout(() => console.log("iife:", captured), 0);
  })(k);
}
$ node loop.js
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2
iife: 0
iife: 1
iife: 2

The var loop prints 3 three times: by the time the timers fire, the one shared i has been incremented to 3. let creates a fresh binding per iteration, so each callback closes over a different j. The IIFE fix does the same thing by hand — captured is a new parameter per call — and is what you’d see in code written before let existed.

Partial Application

Pre-filling some arguments and returning a function that takes the rest is a closure over the pre-filled ones.

function partial(fn, ...presetArgs) {
  return function (...laterArgs) {
    return fn(...presetArgs, ...laterArgs);
  };
}

const multiply = (a, b) => a * b;
const double = partial(multiply, 2);
const triple = partial(multiply, 3);
console.log(double(5), triple(5));

const makeUrl = (base, path, query) => `${base}${path}${query ? "?" + query : ""}`;
const api = partial(makeUrl, "https://api.example.com");
console.log(api("/users"));
console.log(api("/users", "page=2"));
$ node partial.js
10 15
https://api.example.com/users
https://api.example.com/users?page=2

presetArgs lives in each returned function’s closure, and is spread in before the later arguments — so api("/users") fills path, not base. Argument order matters: partial application only works cleanly on the leading parameters, which is why many functional libraries put the “configuration” arguments first.

Pitfall: Closures Keep Large Objects Alive

A closure keeps its whole referenced scope reachable for as long as the closure itself is reachable — including data the callback barely needs.

const listeners = [];

function register(size) {
  const hugeArray = new Array(size).fill(0);
  listeners.push(() => hugeArray.length);
}

function registerLean(size) {
  const len = new Array(size).fill(0).length;
  listeners.push(() => len);
}

const mb = () => Math.round(process.memoryUsage().heapUsed / 1e6);
const base = mb();
for (let i = 0; i < 20; i++) register(1_000_000);
globalThis.gc?.();
console.log("capturing the array:", mb() - base, "MB retained");

listeners.length = 0;
globalThis.gc?.();
const base2 = mb();
for (let i = 0; i < 20; i++) registerLean(1_000_000);
globalThis.gc?.();
console.log("capturing only the length:", mb() - base2, "MB retained");
$ node --expose-gc leak.js
capturing the array: 160 MB retained
capturing only the length: 0 MB retained

Twenty listeners that close over a million-element array keep every array alive — the garbage collector can’t free what a reachable closure references. The lean version computes the one number it needs and closes over that, so the arrays become garbage immediately. In a browser the same thing happens with an addEventListener callback that is never removed. (--expose-gc just lets the example force a collection so the numbers are stable; you don’t need it in real code.)

Key Takeaways

  • A function resolves names where it is written (lexical scope), not where it’s called.
  • A closure captures variables, not snapshots of their values — and closures created together share them.
  • Each call to an outer function creates a fresh, independent set of captured variables.
  • Use let in loops to give each iteration its own binding.
  • A closure keeps everything it references alive; capture only what the callback needs.

Practice

1. Give createCounter a step option so increment() adds it instead of 1.
function createCounter(initialValue = 0, step = 1) {
  let count = initialValue;
  return {
    increment() { count += step; },
    value() { return count; },
  };
}

const c = createCounter(0, 5);
c.increment();
c.increment();
console.log(c.value());
$ node p1.js
10

step is a parameter of the outer function, so it is captured exactly like count — no need to store it anywhere else.

2. Write once(fn): the wrapped function runs fn the first time only and returns that first result forever after.
function once(fn) {
  let done = false;
  let result;
  return function (...args) {
    if (!done) {
      done = true;
      result = fn(...args);
    }
    return result;
  };
}

const init = once((name) => { console.log("initialising", name); return 42; });
console.log(init("db"));
console.log(init("cache"));
$ node p2.js
initialising db
42
42

Both done and result live in the closure. The second call’s argument is ignored — it returns the cached 42 without logging.

3. Fix the memoized Fibonacci so the recursive calls also hit the cache, and print how many times the body ran.
let bodyRuns = 0;
const cache = new Map();

function fib(n) {
  if (cache.has(n)) return cache.get(n);
  bodyRuns += 1;
  const result = n <= 1 ? n : fib(n - 1) + fib(n - 2);
  cache.set(n, result);
  return result;
}

console.log(fib(25), "body runs:", bodyRuns);
console.log(fib(80), "body runs:", bodyRuns);
$ node p3.js
75025 body runs: 26
23416728348467684 body runs: 81

The cache sits in the enclosing scope and the recursion goes through fib itself, so each n is computed once — 26 runs for fib(25), then just 55 more for fib(80) (n = 26…80). Look closely at that result, though: the true 80th Fibonacci number ends in …685. It is above Number.MAX_SAFE_INTEGER, so a plain number silently rounds it; switch to BigInt (0n, 1n) when the values get this big.

4. Build makeAccount(balance) with deposit, withdraw (refusing overdrafts) and balance(), with no way to set the balance directly.
function makeAccount(balance) {
  return {
    deposit(amount) { balance += amount; return balance; },
    withdraw(amount) {
      if (amount > balance) return `refused: only ${balance} available`;
      balance -= amount;
      return balance;
    },
    balance: () => balance,
  };
}

const acct = makeAccount(100);
console.log(acct.deposit(50));
console.log(acct.withdraw(500));
console.log(acct.withdraw(30));
console.log(acct.balance());
$ node p4.js
150
refused: only 150 available
120
120

The parameter balance itself is the private state — parameters are local variables, so the returned methods close over it just like a let.

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.