Back to all articles
JavaScript

Scope & Closures

Learn JavaScript scope and closures. Understand the three types of scope, var vs let vs const, lexical scoping, the scope chain, and closure patterns for data privacy.

Scope & Closures

Why can some variables be accessed from anywhere in your code, while others seem to disappear? How do functions "remember" variables from their parent functions, even after those functions have finished running?

function createCounter() {
  let count = 0  // This variable is "enclosed"
  
  return function() {
    count++
    return count
  }
}

const counter = createCounter()
console.log(counter())  // 1
console.log(counter())  // 2 — it remembers!

The answers lie in understanding scope and closures. These two fundamental concepts govern how variables work in JavaScript. Scope determines where variables are visible, while closures allow functions to remember their original environment.

What you'll learn in this guide:

  • The 3 types of scope: global, function, and block
  • How var, let, and const behave differently
  • What lexical scope means and how the scope chain works
  • What closures are and why every JavaScript developer must understand them
  • Practical patterns: data privacy, factories, and memoization
  • The classic closure gotchas and how to avoid them

Prerequisite: This guide builds on your understanding of the call stack. Knowing how JavaScript tracks function execution will help you understand how scope and closures work under the hood.


What is Scope in JavaScript?

Scope is the current context of execution in which values and expressions are "visible" or can be referenced. It's the set of rules that determines where and how variables can be accessed in your code. If a variable is not in the current scope, it cannot be used. Scopes can be nested, and inner scopes have access to outer scopes, but not vice versa.


The Office Building Analogy

Imagine it's after hours and you're wandering through your office building (legally, you work there, promise). You notice something interesting about what you can and can't see:

  • Inside your private office, you can see everything on your desk, peek into the hallway through your door, and even see the lobby through the glass walls
  • In the hallway, you can see the lobby clearly, but those private offices? Their blinds are shut. No peeking allowed
  • In the lobby, you're limited to just what's there: the reception desk, some chairs, maybe a sad-looking plant
┌─────────────────────────────────────────────────────────────┐
│ LOBBY (Global Scope)                                        │
│   reception = "Welcome Desk"                                │
│                                                             │
│   ┌───────────────────────────────────────────────────┐     │
│   │ HALLWAY (Function Scope)                          │     │
│   │   hallwayPlant = "Fern"                           │     │
│   │                                                   │     │
│   │   ┌───────────────────────────────────────┐       │     │
│   │   │ PRIVATE OFFICE (Block Scope)          │       │     │
│   │   │   secretDocs = "Confidential"         │       │     │
│   │   │                                       │       │     │
│   │   │   Can see: secretDocs      ✓          │       │     │
│   │   │   Can see: hallwayPlant    ✓          │       │     │
│   │   │   Can see: reception       ✓          │       │     │
│   │   └───────────────────────────────────────┘       │     │
│   │                                                   │     │
│   │   Cannot see: secretDocs       ✗                  │     │
│   └───────────────────────────────────────────────────┘     │
│                                                             │
│   Cannot see: hallwayPlant, secretDocs  ✗                   │
└─────────────────────────────────────────────────────────────┘

This is exactly how scope works in JavaScript! Code in inner scopes can "look out" and access variables from outer scopes, but outer scopes can never "look in" to inner scopes.

And here's where it gets really interesting: imagine someone who worked in that private office quits and leaves the building. But they took a mental snapshot of everything in there: the passwords on sticky notes, the secret project plans, the snack drawer location. Even though they've left, they still remember everything. That's essentially what a closure is: a function that "remembers" the scope where it was created, even after that scope is gone.

Why Does Scope Exist?

Scope exists for three critical reasons:


The Three Types of Scope

JavaScript has three main types of scope. Understanding each one is fundamental to writing predictable code.

ES6 modules also introduce module scope, where top-level variables are scoped to the module rather than being global. Learn more in our IIFE, Modules and Namespaces guide.

1. Global Scope

Variables declared outside of any function or block are in the global scope. They're accessible from anywhere in your code.

