Skip to main content

Variable Scope and Closures in JavaScript

Closures are one of the most important concepts in JavaScript, and also one of the most misunderstood. They are not an advanced feature you opt into. They happen automatically every time you write a function. Understanding closures means understanding how JavaScript decides which variables a function can access, how those variables stay alive, and why certain patterns work the way they do.

This guide takes you from the foundational concept of lexical environments through block scope and function scope, into nested functions and the scope chain, and finally into closures themselves. You will see how closures enable powerful patterns like data privacy, function factories, and memoization. You will also learn about the infamous loop closure problem and how to avoid it.

Lexical Environment: The Theory

Every piece of running JavaScript code has an associated Lexical Environment. This is an internal (hidden) data structure that the engine uses to track variables. Understanding it is the key to understanding everything about scope and closures.

A Lexical Environment consists of two parts:

  1. Environment Record: An object that stores all local variables (and function declarations) as properties. Think of it as a "variable storage" for the current scope.
  2. Reference to the outer Lexical Environment: A pointer to the Lexical Environment of the enclosing (parent) scope. This is what creates the "chain" that allows inner code to access outer variables.

How Variables Are Created

When a script starts running, a global Lexical Environment is created. As the engine encounters variable declarations, they are added to the Environment Record. However, the timing depends on how the variable is declared:

// Step 1: Script starts. Global Lexical Environment is created.
// The engine scans the code and registers:
// - "phrase" exists but is UNINITIALIZED (in the Temporal Dead Zone)
// - "sayHi" is fully available (function declarations are hoisted completely)

// Step 2: Execution reaches this line
let phrase = "Hello"; // Now "phrase" is initialized with "Hello"

// Step 3: phrase is reassigned
phrase = "Goodbye"; // Environment Record updates: phrase = "Goodbye"

function sayHi() {
console.log(phrase);
}

Key rules about variable registration:

  • let and const: The engine knows about them from the start of their block, but they are uninitialized until the declaration line is reached. Accessing them before that line throws a ReferenceError. This period is called the Temporal Dead Zone (TDZ).
  • var: Registered and initialized to undefined from the start of the function (or script). No TDZ.
  • Function declarations: Fully initialized from the start. They can be called before their declaration line appears in the code.
// Function declarations are available immediately
sayHello(); // Works! "Hello"

function sayHello() {
console.log("Hello");
}

// let/const are NOT available before their declaration
// console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 10;

The Outer Reference

Every Lexical Environment (except the global one) has an outer reference pointing to the Lexical Environment where the code was written (not where it is called). This is the word lexical in "Lexical Environment": it is determined by the position in the source code.

let globalVar = "I'm global";

function outer() {
let outerVar = "I'm in outer";

function inner() {
let innerVar = "I'm in inner";
console.log(innerVar); // Found in inner's Environment Record
console.log(outerVar); // Not in inner → follow outer reference → found in outer
console.log(globalVar); // Not in inner → not in outer → follow outer reference → found in global
}

inner();
}

outer();

The chain of outer references is called the scope chain. When JavaScript needs to resolve a variable name, it walks up this chain from the current environment to the global environment. If the variable is not found anywhere, a ReferenceError is thrown.

Block Scope

In modern JavaScript (with let and const), every pair of curly braces {} creates a new Lexical Environment. This is called block scope.

if, for, while Blocks

let x = 10;

if (x > 5) {
let message = "x is greater than 5";
const limit = 100;
console.log(message); // "x is greater than 5"
console.log(limit); // 100
}

// console.log(message); // ReferenceError: message is not defined
// console.log(limit); // ReferenceError: limit is not defined

The variables message and limit exist only inside the if block. Once execution leaves the block, those variables are gone.

for Loop Block Scope

The for loop is especially interesting because let creates a separate binding for each iteration:

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

// console.log(i); // ReferenceError: i is not defined

Each iteration gets its own i. This is critical for closures (as we will see later).

Standalone Blocks

