Scope decides where a name is visible; a closure is what happens when a function remembers the scope it was created in. Together they explain why a callback still has access to a variable long after the function that owned it has finished, and why a tiny factory function can give you a piece of state that nothing else can touch.
What are the levels of scope in JavaScript?
Three, nested inside each other. The global scope holds the top-level variables of your script. A function scope is created every time a function runs. A block scope is created by any { } — a function body, an if, a for, a plain block.
const globalScope = "global";
function outer() {
const functionScope = "function";
if (true) {
const blockScope = "block";
console.log(blockScope); // "block"
console.log(functionScope); // "function"
console.log(globalScope); // "global"
}
try {
console.log(blockScope); // ReferenceError — the block is over
} catch (error) {
console.log("caught:", error.constructor.name);
}
}
outer();
Inside the if block, all three names are visible. Outside it, blockScope is gone and reading it throws a ReferenceError. The function scope and the global scope are still there, because the block sits inside the function, which sits inside the global scope.
That nesting is the rule that resolves every scope question: inner scopes can see outer scopes, outer scopes cannot see inner ones. A name is visible from the point where it was declared until the end of the block that declared it.
What happens when two scopes use the same name?
The inner one wins, and it shadows the outer one for as long as the inner scope lasts. This is legal and occasionally useful, but it is also the source of the classic bug where you change a variable and the wrong one changes.
const name = "global";
function shadow() {
const name = "local";
return name;
}
console.log(shadow()); // "local"
console.log(name); // "global"
Renaming the inner variable to something more specific — label rather than name — removes the ambiguity entirely. When you find yourself tempted to shadow, rename instead.
What is the temporal dead zone, exactly?
A let or const binding exists from the start of its block, but it has no value until its declaration runs. The gap between those two moments is the temporal dead zone, and touching the name inside it throws a ReferenceError.
try {
console.log(later);
let later = "never reached";
} catch (error) {
console.log("caught:", error.constructor.name, "-", error.message);
}
The message is Cannot access 'later' before initialization. This is why you cannot call a function that uses a const before the const has been assigned, even though the function itself is perfectly valid — and it is exactly what makes const a stronger guarantee than var, where the same mistake would silently give you undefined.
What is a closure?
A closure is a function together with the scope it was created in. When you create a function inside another function, the inner function keeps a reference to the outer function’s variables — and it keeps that reference after the outer function has returned.
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
const other = createCounter();
console.log(other()); // 1 — a completely separate counter
Read that carefully. createCounter has finished running, and count no longer exists anywhere you can reach it — yet counter() still increments it and still reports the running total. Each call to createCounter produces a fresh count, which is why other() starts at 1 again.
The variable is not hidden by magic. It is simply that no name outside the closure refers to it, so nothing else can change it. The closure holds the only reference.
What are closures actually good for?
Keeping state private. A closure lets you expose operations without exposing the data they work on, and without needing a class.
function createBankAccount(owner) {
let balance = 0;
return {
deposit(amount) {
balance += amount;
return balance;
},
getBalance() {
return balance;
},
ownerName() {
return owner;
},
};
}
const account = createBankAccount("Ada");
account.deposit(500);
console.log(account.getBalance()); // 500
console.log(account.balance); // undefined — not exposed
console.log(account.ownerName()); // "Ada"
The balance variable is unreachable from outside. The only way to change it is through deposit, and the only way to read it is through getBalance. That is encapsulation without a single class, and it is the same mechanism that powers the module pattern, private counters, memoised results and a surprising amount of library code you already use.
Another everyday use is attaching context to a callback. A closure lets you hand a function a piece of information it should carry with it, without that information having to be a parameter passed at call time.
function attachLabel(prefix) {
return function log(message) {
return `[${prefix}] ${message}`;
};
}
const warn = attachLabel("warn");
console.log(warn("disk almost full")); // "[warn] disk almost full"
console.log(warn("retrying")); // "[warn] retrying"
What are the closure pitfalls?
Two, and both are about lifetimes rather than about syntax.
The first is memory retention. A closure keeps its captured variables alive for as long as the closure itself is reachable. If you create a closure in a loop and store it in an array, every captured value stays alive too — which is usually what you want, and occasionally a leak in a long-running page.
function makeHandlers(items) {
const handlers = [];
for (const item of items) {
handlers.push(() => item); // each closure keeps its own item alive
}
return handlers;
}
const handlers = makeHandlers(["a", "b", "c"]);
console.log(handlers.map((h) => h())); // [ "a", "b", "c" ]
console.log(handlers.length); // 3
The second is shared state. When two closures capture the same variable, they share it. Two handlers created from the same factory can interfere with each other, and the bug only appears when both are used together. When a factory returns something meant to be used once, make that explicit in the name — createCounter rather than counter.
One more trap is worth naming: creating a closure inside a loop that captures the loop variable. The let version of a classic for loop gives each iteration its own binding, and the var version shares one — a difference covered in the variables module of this tutorial.
The scope and closure rules to remember
- Scopes nest: global, then function, then block. Inner scopes can read outer names; outer scopes cannot read inner ones.
- Declaring a name inside a block hides the outer one for that block. Rename rather than shadow.
- A
letorconstis in its temporal dead zone until its declaration runs. Using it earlier throws. - A closure is a function plus the scope it was created in. It keeps that scope alive after the outer function returns.
- Closures give you private state without classes: expose functions, hide the variable.
- Closures capture values, so two closures over the same variable share it.
Try it yourself: build a createPlaylist() factory that returns an object with add, remove, next and list, where the playlist itself lives in a closure and nothing outside can reach it. Then call createPlaylist() twice and confirm the two playlists are completely independent. Finally, try to read the internal list directly and watch the undefined tell you that the state really is private.