JavaScript Scope, Hoisting and Closures: Where Do Variables Live?
We learn global, function and block scope, the scope chain, how var, let, const and functions are hoisted, the TDZ, and closures through counters, private data and function factories, plus the closure-in-a-loop trap.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
A three-line loop and a timer:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
What does this print to the console? Make a guess before you read on. If you said 0 1 2, you're not alone; this is one of the all-time favorite JavaScript interview questions. We'll give the answer near the end of the post, once we've covered closures.
What Is Scope? Rooms Inside Rooms
Scope is the area where a variable can be seen and used. Think of it as rooms nested inside each other, but with one-way glass. From an inner room you can see the outer rooms; from the outside you can't look in. JavaScript has three basic kinds of rooms.
Global scope is the outermost room; declarations that aren't inside any function or block live here and are visible everywhere. Function scope is the inside of a function; variables declared there can't be seen from outside the function. Block scope is every block opened with curly braces: an if, a for, or a bare { }. The let and const we saw earlier have respected these blocks since ES2015. var, on the other hand, ignores blocks and only stops at a function boundary.
Paste this into your browser console and try it:
const appName = "Shop"; // global scope
function showCart() {
const itemCount = 3; // function scope
if (itemCount > 0) {
const message = `${appName}: ${itemCount} items`; // block scope
var legacy = "var ignores blocks";
console.log(message); // "Shop: 3 items"
}
console.log(legacy); // "var ignores blocks"
console.log(typeof message); // "undefined", not visible here
}
showCart();
console.log(typeof itemCount); // "undefined", not visible outside
The message variable was born inside the if block and disappeared when the block ended. legacy, declared with var, leaked out of the block. That is why modern code uses let and const instead of var. One note: modules have their own scope too; we'll see that in the post on modules in the browser.
The Scope Chain: Searching from the Inside Out
When the engine runs into a variable name, it first looks in the room it is in. If the name isn't there, it moves one room out, then another, all the way to global scope. This chain is called the scope chain. If the name isn't in global scope either, the engine throws a ReferenceError.
Here's the key point: the chain is built from where the function is written in the code, not where it is called. This is called lexical scope. The following example shows it clearly:
const level = "global";
function outer() {
const level = "outer";
function inner() {
console.log(level);
}
return inner;
}
function elsewhere() {
const level = "elsewhere";
const fn = outer();
fn();
}
elsewhere(); // "outer"
inner was called from inside elsewhere, but it was written inside outer. The engine looks up variables based on where the function was written, so the result is "outer". An inner variable hiding an outer one with the same name is called shadowing: the level inside outer hides the global one. Once you declare a new variable with the same name inside, you can no longer reach the outer one from that block.
Hoisting: The Engine Reads Your Code Twice
When the engine enters a scope, it doesn't start running the code line by line right away. It first does a preparation pass and notes every declaration in that scope: which variables exist, which functions exist. Only then does it start executing. Declarations behaving as if they were "lifted" to the top of the code is called hoisting. The code doesn't actually move; the engine simply knows the names in advance.
console.log(greetUser("Ada")); // "Hello Ada"
console.log(total); // undefined
var total = 100;
console.log(total); // 100
function greetUser(name) {
return `Hello ${name}`;
}
Function declarations are prepared together with their bodies, which is why you can call them before the declaration. For a variable declared with var, only the name is prepared and its value starts as undefined; the assignment happens when execution reaches that line. Function expressions and arrow functions are assigned to a variable, so they follow that variable's rules:
console.log(typeof sayBye); // "undefined"
var sayBye = function () {
return "See you";
};
console.log(sayBye()); // "See you"
Had you called sayBye before the declaration, you'd get TypeError: sayBye is not a function, because at that moment its value is undefined. Doing the same with const would give a different error. Which brings us to the next topic.
The TDZ: The Waiting Room of let and const
let and const are hoisted too; the engine notes them during the preparation pass as well. But it doesn't give them an initial value. The stretch from the start of the block to the line where the declaration happens is called the TDZ (Temporal Dead Zone), and touching the variable in that zone throws an error:
function checkout() {
console.log(discount); // ReferenceError: Cannot access 'discount' before initialization
let discount = 10;
}
checkout();
That error message is Chrome's; Firefox and Safari say the same thing in different words. You might ask, "Is let really hoisted, then?" This example answers it:
const tax = 18;
function calcPrice() {
console.log(tax); // ReferenceError, not 18
const tax = 20;
}
calcPrice();
If const tax = 20 weren't hoisted, the engine wouldn't find tax inside the function, would walk out along the scope chain and print the global 18. But the engine already noted the inner tax during the preparation pass; it just hasn't initialized it yet. The TDZ isn't a punishment, it's a safety measure: using a variable before declaring it is almost always a bug, and instead of the silent undefined of var, it tells you so loudly.
Closures: A Function's Backpack
Normally, when a function finishes, its local variables are done too. But what if another function born inside it gets out and still needs those variables? That's where closures come in. Every function carries the variables of the environment it was born in, like a backpack. Wherever it's called from, it can reach what's in its backpack.
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
const another = createCounter();
console.log(another()); // 1
console.log(counter()); // 3
createCounter finished long ago, yet the function it returned keeps using count. Because the engine sees that a function still references that variable, it doesn't throw the environment away. And every createCounter() call creates a new environment, a new backpack; that's why another started from zero with its own counter.
Let's define it in one sentence: a closure is a function combined with the lexical environment of the place where it was written. In fact, every function in JavaScript is a closure; it just becomes visible when an inner function gets out. It's also the secret behind how the callbacks we passed to map earlier could reach the products array outside of them.
Where Are Closures Useful?
The first use is private data. A variable inside a closure can't be reached directly from outside; it can only be read and changed through the functions you hand out:
function createWallet(initial) {
let balance = initial;
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) return "Insufficient funds";
balance -= amount;
return balance;
},
getBalance: () => balance
};
}
const wallet = createWallet(100);
console.log(wallet.deposit(50)); // 150
console.log(wallet.withdraw(500)); // "Insufficient funds"
console.log(wallet.balance); // undefined, no direct access
Nobody can accidentally set the balance to -1000, because the only doors to it are deposit and withdraw. In classes, private fields declared with # do the same job; we'll see them in the post on classes.
The second use is function factories. You provide a setting once and produce new functions that behave accordingly:
function multiplier(factor) {
return (n) => n * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
console.log([1, 2, 3].map(double)); // [2, 4, 6]
console.log(triple(5)); // 15
function once(fn) {
let done = false;
return (...args) => {
if (done) return;
done = true;
return fn(...args);
};
}
const init = once(() => console.log("Initialized"));
init(); // "Initialized"
init(); // nothing happens
Small helpers like once, event handlers and timers are the everyday face of closures. When you write a listener that keeps track of how many times a button was clicked, you'll most likely keep the counter in a closure.
The Closure-in-a-Loop Trap
Now back to the opening question. The answer is 3 3 3. We can now explain why, piece by piece. Since var ignores blocks, there is a single i for the entire loop. All three callbacks put that same i into their backpacks. The timers run after the loop has finished; we'll see in detail why they run later in the posts on asynchronous code. At that moment the single i is 3, and all three callbacks read it.
When you write it with let, the for loop creates a new i for every iteration. Each callback carries the i of its own iteration:
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}
// 0 1 2
In older code you may see this problem solved with an IIFE (an immediately invoked function expression). It opens a new function scope for each iteration and copies the value in as a parameter:
for (var k = 0; k < 3; k++) {
(function (copy) {
setTimeout(() => console.log(copy), 0);
})(k);
}
// 0 1 2
There's no need for that today; let does the same job on its own. But recognizing the pattern helps when you read old code.
Common Pitfalls
Undeclared assignment. In a classic script, if you write result = 5 without let, const or var, the engine walks to the end of the scope chain, finds nothing and silently creates a global variable. In strict mode (available since ES5 and the default in modules), the same line throws a ReferenceError. A misspelled variable name silently turning into a global is a classic source of bugs people hunt for hours.
Clobbering window with a global var. A variable declared with var at the top level of a classic script becomes a property of the window object; let and const don't. That can collide with the browser's own properties. For example, window.name always holds a string:
var name = 42;
console.log(typeof name); // "string", window.name converted it
Running code twice in the same console. When you paste an example again, you may see an error along the lines of "has already been declared"; the same name can't be declared twice in the same scope. Reloading the page or wrapping the code in a { } block is enough.
Closures holding on to more than they need. A closure keeps the variables it uses alive. If you attach a closure that holds a huge array to a long-lived event listener, that array stays in memory too. We'll cover memory management in detail in the Advanced series; for now, just remember that closures aren't free.
A Small Experiment
★☆☆ Predict before running: function f() { console.log(x); var x = 1; console.log(x); } f(); Then replace var with let and explain what changes.
★★☆ Extend createCounter so it returns an object with increment, decrement and reset methods. Show in the console that count can't be reached directly from outside.
★★☆ Write a function factory called makeGreeting(greeting). Calling makeGreeting("Hi")("Ada") should return "Hi, Ada!".
★★★ Write a small HTML file that puts three buttons on a page. Create the buttons with a for loop that uses var, and give each one a click listener that shows its own position, like "I am number 1". Watch the broken version first, then fix it in two different ways.
Closures capture variables from where they were written. But JavaScript has one special keyword that doesn't follow this rule, whose value depends not on where a function is written but on how it is called: this. In the next post we'll see how this is decided, why methods lose their this, and how to take control of it with call, apply and bind.