// Global scope
const appName = "MyApp";
let userCount = 0;

function greet() {
  console.log(appName);  // ✓ Can access global variable
  userCount++;           // ✓ Can modify global variable
}

if (true) {
  console.log(appName);  // ✓ Can access global variable
}

The Global Object

In browsers, global variables become properties of the window object. In Node.js, they attach to global. The modern, universal way to access the global object is globalThis.

var oldSchool = "I'm on window";      // window.oldSchool (var only)
let modern = "I'm NOT on window";      // NOT on window

console.log(window.oldSchool);         // "I'm on window"
console.log(window.modern);            // undefined
console.log(globalThis);               // Works everywhere

Avoid Global Pollution! Too many global variables lead to naming conflicts, hard-to-track bugs, and code that's difficult to maintain. Keep your global scope clean.

// Bad: Polluting global scope
var userData = {};
var settings = {};
var helpers = {};

// Good: Use a single namespace
const MyApp = {
  userData: {},
  settings: {},
  helpers: {}
};

2. Function Scope

Variables declared with var inside a function are function-scoped. They're only accessible within that function.

function calculateTotal() {
  var subtotal = 100;
  var tax = 10;
  var total = subtotal + tax;
  
  console.log(total);  // ✓ 110
}

calculateTotal();
// console.log(subtotal);  // ✗ ReferenceError: subtotal is not defined

var Hoisting

Variables declared with var are "hoisted" to the top of their function. This means JavaScript knows about them before the code runs, but they're initialized as undefined until the actual declaration line.

function example() {
  console.log(message);  // undefined (not an error!)
  var message = "Hello";
  console.log(message);  // "Hello"
}

// JavaScript interprets this as:
function exampleHoisted() {
  var message;           // Declaration hoisted to top
  console.log(message);  // undefined
  message = "Hello";     // Assignment stays in place
  console.log(message);  // "Hello"
}

Hoisting Visualization:

Your code:                    How JS sees it:
┌─────────────────────┐       ┌─────────────────────┐
│ function foo() {    │       │ function foo() {    │
│                     │       │   var x;  // hoisted│
│   console.log(x);   │  ──►  │   console.log(x);   │
│   var x = 5;        │       │   x = 5;            │
│ }                   │       │ }                   │
└─────────────────────┘       └─────────────────────┘

3. Block Scope

Variables declared with let and const are block-scoped. A block is any code within curly braces {}: if statements, for loops, while loops, or just standalone blocks.

if (true) {
  let blockLet = "I'm block-scoped";
  const blockConst = "Me too";
  var functionVar = "I escape the block!";
}

// console.log(blockLet);    // ✗ ReferenceError
// console.log(blockConst);  // ✗ ReferenceError
console.log(functionVar);    // ✓ "I escape the block!"

The Temporal Dead Zone (TDZ)

Unlike var, variables declared with let and const are not initialized until their declaration is evaluated. Accessing them before declaration causes a ReferenceError. This period is called the Temporal Dead Zone.

function demo() {
  // TDZ for 'name' starts here
  
  console.log(name);  // ReferenceError: Cannot access 'name' before initialization
  
  let name = "Alice"; // TDZ ends here
  
  console.log(name);  // "Alice"
}
┌────────────────────────────────────────────────────────────┐
│                                                            │
│   function demo() {                                        │
│                                                            │
│     ┌────────────────────────────────────────────────┐     │
│     │           TEMPORAL DEAD ZONE                   │     │
│     │                                                │     │
│     │   'name' exists but cannot be accessed yet!    │     │
│     │                                                │     │
│     │   console.log(name);  // ReferenceError        │     │
│     │                                                │     │
│     └────────────────────────────────────────────────┘     │
│                           │                                │
│                           ▼                                │
│     let name = "Alice";   // TDZ ends here                 │
│                                                            │
│     console.log(name);    // "Alice" - works fine!         │
│                                                            │
│   }                                                        │
│                                                            │
└────────────────────────────────────────────────────────────┘

