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, andconstbehave 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 everywhereAvoid 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 definedvar 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:
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisting | Yes (initialized as undefined) | Yes (but TDZ) | Yes (but TDZ) |
| Redeclaration | ✓ Allowed | ✗ Error | ✗ Error |
| Reassignment | ✓ Allowed | ✓ Allowed | ✗ Error |
| Must Initialize | No | No | Yes |
// 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:
- Use
constby default - Use
letwhen you need to reassign - Avoid
varentirely (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); // ✗ ReferenceErrorThe 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); // undefinedThis 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 multiplierThis 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 → 120Common 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, 3Why 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 itModern 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 freedBest Practices:
- Don't capture more than you need in closures
- Set closure references to
nullwhen done - Remove event listeners when components unmount
- Be especially careful in loops and long-lived applications
Key Takeaways
The key things to remember about Scope & Closures:
-
Scope = Variable Visibility — It determines where variables can be accessed
-
Three types of scope: Global (everywhere), Function (
var), Block (let/const) -
Lexical scope is static — Determined by code position, not runtime behavior
-
Scope chain — JavaScript looks up variables from inner to outer scope
-
letandconstare block-scoped — Prefer them overvar -
Temporal Dead Zone —
let/constcan't be accessed before declaration -
Closure = Function + Its Lexical Environment — Functions "remember" where they were created
-
Closures enable: Data privacy, function factories, stateful callbacks, memoization
-
Watch for the loop gotcha — Use
letinstead ofvarin loops with async callbacks -
Mind memory — Closures keep references alive; clean up when done
Test Your Knowledge
Frequently Asked Questions
Related Concepts
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
Closures — MDN
Official MDN documentation on closures and lexical scoping
Scope — MDN Glossary
MDN glossary entry explaining scope in JavaScript
var — MDN
Reference for the var keyword, function scope, and hoisting
let — MDN
Reference for the let keyword and block scope
const — MDN
Reference for the const keyword and immutable bindings
Closures — JavaScript.Info
In-depth tutorial on closures and lexical environment
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
Var, Let, and Const – What's the Difference?
Clear FreeCodeCamp guide comparing the three variable declaration keywords with practical examples.
JavaScript Scope and Closures
Zell Liew's comprehensive CSS-Tricks article covering both scope and closures in one excellent resource.
whatthefuck.is · A Closure
Dan Abramov's clear, concise explanation of closures. Perfect for the "aha moment."
I never understood JavaScript closures
Olivier De Meulder's article that has helped countless developers finally understand closures.
The Difference Between Function and Block Scope
Joseph Cardillo's focused explanation of how var differs from let and const in terms of scope.
Closures: Using Memoization
Brian Barbour's practical guide showing how closures enable powerful caching patterns.
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
JavaScript The Hard Parts: Closure, Scope & Execution Context
Will Sentance draws out execution contexts and the scope chain on a whiteboard as code runs. This visual approach makes the "how" of closures click.
Closures in JavaScript
Akshay Saini's popular Namaste JavaScript episode with clear visual explanations.
Closures — Fun Fun Function
Mattias Petter Johansson's entertaining and educational take on closures.
Learn Closures In 7 Minutes
Web Dev Simplified's concise, beginner-friendly closure explanation.