Two questions sit underneath an enormous amount of JavaScript behavior: where can a piece of code read a given variable, and how long does that variable stay alive? The answer to the first is scope. The answer to the second — once you mix functions into it — leads straight to closures, one of the features people find most confusing right up until the moment it clicks.
The good news is that neither idea is hard once you stop memorizing rules and start picturing what actually happens. By the end of this article you’ll be able to look at a snippet, trace which variables each line can reach, and explain why a counter you wrote three lines ago still remembers its own private number. We’ll lean on what you already know about functions and variables, so if those feel shaky, skim them first.
What scope actually means
Scope is the set of variables that are visible — reachable by name — from a given spot in your code. Not every variable is available everywhere. JavaScript draws boundaries, and a variable only exists inside the boundary where it was declared.
Think of it like rooms in a building. If you stand in a small room, you can see everything in that room and everything in the larger rooms surrounding it — but someone in the next room over can’t see what’s on your desk. Code works the same way: an inner area can look outward, but the outer area can’t peek inward.
There are three kinds of scope worth naming:
- Global scope — declared outside every function and block. Visible everywhere.
- Function scope — declared inside a function. Visible only within that function.
- Block scope — declared inside a
{ }block (anif, afor, or just a bare pair of braces). Visible only within that block. This one applies toletandconst, but not tovar.
const appName = "ACY Partner Indonesia"; // global
function greet() {
const message = "Welcome"; // function scope
console.log(message); // ✅ works
console.log(appName); // ✅ works — can see outward to global
}
greet();
console.log(appName); // ✅ "ACY Partner Indonesia"
console.log(message); // ❌ ReferenceError: message is not defined
message lives inside greet. The moment you try to read it from the outside, JavaScript has no idea what you’re talking about — that name simply doesn’t exist out there.
Block scope: let and const vs var
This is the one piece of scope history you genuinely need to know, because old code and new code behave differently. Before 2015, var was the only way to declare a variable, and var ignores block boundaries entirely. It only respects function scope.
function check() {
if (true) {
var x = 10; // var ignores the { } block
let y = 20; // let is confined to the block
}
console.log(x); // ✅ 10 — var leaked out of the if-block
console.log(y); // ❌ ReferenceError — let stayed inside
}
Look closely: x and y were declared on adjacent lines inside the same if. Yet x escaped the block and y didn’t. That’s the whole var problem in one example — it’s loose about where it lives, which leads to bugs that are hard to spot.
Default to const, reach for let, avoid var
In modern JavaScript, declare everything with const first. If you genuinely need to reassign the variable later, switch that one to let. You almost never need var — its block-ignoring behavior causes more surprises than it solves. Reserving var for “never, unless I’m maintaining old code” is a habit that quietly removes a whole category of bugs from your work.
The classic var-in-a-loop trap
The most famous victim of var’s loose scoping is a loop that creates functions. Because every iteration shares the same var i, all the functions end up reading the final value:
// With var — all three print 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// prints: 3, 3, 3
// With let — a fresh i per iteration
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// prints: 0, 1, 2
With let, each turn of the loop gets its own brand-new i, so each function captures a different number. This single difference is reason enough to leave var behind. (Don’t worry about why the setTimeout runs later for now — what matters is which i each function sees.)
The scope chain: how JavaScript finds a variable
When your code uses a variable, JavaScript needs to figure out which one you mean. It does this by walking the scope chain: it looks in the current scope first, and if the name isn’t there, it steps outward to the surrounding scope, then outward again, all the way up to global. The first match wins. If it reaches global and still finds nothing, you get a ReferenceError.
const level = "global";
function outer() {
const level = "outer";
function inner() {
console.log(level); // "outer"
}
inner();
}
outer();
Inside inner, JavaScript looks for level. It’s not declared in inner, so it steps out to outer, finds level = "outer" there, and stops. The global level never gets a look, because the search stopped at the first match. This outward-only search is the engine behind everything else in this article.
The search only goes outward, never inward
A function can read variables from the scopes that enclose it, but never from a scope nested inside it, and never from a sibling function. If inner defines a variable, outer cannot see it. The chain is a one-way street pointing out toward global — keep that direction in mind and most “why is this undefined?” puzzles solve themselves.
Closures: functions that remember
Here’s where it all comes together. A closure is what you get when a function holds on to the variables from the scope where it was created, even after that outer scope has finished running. The function “closes over” those variables and carries them along like a backpack.
That sounds abstract, so let’s see it:
function makeGreeter(name) {
// `name` lives in makeGreeter's scope
return function () {
console.log("Hello, " + name + "!");
};
}
const greetJohn = makeGreeter("John Doe");
const greetJane = makeGreeter("Jane Doe");
greetJohn(); // "Hello, John Doe!"
greetJane(); // "Hello, Jane Doe!"
Stop and notice something strange. By the time you call greetJohn(), the makeGreeter function has long since finished — it ran, returned, and exited. Normally, when a function exits, its local variables are gone. Yet the inner function still knows that name is "John Doe". That’s the closure: the returned function kept a live link to the name it was born with, so the variable survived past the death of its parent.
And because each call to makeGreeter creates a fresh scope, greetJohn and greetJane remember different names. They don’t share state — each closure has its own private copy.
A private counter (the closure you’ll actually use)
The textbook example, and a genuinely useful pattern, is a counter that keeps its count hidden from the rest of your code:
function createCounter() {
let count = 0; // private — nothing outside can touch it directly
return {
increment() {
count++;
return count;
},
reset() {
count = 0;
},
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.increment()); // 3
counter.reset();
console.log(counter.increment()); // 1
count is locked away inside createCounter. There is no counter.count to read or accidentally overwrite from the outside — the only way to change it is through the increment and reset methods you were given. The closure gives you data privacy: a value that persists between calls but can only be touched through a controlled door. This is the foundation of countless real patterns, from caching to modules to event handlers that remember their setup.
A closure captures the variable, not a snapshot
This catches people out: a closure holds a live reference to the variable, not a frozen copy of its value at creation time. If the variable changes later, the closure sees the new value. That’s exactly why the count above keeps climbing across calls instead of resetting to its starting value — the closure and the original scope are looking at the same count, not two separate ones.
Why closures matter in real code
Closures aren’t a party trick — you’re already using them constantly, often without naming them. A few places they show up every day:
- Event handlers that need to remember something from when they were set up:
function setupButton(label) {
const button = document.querySelector("#save");
button.addEventListener("click", () => {
// closes over `label` — still available when the click happens later
console.log("Clicked: " + label);
});
}
- Function factories — building specialized functions from a general one:
function multiplyBy(factor) {
return (n) => n * factor;
}
const double = multiplyBy(2);
const triple = multiplyBy(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
double permanently remembers factor = 2; triple remembers factor = 3. Same factory, two functions with two different memories baked in. Once you spot this pattern, you’ll see it all over real codebases — it’s a clean way to configure behavior up front.
A common gotcha: leaking globals
One last trap that ties scope back to everyday bugs. If you assign to a variable you never declared, in non-strict code JavaScript quietly creates it as a global — far outside the scope you meant it to live in:
function setup() {
config = { ready: true }; // ❌ no let/const/var — becomes a global!
}
setup();
console.log(config.ready); // true — config leaked into global scope
You probably intended config to be local to setup. Instead it’s now visible everywhere, ready to collide with anything else named config. Always declare your variables, and put "use strict"; at the top of your files — strict mode turns this silent mistake into a loud ReferenceError so you catch it immediately.
Wrapping up
You now understand the two ideas that quietly govern how JavaScript variables behave:
- Scope is where a variable is visible. There’s global, function, and block scope.
letandconstrespect block boundaries;varonly respects function scope and leaks out of blocks — so default toconst, useletwhen you must reassign, and skipvar. - JavaScript finds variables by walking the scope chain — from the current scope outward toward global, taking the first match. The search never goes inward or sideways.
- A closure is a function that remembers the variables from the scope where it was created, even after that scope has finished. It captures the variable itself, not a snapshot of its value.
- Closures give you private state (the counter pattern), power event handlers and function factories, and are already running throughout your code.
- Always declare your variables to avoid accidentally leaking globals; strict mode helps catch the mistake.
Scope and closures are the kind of topic that, once it clicks, makes a dozen other behaviors suddenly make sense. A natural companion to this is the this keyword — another rule about context that confuses people for the same reason, and the subject of the next article. With scope and closures under your belt, you’re ready for it.