The TDZ exists to catch programming errors. It's actually a good thing! It prevents you from accidentally using variables before they're ready.


var vs let vs const

Here's a comprehensive comparison of the three variable declaration keywords:

Featurevarletconst
ScopeFunctionBlockBlock
HoistingYes (initialized as undefined)Yes (but TDZ)Yes (but TDZ)
Redeclaration✓ Allowed✗ Error✗ Error
Reassignment✓ Allowed✓ Allowed✗ Error
Must InitializeNoNoYes
// var allows redeclaration (can cause bugs!)
var name = "Alice";
var name = "Bob";     // No error, silently overwrites
console.log(name);    // "Bob"

// let and const prevent redeclaration
let age = 25
// let age = 30      // SyntaxError: 'age' has already been declared

const PI = 3.14
// const PI = 3.14159 // SyntaxError
// var and let allow reassignment
var count = 1;
count = 2;           // ✓ Fine

let score = 100;
score = 200;         // ✓ Fine

// const prevents reassignment
const API_KEY = "abc123"
// API_KEY = "xyz789"  // TypeError: Assignment to constant variable

// BUT: const objects/arrays CAN be mutated!
const user = { name: "Alice" }
user.name = "Bob"   // ✓ This works!
user.age = 25       // ✓ This works too!
// user = {}        // ✗ This fails (reassignment)
function hoistingDemo() {
  // var: hoisted and initialized as undefined
  console.log(a);  // undefined
  var a = 1;
  
  // let: hoisted but NOT initialized (TDZ)
  // console.log(b);  // ReferenceError!
  let b = 2;
  
  // const: same as let
  // console.log(c);  // ReferenceError!
  const c = 3;
}

The Classic for-loop Problem

This is one of the most common JavaScript gotchas, and it perfectly illustrates why let is preferred over var:

// The Problem: var is function-scoped
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 100)
}
// Output: 3, 3, 3  (not 0, 1, 2!)

// Why? There's only ONE 'i' variable shared across all iterations.
// By the time the setTimeout callbacks run, the loop has finished and i === 3.

The setTimeout() callbacks all close over the same i variable, which equals 3 by the time they execute. (To understand why the callbacks don't run immediately, see our Event Loop guide.)

// The Solution: let is block-scoped
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 100)
}
// Output: 0, 1, 2  (correct!)

// Why? Each iteration gets its OWN 'i' variable.
// Each setTimeout callback closes over a different 'i'.

Modern Best Practice:

  1. Use const by default
  2. Use let when you need to reassign
  3. Avoid var entirely (legacy code only)

This approach catches bugs at compile time and makes your intent clear.


Lexical Scope

Lexical scope (also called static scope) means that the scope of a variable is determined by its position in the source code, not by how functions are called at runtime. As Kyle Simpson explains in You Don't Know JS: Scope & Closures, lexical scope is determined at "lex-time" — the time when the code is being parsed — which is why it is also called "static" scope.

const outer = "I'm outside!";

function outerFunction() {
  const middle = "I'm in the middle!";
  
  function innerFunction() {
    const inner = "I'm inside!";
    
    // innerFunction can access all three variables
    console.log(inner);   // ✓ Own scope
    console.log(middle);  // ✓ Parent scope
    console.log(outer);   // ✓ Global scope
  }
  
  innerFunction();
  // console.log(inner);  // ✗ ReferenceError
}

outerFunction();
// console.log(middle);   // ✗ ReferenceError

The Scope Chain

When JavaScript needs to find a variable, it walks up the scope chain. It starts from the current scope and moves outward until it finds the variable or reaches the global scope.

Look in Current Scope

JavaScript first checks if the variable exists in the current function/block scope.

Look in Parent Scope

If not found, it checks the enclosing (parent) scope.

Continue Up the Chain

This process continues up through all ancestor scopes.

Reach Global Scope

Finally, it checks the global scope. If still not found, a ReferenceError is thrown.

Variable Lookup: Where is 'x'?

