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.
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?
Copy
Ask AI
function createCounter() { let count = 0 // This variable is "enclosed" return function() { count++ return count }}const counter = createCounter()console.log(counter()) // 1console.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 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.
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.
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
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.
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.
Variables declared outside of any function or block are in the global scope. They’re accessible from anywhere in your code.
Copy
Ask AI
// Global scopeconst 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}
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.
Copy
Ask AI
var oldSchool = "I'm on window"; // window.oldSchool (var only)let modern = "I'm NOT on window"; // NOT on windowconsole.log(window.oldSchool); // "I'm on window"console.log(window.modern); // undefinedconsole.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.
Copy
Ask AI
// Bad: Polluting global scopevar userData = {};var settings = {};var helpers = {};// Good: Use a single namespaceconst MyApp = { userData: {}, settings: {}, helpers: {}};
Variables declared with var inside a function are function-scoped. They’re only accessible within that function.
Copy
Ask AI
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
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.
Copy
Ask AI
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:
Copy
Ask AI
Your code: How JS sees it:┌─────────────────────┐ ┌─────────────────────┐│ function foo() { │ │ function foo() { ││ │ │ var x; // hoisted││ console.log(x); │ ──► │ console.log(x); ││ var x = 5; │ │ x = 5; ││ } │ │ } │└─────────────────────┘ └─────────────────────┘
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.
Copy
Ask AI
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); // ✗ ReferenceErrorconsole.log(functionVar); // ✓ "I escape the block!"
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.
Copy
Ask AI
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"}
Copy
Ask AI
┌────────────────────────────────────────────────────────────┐│ ││ 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.
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
Redeclaration
Reassignment
Hoisting Behavior
Copy
Ask AI
// var allows redeclaration (can cause bugs!)var name = "Alice";var name = "Bob"; // No error, silently overwritesconsole.log(name); // "Bob"// let and const prevent redeclarationlet age = 25// let age = 30 // SyntaxError: 'age' has already been declaredconst PI = 3.14// const PI = 3.14159 // SyntaxError
Copy
Ask AI
// var and let allow reassignmentvar count = 1;count = 2; // ✓ Finelet score = 100;score = 200; // ✓ Fine// const prevents reassignmentconst 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)
Copy
Ask AI
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;}
This is one of the most common JavaScript gotchas, and it perfectly illustrates why let is preferred over var:
Copy
Ask AI
// The Problem: var is function-scopedfor (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.)
Copy
Ask AI
// The Solution: let is block-scopedfor (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 const by default
Use let when you need to reassign
Avoid var entirely (legacy code only)
This approach catches bugs at compile time and makes your intent clear.
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.
Copy
Ask AI
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
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.
1
Look in Current Scope
JavaScript first checks if the variable exists in the current function/block scope.
2
Look in Parent Scope
If not found, it checks the enclosing (parent) scope.
3
Continue Up the Chain
This process continues up through all ancestor scopes.
4
Reach Global Scope
Finally, it checks the global scope. If still not found, a ReferenceError is thrown.
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!
In JavaScript, closures are created automatically every time you create a function. The function maintains a reference to its lexical environment.
Copy
Ask AI
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!
Closures let you create truly private variables in JavaScript:
Copy
Ask AI
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()); // 0console.log(counter.increment()); // 1console.log(counter.increment()); // 2console.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.
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:
Copy
Ask AI
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"
// 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
The simplest modern solution. let creates a new binding for each iteration:
Copy
Ask AI
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:
Copy
Ask AI
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:
Closures are powerful, but they come with responsibility. Since closures keep references to their outer scope variables, those variables can’t be garbage collected.
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.
When you’re done with a closure, explicitly break the reference. Use removeEventListener() to clean up event handlers:
Copy
Ask AI
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:
Don’t capture more than you need in closures
Set closure references to null when done
Remove event listeners when components unmount
Be especially careful in loops and long-lived applications
Question 1: What are the three types of scope in JavaScript?
Answer:
Global Scope — Variables declared outside any function or block; accessible everywhere
Function Scope — Variables declared with var inside a function; accessible only within that function
Block Scope — Variables declared with let or const inside a block {}; accessible only within that block
Copy
Ask AI
const global = "everywhere"; // Global scopefunction example() { var functionScoped = "function"; // Function scope if (true) { let blockScoped = "block"; // Block scope }}
Question 2: What is the Temporal Dead Zone?
Answer: The Temporal Dead Zone (TDZ) is the period between entering a scope and the actual declaration of a let or const variable. During this time, the variable exists but cannot be accessed. Doing so throws a ReferenceError.
Copy
Ask AI
function example() { // TDZ starts for 'x' console.log(x); // ReferenceError! // TDZ continues... let x = 10; // TDZ ends console.log(x); // 10 ✓}
The TDZ helps catch bugs where you accidentally use variables before they’re initialized.
Question 3: What is lexical scope?
Answer: Lexical scope (also called static scope) means that the accessibility of variables is determined by their physical position in the source code at write time, not by how or where functions are called at runtime.Inner functions have access to variables declared in their outer functions because of where they are written, not because of when they’re invoked.
Copy
Ask AI
function outer() { const message = "Hello"; function inner() { console.log(message); // Can access 'message' because of lexical scope } return inner;}const fn = outer();fn(); // "Hello" — still works even though outer() has returned
Question 4: What is a closure?
Answer: A closure is a function combined with references to its surrounding lexical environment. In simpler terms, a closure is a function that “remembers” the variables from the scope where it was created, even when executed outside that scope.
Copy
Ask AI
function createCounter() { let count = 0; // This variable is "enclosed" in the closure return function() { count++; return count; };}const counter = createCounter();console.log(counter()); // 1console.log(counter()); // 2// 'count' persists because of the closure
Every function in JavaScript creates a closure. The term usually refers to situations where this behavior is notably useful or surprising.
Question 5: What will this code output and why?
Copy
Ask AI
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100);}
Answer: It outputs 3, 3, 3.Why? Because var is function-scoped (not block-scoped), there’s only ONE i variable shared across all iterations. By the time the setTimeout callbacks execute (after ~100ms), the loop has already completed and i equals 3.Fix: Use let instead of var:
Copy
Ask AI
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100);}// Outputs: 0, 1, 2
With let, each iteration gets its own i variable, and each callback closes over a different value.
Question 6: When would you use a closure in real code?
Answer: Common practical uses for closures include:
Data Privacy — Creating private variables that can’t be accessed directly:
What is the difference between scope and closures in JavaScript?
Scope defines where a variable can be accessed in your code. A closure is what happens when a function keeps access to variables from its outer lexical scope even after that outer function returns. In short: scope is the rulebook, closure is a practical behavior created by those rules.
Why should I use let and const instead of var?
let and const are block-scoped, so they reduce accidental leaks and make intent clearer. const communicates that the binding should not be reassigned, while let is for values that change. var is function-scoped and hoisted in ways that often produce bugs, especially inside loops and conditionals.
How do closures work in JavaScript?
A function closes over the variables available where it was defined, not where it is called. When that function runs later, JavaScript still resolves those captured variables through the saved lexical environment.
Copy
Ask AI
function makeGreeter(name) { return function greet() { return `Hi, ${name}`; };}
What are common use cases for closures?
Common uses include data privacy, function factories, memoization, and stateful callbacks. As Kyle Simpson explains in You Don’t Know JS: Scope & Closures, closures are not a niche feature; they are a core part of how JavaScript functions work. You will use closures any time a callback needs to remember context.
What is lexical scope vs dynamic scope?
JavaScript uses lexical scope, which means variable access is decided by where code is written in the file. Dynamic scope would decide variable access based on the call stack at runtime, but JavaScript does not use that model. This is why moving a function changes what it can access, even if calls stay the same.
Can closures cause memory leaks?
Closures can keep objects in memory longer than expected if they retain references you no longer need. This is most common with long-lived event listeners and timers that capture large data structures. In the 2023 State of JS survey, many developers still reported debugging memory/performance issues, so cleaning up listeners and limiting captured data is an important habit.