Knowledge Base/concepts/JavaScript Closures

JavaScript Closures

JavaScript closures are what happen when a function keeps access to variables from the scope where it was created. The phrase sounds abstract, but the behavior is practical: callbacks remember values, factory functions keep configuration, and private state can live outside object properties. The catch is that closures remember variables, not frozen snapshots of values.

That one sentence explains most closure bugs.

Start with lexical scope

JavaScript uses lexical scope. A function can access variables from where it is written, not from wherever it is called.

const message = "global";

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

  function inner() {
    return message;
  }

  return inner();
}

console.log(outer()); // "outer"

inner sees the message from outer because that is where inner is defined. The caller does not get to swap in a different scope.

This scope chain is created by the structure of the code. The engine can read that structure before anything runs.

A closure keeps the outer scope alive

Here is the classic counter:

function makeCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const counter = makeCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

makeCounter has already returned, but count still exists. The returned increment function keeps access to the scope where it was created. That preserved access is the closure.

Each call to makeCounter creates a separate scope:

const a = makeCounter();
const b = makeCounter();

a(); // 1
a(); // 2
b(); // 1

a and b do not share count. They were created by different calls.

Closures capture variables, not values

This is the part that trips people:

let name = "Ada";

const greet = () => `Hello, ${name}`;

name = "Grace";

console.log(greet()); // "Hello, Grace"

The function did not freeze the value "Ada". It kept access to the variable name. When the variable changed, the closure saw the new value.

That is also why old var loop bugs happen:

const callbacks = [];

for (var i = 0; i < 3; i++) {
  callbacks.push(function () {
    return i;
  });
}

console.log(callbacks[0]()); // 3
console.log(callbacks[1]()); // 3
console.log(callbacks[2]()); // 3

All three functions close over the same i. By the time they run, the loop has finished and i is 3.

Use let to get a new binding per iteration:

const callbacks = [];

for (let i = 0; i < 3; i++) {
  callbacks.push(function () {
    return i;
  });
}

console.log(callbacks[0]()); // 0
console.log(callbacks[1]()); // 1
console.log(callbacks[2]()); // 2

The for...of loop has the same useful per-iteration binding behavior when you use const or let.

Private state with closures

Closures let you keep state out of public object properties:

function createStack() {
  const items = [];

  return {
    push(item) {
      items.push(item);
    },
    pop() {
      return items.pop();
    },
    peek() {
      return items[items.length - 1];
    },
    get size() {
      return items.length;
    },
  };
}

const stack = createStack();

stack.push("first");
stack.push("second");

console.log(stack.peek()); // "second"
console.log(stack.size); // 2
console.log(stack.items); // undefined

items is not on stack. It lives in the closure shared by the returned methods.

This is useful, but it has a cost: those methods are created for each stack instance. Class prototype methods are shared across instances, but they cannot close over constructor-local variables in the same way. Pick the tradeoff deliberately.

Function factories

Returning a function is a common closure pattern:

function makeDiscount(percent) {
  return function applyDiscount(price) {
    return price * (1 - percent);
  };
}

const studentDiscount = makeDiscount(0.2);
const saleDiscount = makeDiscount(0.5);

console.log(studentDiscount(100)); // 80
console.log(saleDiscount(100)); // 50

Each returned function remembers its own percent.

This pattern is a core part of Higher-Order Functions in JavaScript. You configure behavior once, then reuse the returned function many times.

Closures in event handlers

Event handlers often close over local variables:

function attachCounter(button) {
  let clicks = 0;

  button.addEventListener("click", () => {
    clicks += 1;
    button.textContent = `Clicked ${clicks} times`;
  });
}

The event handler keeps clicks alive after attachCounter returns. Every click updates the same variable.

Closures also help with cleanup:

function listen(element, eventName, handler) {
  element.addEventListener(eventName, handler);

  return function stopListening() {
    element.removeEventListener(eventName, handler);
  };
}

const stop = listen(button, "click", handleClick);

stop();

The cleanup function remembers the exact element, event name, and handler needed to remove the listener.

Closures and async code

Async callbacks run later, but they still see the scope where they were created:

function loadUser(id) {
  const startedAt = Date.now();

  return fetch(`/api/users/${id}`)
    .then((response) => response.json())
    .then((user) => {
      const elapsed = Date.now() - startedAt;
      console.log(`Loaded ${id} in ${elapsed}ms`);
      return user;
    });
}

The final callback closes over id and startedAt. loadUser has returned by the time the response arrives, but those variables remain available.

The same idea applies with async and await:

async function processQueue(items) {
  const results = [];

  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }

  return results;
}

The function scope survives across each await. You do not lose results just because the function pauses.

Memoization example

Memoization stores previous results so repeated calls can return quickly:

function memoize(fn) {
  const cache = new Map();

  return function memoized(input) {
    if (cache.has(input)) {
      return cache.get(input);
    }

    const result = fn(input);
    cache.set(input, result);
    return result;
  };
}

const double = memoize((n) => {
  console.log("computing");
  return n * 2;
});

double(10); // logs "computing", returns 20
double(10); // returns 20 from cache

The returned function closes over cache. The cache persists between calls without being global.

For multiple arguments or object arguments, cache keys need more care. JSON.stringify(args) works for some demos and fails for functions, circular objects, key order issues, and values that do not serialize cleanly.

Memory retention

A closure keeps the variables it needs alive. That is useful, but not free.

function makeReader() {
  const large = new Array(100000).fill("data");

  return function readFirst() {
    return large[0];
  };
}

const reader = makeReader();

As long as reader is reachable, large stays reachable too. Modern engines can optimize some cases, but the safe mental model is: if a closure can still access it, it cannot be collected.

Avoid closing over large objects accidentally in long-lived callbacks, caches, and event listeners. Clean up listeners when they are no longer needed, and keep closed-over state as small as practical.

Common closure bugs

Mutating a closed-over object affects future calls:

function makeFormatter(options) {
  return function format(value) {
    return options.uppercase ? value.toUpperCase() : value;
  };
}

const options = { uppercase: false };
const format = makeFormatter(options);

options.uppercase = true;

console.log(format("ada")); // "ADA"

If you wanted a snapshot, copy the options:

function makeFormatter(options) {
  const config = { ...options };

  return function format(value) {
    return config.uppercase ? value.toUpperCase() : value;
  };
}

Cleaning up shared state too early can also break other closure methods:

function setup() {
  let data = { value: 42 };

  return {
    getValue() {
      return data.value;
    },
    cleanup() {
      data = null;
    },
  };
}

After cleanup, getValue will fail because both methods share the same data binding.

Key Takeaways

  • A closure is a function keeping access to variables from its defining scope.
  • Closures keep variables alive after the outer function returns.
  • Closures capture variables, not frozen value snapshots.
  • let and const create safer per-iteration bindings than var.
  • Closures are useful for private state, callbacks, factories, memoization, and cleanup functions.
  • Long-lived closures can retain memory, so avoid capturing large data by accident.
Share this article:
javascriptfunctionsscope