┌─────────────────────────────────────────────────┐
│ Global Scope                                    │
│   x = "global"                                  │
│                                                 │
│   ┌─────────────────────────────────────────┐   │
│   │ outer() Scope                           │   │
│   │   x = "outer"                           │   │
│   │                                         │   │
│   │   ┌─────────────────────────────────┐   │   │
│   │   │ inner() Scope                   │   │   │
│   │   │                                 │   │   │
│   │   │   console.log(x);               │   │   │
│   │   │         │                       │   │   │
│   │   │         ▼                       │   │   │
│   │   │   1. Check inner() → not found  │   │   │
│   │   │         │                       │   │   │
│   │   └─────────│───────────────────────┘   │   │
│   │             ▼                           │   │
│   │   2. Check outer() → FOUND! "outer"     │   │
│   │                                         │   │
│   └─────────────────────────────────────────┘   │
│                                                 │
└─────────────────────────────────────────────────┘

Result: "outer"

Variable Shadowing

When an inner scope declares a variable with the same name as an outer scope, the inner variable "shadows" the outer one:

const name = "Global";

function greet() {
  const name = "Function";  // Shadows global 'name'
  
  if (true) {
    const name = "Block";   // Shadows function 'name'
    console.log(name);      // "Block"
  }
  
  console.log(name);        // "Function"
}

greet();
console.log(name);          // "Global"

Shadowing can be confusing. While sometimes intentional, accidental shadowing is a common source of bugs. Many linters warn about this.


What is a Closure in JavaScript?

A closure is the combination of a function bundled together with references to its surrounding state (the lexical environment). According to MDN, a closure gives a function access to variables from an outer (enclosing) scope, even after that outer function has finished executing and returned. Every function in JavaScript creates a closure at creation time.

Remember our office building analogy? A closure is like someone who worked in the private office, left the building, but still remembers exactly where everything was, and can still use that knowledge!

Every Function Creates a Closure

In JavaScript, closures are created automatically every time you create a function. The function maintains a reference to its lexical environment.

function createGreeter(greeting) {
  // 'greeting' is in createGreeter's scope
  
  return function(name) {
    // This inner function is a closure!
    // It "closes over" the 'greeting' variable
    console.log(`${greeting}, ${name}!`);
  };
}

const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");

// createGreeter has finished executing, but...
sayHello("Alice");  // "Hello, Alice!"
sayHola("Bob");     // "Hola, Bob!"

// The inner functions still remember their 'greeting' values!

How Closures Work: Step by Step

Outer Function is Called

createGreeter("Hello") is called. A new execution context is created with greeting = "Hello".

Inner Function is Created

The inner function is created. It captures a reference to the current lexical environment (which includes greeting).

Outer Function Returns

createGreeter returns the inner function and its execution context is (normally) cleaned up.

But the Closure Survives!

Because the inner function holds a reference to the lexical environment, the greeting variable is NOT garbage collected. It survives!

Closure is Invoked Later

When sayHello("Alice") is called, the function can still access greeting through its closure.

After createGreeter("Hello") returns:

┌──────────────────────────────────────┐
│ sayHello (Function)                  │
├──────────────────────────────────────┤
│ [[Code]]: function(name) {...}       │
│                                      │
│ [[Environment]]: ────────────────────────┐
└──────────────────────────────────────┘   │

                          ┌────────────────────────────┐
                          │ Lexical Environment        │
                          │ (Kept alive by closure!)   │
                          ├────────────────────────────┤
                          │ greeting: "Hello"          │
                          └────────────────────────────┘

Closures in the Wild

Closures aren't just a theoretical concept. You'll use them every day. Here are the patterns that make closures so powerful.

1. Data Privacy & Encapsulation

Closures let you create truly private variables in JavaScript:

function createCounter() {
  let count = 0;  // Private variable - no way to access directly!
  
  return {
    increment() {
      count++;
      return count;
    },
    decrement() {
      count--;
      return count;
    },
    getCount() {
      return count;
    }
  };
}

const counter = createCounter();

console.log(counter.getCount());  // 0
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1