You can create a block scope with just curly braces, without any control structure:

{
let secret = "hidden";
console.log(secret); // "hidden"
}

// console.log(secret); // ReferenceError: secret is not defined

This can be useful for limiting the scope of temporary variables:

{
const temp = computeExpensiveValue();
processResult(temp);
// temp is not needed after this block
}
// temp is gone, no accidental usage later

var Ignores Block Scope

This is a crucial difference. var does not respect block scope. It is scoped to the nearest function (or the global scope):

if (true) {
var leaked = "I escaped the block!";
let contained = "I stay here";
}

console.log(leaked); // "I escaped the block!" (var ignores the block)
// console.log(contained); // ReferenceError (let respects the block)
for (var i = 0; i < 3; i++) {
// ...
}
console.log(i); // 3 (var leaked out of the for loop!)

for (let j = 0; j < 3; j++) {
// ...
}
// console.log(j); // ReferenceError (let stayed in the loop)
warning

This is one of the main reasons var was replaced by let and const. The fact that var leaks out of blocks leads to subtle bugs, especially in loops with closures.

Function Scope

Every function call creates its own Lexical Environment. Variables declared inside a function are not visible outside that function, regardless of whether you use let, const, or var:

function greet(name) {
let greeting = "Hello";
var punctuation = "!";
console.log(`${greeting}, ${name}${punctuation}`);
}

greet("Alice"); // "Hello, Alice!"

// console.log(greeting); // ReferenceError
// console.log(punctuation); // ReferenceError
// console.log(name); // ReferenceError (parameter is also local)

Parameters Are Local Variables

Function parameters behave like local variables declared inside the function:

let value = "outer";

function test(value) {
// This "value" parameter shadows the outer "value"
console.log(value);
}

test("inner"); // "inner"
console.log(value); // "outer" (the outer variable is untouched)

Functions Create a Snapshot of Their Outer Environment

This is the foundation of closures. When a function is created (not called), it stores a reference to the Lexical Environment where it was created. This reference is stored in an internal property called [[Environment]]:

let name = "Alice";

function sayName() {
console.log(name); // Accesses the outer variable
}

name = "Bob";
sayName(); // "Bob" (it reads the CURRENT value, not the value at creation time)

The function does not copy the variable's value. It keeps a live reference to the Lexical Environment, so it always sees the current value.

Nested Functions and the Scope Chain

When functions are nested inside other functions, each inner function has access to the variables of all enclosing functions. This creates a scope chain:

function outermost() {
let a = 1;

function middle() {
let b = 2;

function innermost() {
let c = 3;
console.log(a + b + c); // Can access a, b, and c
}

innermost();
}

middle();
}

outermost(); // 6

The scope chain for innermost looks like this:

innermost Environment Record: { c: 3 }
→ outer reference: middle Environment Record: { b: 2 }
→ outer reference: outermost Environment Record: { a: 1 }
→ outer reference: Global Environment Record

When innermost needs variable a, it:

  1. Checks its own Environment Record: not found.
  2. Follows the outer reference to middle: not found.
  3. Follows the outer reference to outermost: found! a = 1.

Returning Functions from Functions

This is where things get powerful. A function can return an inner function:

function createGreeter(greeting) {
return function(name) {
console.log(`${greeting}, ${name}!`);
};
}

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

sayHello("Alice"); // "Hello, Alice!"
sayHello("Bob"); // "Hello, Bob!"
sayHi("Charlie"); // "Hi, Charlie!"

Even though createGreeter has finished executing, the returned function still has access to the greeting variable. This is a closure.

Closures: What They Are and How They Work

A closure is a function that remembers and has access to variables from its outer lexical scope, even after the outer function has returned. In JavaScript, every function is a closure (because every function stores a reference to its outer Lexical Environment via the [[Environment]] property).

But the term "closure" is most meaningful when a function is used outside of its original scope, and it still accesses variables from that scope.

Step-by-Step Closure Example

Let's trace exactly what happens:

