Understanding JavaScript Closures and Execution Context

What Is a Closure?

A closure is the combination of a function and the lexical environment in which it was declared. In simple terms, it is the ability of a function to remember and access variables from the environment where it was created. When a function references variables from an outer scope, it can still access those variables even after that scope has ended.

Closures are one of the most fundamental concepts in JavaScript, used extensively in data encapsulation, factory patterns, event handlers, React hooks, and more.

Scope Chain and Lexical Environment

When the JavaScript engine executes code, it creates an Execution Context. Each execution context contains a Lexical Environment that stores variables, and through references to outer environments, forms the scope chain.

const globalVar = "global";

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

  function inner() {
    const innerVar = "inner";
    // Scope chain: inner -> outer -> global
    console.log(innerVar);   // "inner"   (own scope)
    console.log(outerVar);   // "outer"   (outer function scope)
    console.log(globalVar);  // "global"  (global scope)
  }

  inner();
}

outer();
// inner
// outer
// global

When looking up a variable, the search starts from the current scope and moves up to parent scopes. This chain is the scope chain.

Scope TypeCreated WhenExample
Global scopeProgram startconst globalVar
Function scopeFunction callvar, function declarations
Block scopeEntering a block {}let, const declarations

How Closures Work

A closure remembers the scope where the function was defined, not where it is called.

function createCounter(initial = 0) {
  let count = initial;  // This variable is preserved by the closure

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

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

// Direct access to count is impossible — encapsulation
// console.log(count);  // ReferenceError

Even after createCounter() finishes executing, the count variable is not garbage collected because the returned methods still reference it. This is the core of closures.

Practical Pattern 1 — Data Encapsulation

Closures are used to implement private variables. They create state that cannot be directly accessed from outside.

function createWallet(ownerName, initialBalance = 0) {
  // Private variables — cannot be accessed directly from outside
  let balance = initialBalance;
  const transactions = [];

  function recordTransaction(type, amount) {
    transactions.push({
      type,
      amount,
      balance,
      timestamp: new Date().toISOString(),
    });
  }

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Only positive amounts can be deposited");
      balance += amount;
      recordTransaction("deposit", amount);
      return `Deposited ${amount}. Balance: ${balance}`;
    },
    withdraw(amount) {
      if (amount <= 0) throw new Error("Only positive amounts can be withdrawn");
      if (amount > balance) throw new Error("Insufficient balance");
      balance -= amount;
      recordTransaction("withdrawal", amount);
      return `Withdrew ${amount}. Balance: ${balance}`;
    },
    getBalance() {
      return balance;
    },
    getHistory() {
      return [...transactions];  // Return a copy
    },
    getOwner() {
      return ownerName;
    },
  };
}

const wallet = createWallet("John", 10000);
console.log(wallet.deposit(5000));
// Deposited 5000. Balance: 15000
console.log(wallet.withdraw(3000));
// Withdrew 3000. Balance: 12000
console.log(wallet.getBalance());
// 12000

// Direct access to balance and transactions is impossible
// console.log(wallet.balance);  // undefined

Practical Pattern 2 — Function Factory

Closures can create specialized functions that remember configuration values.

// Rate limiter function factory
function createRateLimiter(maxCalls, windowMs) {
  const calls = [];

  return function rateLimiter() {
    const now = Date.now();
    // Remove calls outside the time window
    while (calls.length > 0 && calls[0] <= now - windowMs) {
      calls.shift();
    }
    if (calls.length >= maxCalls) {
      return { allowed: false, retryAfter: calls[0] + windowMs - now };
    }
    calls.push(now);
    return { allowed: true, remaining: maxCalls - calls.length };
  };
}

// A limiter that allows 3 calls per second
const limiter = createRateLimiter(3, 1000);

console.log(limiter());  // { allowed: true, remaining: 2 }
console.log(limiter());  // { allowed: true, remaining: 1 }
console.log(limiter());  // { allowed: true, remaining: 0 }
console.log(limiter());  // { allowed: false, retryAfter: ... }

Common Mistake — Closures in Loops

Using var with loops produces unexpected behavior. This is the most famous closure pitfall.

// Wrong: var has function scope
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);  // All print 3 (the value of i after the loop ends)
  }, 100);
}
// 3, 3, 3

// Correct solution 1: let has block scope (new binding per iteration)
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);  // Correctly prints 0, 1, 2
  }, 100);
}

// Correct solution 2: IIFE to create a closure (pre-ES6 approach)
for (var i = 0; i < 3; i++) {
  (function (captured) {
    setTimeout(() => {
      console.log(captured);  // 0, 1, 2
    }, 100);
  })(i);
}

var has function scope, so a single i is shared across the entire loop. let has block scope, creating an independent i for each iteration.

Memory Considerations

Variables referenced by closures are not garbage collected, so be mindful of memory leaks.

function createHeavyClosure() {
  // Large data referenced by the closure
  const hugeArray = new Array(1000000).fill("data");

  return function process() {
    // The entire hugeArray remains in memory
    return hugeArray.length;
  };
}

// Improved: only include necessary data in the closure
function createLightClosure() {
  const hugeArray = new Array(1000000).fill("data");
  const length = hugeArray.length;  // Extract only the needed value

  // hugeArray is no longer referenced, eligible for GC
  return function process() {
    return length;
  };
}

Summary

Closures are a core mechanism of JavaScript, and understanding them correctly enables powerful patterns.

  • Definition: The combination of a function and the lexical environment in which it was declared
  • Scope chain: The chain that searches for variables from the current scope up to parent scopes
  • Encapsulation: Implementing private variables to restrict external access
  • Factory: Creating specialized functions that remember configuration values
  • Caveats: Be aware of the var + loop pitfall and memory leaks
  • Modern usage: Widely used in React hooks, event handlers, currying, memoization, and more

Was this article helpful?