// There's NO way to access 'count' directly!
console.log(counter.count);       // undefined

This pattern is the foundation of the Module Pattern, widely used before ES6 modules became available. Learn more in our IIFE, Modules and Namespaces guide.

2. Function Factories

Closures let you create specialized functions on the fly:

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

const double = createMultiplier(2);
const triple = createMultiplier(3);
const tenX = createMultiplier(10);

console.log(double(5));   // 10
console.log(triple(5));   // 15
console.log(tenX(5));     // 50

// Each function "remembers" its own multiplier

This pattern works great with the Fetch API for creating reusable API clients:

// Real-world example: API request factories
function createApiClient(baseUrl) {
  return {
    get(endpoint) {
      return fetch(`${baseUrl}${endpoint}`);
    },
    post(endpoint, data) {
      return fetch(`${baseUrl}${endpoint}`, {
        method: 'POST',
        body: JSON.stringify(data)
      });
    }
  };
}

const githubApi = createApiClient('https://api.github.com');
const myApi = createApiClient('https://myapp.com/api');

// Each client remembers its baseUrl
githubApi.get('/users/leonardomso');
myApi.get('/users/1');

3. Preserving State in Callbacks & Event Handlers

Closures are essential for maintaining state in asynchronous code. When you use addEventListener() to attach event handlers, those handlers can close over variables from their outer scope:

function setupClickCounter(buttonId) {
  let clicks = 0;  // This variable persists across clicks!
  
  const button = document.getElementById(buttonId);
  
  button.addEventListener('click', function() {
    clicks++;
    console.log(`Button clicked ${clicks} time${clicks === 1 ? '' : 's'}`);
  });
}

setupClickCounter('myButton');
// Each click increments the same 'clicks' variable
// Click 1: "Button clicked 1 time"
// Click 2: "Button clicked 2 times"
// Click 3: "Button clicked 3 times"

4. Memoization (Caching Results)

Closures enable efficient caching of expensive computations:

function createMemoizedFunction(fn) {
  const cache = {};  // Cache persists across calls!
  
  return function(arg) {
    if (arg in cache) {
      console.log('Returning cached result');
      return cache[arg];
    }
    
    console.log('Computing result');
    const result = fn(arg);
    cache[arg] = result;
    return result;
  };
}

// Expensive operation: calculate factorial
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

const memoizedFactorial = createMemoizedFunction(factorial);

console.log(memoizedFactorial(5));  // Computing result → 120
console.log(memoizedFactorial(5));  // Returning cached result → 120
console.log(memoizedFactorial(5));  // Returning cached result → 120

Common Mistakes and Pitfalls

Understanding scope and closures means understanding where things go wrong. These are the mistakes that trip up even experienced developers.

The #1 Closure Interview Question

This is the classic closure trap. Almost everyone gets it wrong the first time:

The Problem

// What does this print?
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}

// Most people expect: 0, 1, 2
// Actual output: 3, 3, 3

Why Does This Happen?

What actually happens:

  TIME ════════════════════════════════════════════════════►

  ┌─────────────────────────────────────────────────────────┐
  │ IMMEDIATELY (milliseconds):                             │
  │                                                         │
  │   Loop iteration 1: i = 0, schedule callback            │
  │   Loop iteration 2: i = 1, schedule callback            │
  │   Loop iteration 3: i = 2, schedule callback            │
  │   Loop ends: i = 3                                      │
  │                                                         │
  │   All 3 callbacks point to the SAME 'i' variable ──┐    │
  └─────────────────────────────────────────────────────│───┘


  ┌─────────────────────────────────────────────────────────┐
  │ ~1 SECOND LATER:                                        │
  │                                                         │
  │   callback1 runs: "What's i?" → i is 3 → prints 3       │
  │   callback2 runs: "What's i?" → i is 3 → prints 3       │
  │   callback3 runs: "What's i?" → i is 3 → prints 3       │
  │                                                         │
  └─────────────────────────────────────────────────────────┘

  Result: 3, 3, 3 (not 0, 1, 2!)