function makeCounter() {
let count = 0; // Local variable

return function() { // Inner function (the closure)
count++;
return count;
};
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Step 1: makeCounter() is called. A new Lexical Environment is created:

makeCounter Environment: { count: 0 }
→ outer: Global Environment

Step 2: The inner function is created. Its [[Environment]] property is set to the current Lexical Environment (the one containing count: 0).

Step 3: makeCounter() returns the inner function. The makeCounter call is done, but its Lexical Environment is not garbage collected because the inner function still references it.

Step 4: counter() is called (the first time). A new Lexical Environment is created for this call:

counter call Environment: { } (empty - no local variables)
→ outer: makeCounter Environment: { count: 0 }
→ outer: Global Environment

The function accesses count, which is not in its own environment. It follows the outer reference and finds count in makeCounter's environment. It increments it to 1 and returns it.

Step 5: counter() is called again. The same makeCounter environment is still alive, now with count: 1. It increments to 2.

Step 6: Same process. count becomes 3.

The key insight: the Lexical Environment of makeCounter persists as long as the inner function exists. The variable count lives on, even though makeCounter has long since returned.

Each Call Creates a Separate Closure

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1 (separate count!)
console.log(counter1()); // 3
console.log(counter2()); // 2 (independent from counter1)

Each call to makeCounter() creates a new Lexical Environment with its own count. The closures are independent.

Closures See Updates, Not Snapshots

A closure does not take a "snapshot" of the variable's value at the time of creation. It maintains a live reference to the variable:

function createTimer() {
let seconds = 0;

const increment = () => {
seconds++;
};

const getTime = () => {
return seconds;
};

return { increment, getTime };
}

const timer = createTimer();
timer.increment();
timer.increment();
timer.increment();
console.log(timer.getTime()); // 3

// Both increment and getTime share the SAME "seconds" variable

Both increment and getTime close over the same seconds variable. Changes made by one are visible to the other.

Practical Uses of Closures

Data Privacy (Encapsulation)

Closures provide a way to create truly private variables in JavaScript. The variables in the outer function are inaccessible from outside, but the returned functions can interact with them:

function createBankAccount(initialBalance) {
let balance = initialBalance; // Private (no way to access directly)

return {
deposit(amount) {
if (amount <= 0) throw new Error("Deposit must be positive");
balance += amount;
return balance;
},

withdraw(amount) {
if (amount <= 0) throw new Error("Withdrawal must be positive");
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
return balance;
},

getBalance() {
return balance;
}
};
}

const account = createBankAccount(100);
console.log(account.getBalance()); // 100
account.deposit(50); // 150
account.withdraw(30); // 120
console.log(account.getBalance()); // 120

// There is NO way to access "balance" directly
// account.balance → undefined
// balance → ReferenceError

There is no way to tamper with balance except through the deposit and withdraw methods, which enforce validation rules. This is true encapsulation.

Function Factories

Closures let you create specialized functions from a general template:

function createMultiplier(factor) {
return function(number) {
return number * factor;
};
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const toPercent = createMultiplier(100);

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

Each returned function "remembers" its own factor. You can create as many specialized multipliers as you need.

A more practical example with URL builders:

function createApiClient(baseUrl) {
return function(endpoint) {
return `${baseUrl}${endpoint}`;
};
}

const api = createApiClient("https://api.example.com");
const staging = createApiClient("https://staging.example.com");

console.log(api("/users")); // "https://api.example.com/users"
console.log(api("/posts")); // "https://api.example.com/posts"
console.log(staging("/users")); // "https://staging.example.com/users"

Memoization (Caching Results)

Closures are perfect for caching because the cache variable is private and persistent:

function memoize(fn) {
const cache = {}; // Private cache, persists across calls

return function(...args) {
const key = JSON.stringify(args);

if (key in cache) {
console.log(`Cache hit for ${key}`);
return cache[key];
}

console.log(`Computing for ${key}`);
const result = fn(...args);
cache[key] = result;
return result;
};
}

function expensiveCalculation(n) {
// Simulate heavy computation
let result = 0;
for (let i = 0; i < n * 1000000; i++) {
result += i;
}
return result;
}

const memoizedCalc = memoize(expensiveCalculation);

console.log(memoizedCalc(10)); // Computing for [10] → result
console.log(memoizedCalc(10)); // Cache hit for [10] → same result, instantly
console.log(memoizedCalc(20)); // Computing for [20] → different result
console.log(memoizedCalc(10)); // Cache hit for [10] → cached

The cache object lives inside the closure. It is invisible to the outside world but persists as long as memoizedCalc exists.

Event Handlers and Callbacks

Closures are everywhere in event-driven programming:

function setupButton(buttonId, message) {
const button = document.getElementById(buttonId);

let clickCount = 0; // Private to this button's handler

button.addEventListener("click", function() {
clickCount++;
console.log(`${message} (clicked ${clickCount} times)`);
});
}

setupButton("btn1", "Hello from button 1");
setupButton("btn2", "Greetings from button 2");

Each button has its own clickCount and message, thanks to closures.

Partial Application

Closures enable partial application, where you "pre-fill" some arguments of a function:

function log(level, timestamp, message) {
console.log(`[${level}] [${timestamp}] ${message}`);
}

function createLogger(level) {
return function(message) {
const timestamp = new Date().toISOString();
log(level, timestamp, message);
};
}

const info = createLogger("INFO");
const error = createLogger("ERROR");

info("Server started"); // [INFO] [2024-01-15T...] Server started
error("Database failed"); // [ERROR] [2024-01-15T...] Database failed

The Loop Closure Problem and Solutions

This is the most famous closure gotcha in JavaScript and a common interview question. Understanding it deeply proves you truly grasp closures.

The Problem with var

for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}

Expected output: 0, 1, 2, 3, 4

Actual output: 5, 5, 5, 5, 5

Why? Because var does not have block scope. There is only one i variable, shared by all iterations and all the callback functions. By the time the setTimeout callbacks execute (after 100ms), the loop has already finished, and i is 5.

Let's trace it:

Iteration 0: Schedule callback. i is currently 0.
Iteration 1: Schedule callback. i is currently 1.
Iteration 2: Schedule callback. i is currently 2.
Iteration 3: Schedule callback. i is currently 3.
Iteration 4: Schedule callback. i is currently 4.
Loop ends: i becomes 5 (loop condition fails).

100ms later: All callbacks execute. They all look up "i" in the same
Lexical Environment, where i is now 5.
Output: 5, 5, 5, 5, 5

All five callbacks close over the same i.

Solution 1: Use let (The Modern Fix)

for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// Output: 0, 1, 2, 3, 4

This works because let creates a new binding for i in each iteration. Each callback closes over its own copy of i.

Iteration 0: New Lexical Environment with { i: 0 }. Callback closes over it.
Iteration 1: New Lexical Environment with { i: 1 }. Callback closes over it.
Iteration 2: New Lexical Environment with { i: 2 }. Callback closes over it.
...

This is the recommended solution and the one you should always use.

Solution 2: IIFE (The Legacy Pattern)

Before let existed, developers used an Immediately Invoked Function Expression to create a new scope for each iteration:

for (var i = 0; i < 5; i++) {
(function(j) {
// "j" is a NEW variable in each IIFE call
setTimeout(function() {
console.log(j);
}, 100);
})(i); // Pass the current "i" as argument "j"
}
// Output: 0, 1, 2, 3, 4

The IIFE creates a new function scope for each iteration. The parameter j captures the value of i at that moment. Each callback closes over its own j.

Solution 3: Separate Function

You can also extract the callback creation into a separate function:

function createCallback(value) {
return function() {
console.log(value);
};
}

for (var i = 0; i < 5; i++) {
setTimeout(createCallback(i), 100);
}
// Output: 0, 1, 2, 3, 4

Same principle: createCallback(i) captures the current value of i in its value parameter.

Visualizing the Difference

WITH var (one shared "i"):
┌─────────────────────────────────┐
│ Single Lexical Environment │
│ i: 5 (final value) │
│ │
│ callback0 ──────────┐ │
│ callback1 ──────────┤ │
│ callback2 ──────────┼──→ i = 5 │
│ callback3 ──────────┤ │
│ callback4 ──────────┘ │
└─────────────────────────────────┘

WITH let (separate "i" per iteration):
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ i: 0 │ │ i: 1 │ │ i: 2 │ │ i: 3 │ │ i: 4 │
│ │ │ │ │ │ │ │ │ │
│ callback0│ │ callback1│ │ callback2│ │ callback3│ │ callback4│
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
tip

The fix is simple: always use let in for loops. This single habit eliminates the most common closure bug in JavaScript. If you encounter old code using var in loops with callbacks, now you understand why it misbehaves and how to fix it.

The Problem Extends Beyond setTimeout

The same issue appears with any callback that executes later, including event listeners:

// BROKEN with var
for (var i = 0; i < 5; i++) {
document.getElementById(`btn-${i}`).addEventListener("click", function() {
console.log(`Button ${i} clicked`); // Always logs "Button 5 clicked"
});
}

// FIXED with let
for (let i = 0; i < 5; i++) {
document.getElementById(`btn-${i}`).addEventListener("click", function() {
console.log(`Button ${i} clicked`); // Correctly logs "Button 0", "Button 1", etc.
});
}

Closures and Memory: When Are Variables Garbage Collected?

Since closures keep their outer Lexical Environment alive, you might wonder: does this cause memory leaks? The answer is nuanced.

How Long Does a Closure Keep Variables Alive?

A Lexical Environment is garbage collected when no function still references it. As long as a closure exists that could access variables from that environment, the environment stays in memory:

function createHeavyObject() {
const bigArray = new Array(1000000).fill("data"); // ~8MB of data

return function() {
return bigArray.length;
};
}

const getLength = createHeavyObject();
// bigArray is still in memory because getLength could access it

console.log(getLength()); // 1000000

// If we remove the reference to getLength:
// getLength = null;
// Now bigArray can be garbage collected

Releasing Closures

When you no longer need a closure, remove all references to it so the garbage collector can reclaim the memory:

let heavyClosure = createHeavyObject();

// ... use it ...

// When done, release the reference
heavyClosure = null; // Now the closure and its captured variables can be GC'd

Engine Optimizations

Modern JavaScript engines (V8, SpiderMonkey) are smart about closures. If a closure does not actually use a variable from its outer scope, the engine may exclude that variable from the closure's retained environment:

function create() {
let unused = new Array(1000000).fill("x"); // Large data
let used = "hello";

return function() {
return used; // Only "used" is referenced
};
}

const fn = create();
// V8 may optimize away "unused" since the inner function never accesses it
// However, this is an engine optimization, not a guarantee
info

You should not rely on engine optimizations for memory management. If you are creating closures that capture large data, be intentional about what variables are in scope. You can sometimes restructure your code to avoid capturing unnecessary data.

Accidental Closure Over Large Data

Be aware of accidentally keeping large objects alive:

// Potential memory issue
function processData() {
const hugeDataset = fetchHugeDataset(); // Imagine this is very large

const summary = computeSummary(hugeDataset);

// This closure captures the entire scope, including hugeDataset
return function getSummary() {
return summary;
};
}

// Better: don't let hugeDataset be in scope
function processDataBetter() {
const summary = computeSummaryFromSource(); // Process and discard

return function getSummary() {
return summary; // Only captures "summary", not the raw data
};
}

Closures in Event Handlers and Timers

A common source of memory issues is forgetting to remove event listeners or clear intervals that close over objects:

function setupComponent(element) {
const data = loadExpensiveData();

const handler = function() {
console.log(data);
};

element.addEventListener("click", handler);

// Return a cleanup function
return function cleanup() {
element.removeEventListener("click", handler);
// Now "data" can be garbage collected
};
}

const cleanup = setupComponent(document.getElementById("myElement"));

// When the component is removed from the page:
cleanup(); // Remove handler, allow GC

Debugging Closures

When debugging, you can inspect closures in Chrome DevTools:

  1. Set a breakpoint inside the inner function
  2. Look at the Scope panel on the right
  3. You will see sections labeled Local, Closure, and Global
  4. The Closure section shows exactly which variables are captured from the outer scope and their current values
function outer() {
let x = 10;
let y = 20;

return function inner() {
debugger; // Set breakpoint here
return x + y;
};
}

const fn = outer();
fn(); // When this hits the debugger, check the Scope panel

In the Scope panel, you will see:

Local: (empty or any inner locals)
Closure (outer): { x: 10, y: 20 }
Global: { fn: ƒ, outer: ƒ, ... }

A Mental Model for Closures

If you want a single mental model to remember closures, think of it this way:

Every function carries an invisible backpack. When the function is created, JavaScript puts into the backpack all the variables from the surrounding scope that the function references. The function carries this backpack wherever it goes, even if it is returned from its original scope, passed as a callback, or stored in a variable. The variables in the backpack are alive and mutable. They are not copies; they are the real variables.

function makeAdder(x) {
// The returned function's "backpack" contains: { x: <whatever was passed> }
return function(y) {
return x + y; // "x" comes from the backpack
};
}

const add5 = makeAdder(5); // backpack: { x: 5 }
const add10 = makeAdder(10); // backpack: { x: 10 }

console.log(add5(3)); // 8 (reaches into backpack, gets x=5, adds 3)
console.log(add10(3)); // 13 (reaches into backpack, gets x=10, adds 3)

Common Mistakes Summary

Mistake 1: Expecting Closures to Capture Values, Not References

let name = "Alice";

function greet() {
console.log(`Hello, ${name}`);
}

name = "Bob";
greet(); // "Hello, Bob" (NOT "Hello, Alice"!)

The closure has a reference to the variable, not a snapshot of its value. If the variable changes, the closure sees the new value.

Mistake 2: The var Loop Problem

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

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

Mistake 3: Not Realizing All Functions Are Closures

Even simple functions are closures if they access variables from an outer scope:

const API_URL = "https://api.example.com";

// This function is a closure: it closes over API_URL
function fetchUsers() {
return fetch(`${API_URL}/users`);
}

Mistake 4: Unnecessary Closures Causing Memory Issues

// WASTEFUL: Creates a new function (and closure) on every call
function createHandlers(elements) {
for (let el of elements) {
el.addEventListener("click", function() {
console.log("clicked"); // Does not use any outer variable!
});
}
}

// BETTER: Share a single handler function
function handleClick() {
console.log("clicked");
}

function createHandlers(elements) {
for (let el of elements) {
el.addEventListener("click", handleClick);
}
}

If the callback does not need to capture any variables, there is no reason to create a new function each time.

Summary

ConceptKey Point
Lexical EnvironmentInternal structure that stores variables and has a reference to the outer scope
Block scopelet and const are scoped to the nearest {} block
Function scopeAll declarations (let, const, var) are scoped to their containing function
Scope chainThe chain of outer references from inner to global scope
ClosureA function that retains access to its outer scope variables, even after the outer function returns
Data privacyUse closures to create truly private variables
Function factoriesUse closures to create specialized functions from templates
MemoizationUse closures to cache computation results
Loop closure bugvar in loops creates one shared variable; let creates one per iteration
MemoryClosures keep their outer environment alive; release references when done

Closures are not a trick or an advanced feature. They are a fundamental part of how JavaScript works. Every callback, every event handler, every factory function, and every module relies on closures. Once you internalize the concept that functions carry their birth environment with them wherever they go, a huge number of JavaScript patterns suddenly make perfect sense.