Skip to content
C Codeloom
JavaScript

JavaScript Closures Explained with Real Examples

A practical guide to JavaScript closures, lexical scope, the classic loop bug, and the patterns that make closures genuinely useful in production code.

·7 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • What a closure actually is in terms of lexical scope
  • Why var inside a for loop creates the classic counter bug
  • How to use closures to implement private state
  • How to build a tiny memoization helper with closures
  • When closures cause memory issues and how to avoid them

Prerequisites

Closures are one of those topics that sound mystical the first time you hear about them and then become completely obvious once they click. The short version: a closure is a function that remembers the variables from the place where it was defined, even after that place is gone. That is it. The rest of this article is about why that simple idea matters in real code.

Lexical scope is the foundation

JavaScript uses lexical scoping, which means a function’s scope is determined by where it is written, not by where it is called. The function carries its surrounding scope with it like a backpack. Every time you create a function, JavaScript attaches a reference to the variable environment in which that function was created.

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

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

  return inner;
}

const fn = outer();
fn(); // "hello from outer"

By the time fn() runs, outer has already returned. Its local variables would normally be garbage collected. But inner still references message, so the engine keeps that variable alive for as long as inner is reachable. That preserved environment is the closure.

A useful mental model

Stop thinking of closures as a special feature you turn on. Every function in JavaScript is a closure. The interesting question is which outer variables it actually uses. If a function references no outer variables, the closure exists but is empty. If it references three, those three are kept alive.

This is why the closure pattern shows up so naturally in callbacks, event handlers, and any code involving promises or async/await. The callback you pass in needs to remember what was around it when it was created.

The classic counter pitfall

Here is the bug that every JavaScript developer eventually meets.

const handlers = [];

for (var i = 0; i < 3; i++) {
  handlers.push(function () {
    console.log(i);
  });
}

handlers[0](); // 3
handlers[1](); // 3
handlers[2](); // 3

You expected 0, 1, 2. You got three threes. The reason is that var is function-scoped, not block-scoped. There is exactly one i shared by every iteration. By the time any handler runs, the loop has finished and i is 3. Every closure points to the same variable.

The fix is to give each iteration its own binding. The easiest way is to use let:

const handlers = [];

for (let i = 0; i < 3; i++) {
  handlers.push(function () {
    console.log(i);
  });
}

handlers[0](); // 0
handlers[1](); // 1
handlers[2](); // 2

With let, the language creates a fresh binding for i on every iteration. Each closure captures its own copy. Before let existed, people used an immediately invoked function expression to create that fresh scope manually:

for (var i = 0; i < 3; i++) {
  (function (j) {
    handlers.push(function () {
      console.log(j);
    });
  })(i);
}

You rarely need that today, but it is still a clean illustration of what closures are doing under the hood.

Private state without classes

Closures give you encapsulation for free. You do not need a class or a #private field to hide state. You just keep the variable inside the function and only expose the operations you want.

function createCounter(start = 0) {
  let count = start;

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

const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.value();     // 2
// counter.count is undefined - truly private

Nothing outside createCounter can read or write count directly. The only way in is through the returned methods. This is the most honest implementation of private state in the language, and it predates class field syntax by decades.

Memoization in five lines

A memoizer caches the result of an expensive function so repeated calls with the same input return instantly. Closures make this trivial.

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

const slowSquare = (n) => {
  for (let i = 0; i < 1e7; i++);
  return n * n;
};

const fastSquare = memoize(slowSquare);
fastSquare(5); // slow
fastSquare(5); // instant

The returned function closes over cache. Every call to memoize produces a fresh cache, so different memoized functions never share state.

The module pattern

Before ES modules landed, library authors leaned heavily on closures to ship code that exposed a clean API and hid everything else.

const auth = (function () {
  let token = null;

  function setToken(value) { token = value; }
  function getHeader() { return token ? `Bearer ${token}` : null; }

  return { setToken, getHeader };
})();

auth.setToken("abc123");
auth.getHeader(); // "Bearer abc123"

You can still use this pattern today inside a single file when you want a namespace without exporting a class. It composes well with plain JavaScript objects.

When closures bite back

Closures keep references alive. That is the whole point. But it also means they can keep large objects in memory longer than you intended.

function attachListener(element) {
  const hugePayload = new Array(1_000_000).fill("data");

  element.addEventListener("click", () => {
    console.log("clicked");
    // hugePayload is captured even though we never use it
  });
}

Even though the handler never touches hugePayload, some engines will keep the entire variable environment alive. The mechanical fix is to only capture what you actually need:

function attachListener(element) {
  const hugePayload = new Array(1_000_000).fill("data");
  const summary = hugePayload.length;

  element.addEventListener("click", () => {
    console.log("clicked", summary);
  });
}

If you cache functions or store handlers in long-lived structures, audit what they close over.

Closures and async code

Every callback you pass to setTimeout, fetch, or an event listener is a closure. This is what lets you write code that reads naturally even though it executes later.

function greetIn(ms, name) {
  setTimeout(() => {
    console.log(`Hello, ${name}`);
  }, ms);
}

greetIn(1000, "Ada");

The arrow function captures name. When the timer fires a second later, the variable is still there. This is also why you can build retry logic, debouncers, and throttlers in a handful of lines.

Wrap up

Closures are not a trick. They are the natural consequence of giving functions access to their surrounding scope and keeping that scope alive as long as the function is reachable. Once you internalize that, the patterns in this article stop looking like clever tricks and start looking like the obvious way to write the code. Use closures for private state, for caching, for partial application, and for any callback that needs to remember context. Just be aware of what you are capturing, and your code will stay both expressive and lean.