The Solutions

The simplest modern solution. let creates a new binding for each iteration:

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

Pre-ES6 solution using an Immediately Invoked Function Expression:

for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 1000);
  })(i);  // Pass i as argument, creating a new 'j' each time
}
// Output: 0, 1, 2 ✓

Using array methods, which naturally create new scope per iteration:

[0, 1, 2].forEach(function(i) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
});
// Output: 0, 1, 2 ✓

Memory Leaks from Closures

Closures are powerful, but they come with responsibility. Since closures keep references to their outer scope variables, those variables can't be garbage collected.

Potential Memory Leaks

function createHeavyClosure() {
  const hugeData = new Array(1000000).fill('x');  // Large data
  
  return function() {
    // This reference to hugeData keeps the entire array in memory
    console.log(hugeData.length);
  };
}

const leakyFunction = createHeavyClosure();
// hugeData is still in memory because the closure references it

Modern JavaScript engines like V8 can optimize closures that don't actually use outer variables. However, it's best practice to assume referenced variables are retained and explicitly clean up large data when you're done with it.

Breaking Closure References

When you're done with a closure, explicitly break the reference. Use removeEventListener() to clean up event handlers:

function setupHandler(element) {
  // Imagine this returns a large dataset
  const largeData = { users: new Array(10000).fill({ name: 'User' }) };
  
  const handler = function() {
    console.log(`Processing ${largeData.users.length} users`);
  };
  
  element.addEventListener('click', handler);
  
  // Return a cleanup function
  return function cleanup() {
    element.removeEventListener('click', handler);
    // Now handler and largeData can be garbage collected
  };
}

const button = document.getElementById('myButton');
const cleanup = setupHandler(button);

// Later, when you're done with this functionality:
cleanup();  // Removes listener, allows memory to be freed

Best Practices:

  1. Don't capture more than you need in closures
  2. Set closure references to null when done
  3. Remove event listeners when components unmount
  4. Be especially careful in loops and long-lived applications

Key Takeaways

The key things to remember about Scope & Closures:

  1. Scope = Variable Visibility — It determines where variables can be accessed

  2. Three types of scope: Global (everywhere), Function (var), Block (let/const)

  3. Lexical scope is static — Determined by code position, not runtime behavior

  4. Scope chain — JavaScript looks up variables from inner to outer scope

  5. let and const are block-scoped — Prefer them over var

  6. Temporal Dead Zonelet/const can't be accessed before declaration

  7. Closure = Function + Its Lexical Environment — Functions "remember" where they were created

  8. Closures enable: Data privacy, function factories, stateful callbacks, memoization

  9. Watch for the loop gotcha — Use let instead of var in loops with async callbacks

  10. Mind memory — Closures keep references alive; clean up when done


Test Your Knowledge


Frequently Asked Questions


Call Stack

How JavaScript tracks function execution and manages scope

Hoisting

Deep dive into how JavaScript hoists declarations

Content coming soon.

IIFE, Modules and Namespaces

Patterns that leverage scope for encapsulation

Content coming soon.

this, call, apply and bind

Understanding execution context alongside scope

Content coming soon.

Higher Order Functions

Functions that return functions often create closures

Content coming soon.

Currying & Composition

Advanced patterns built on closures

Content coming soon.

Reference

Books

You Don't Know JS Yet: Scope & Closures — Kyle Simpson

The definitive deep-dive into JavaScript scope and closures. Free to read online. This book will transform your understanding of how JavaScript really works.

Articles

Tools

JavaScript Tutor — Visualize Code Execution

Step through JavaScript code and see how closures capture variables in real-time. Visualize the scope chain, execution contexts, and how functions "remember" their environment. Perfect for understanding closures visually.

Courses

JavaScript: Understanding the Weird Parts (First 3.5 Hours)

Free preview of Anthony Alicea's acclaimed course. Excellent coverage of scope, closures, and execution contexts.

Videos

AI assistant loads as you scroll.

Comments and reactions load as you scroll.