Scope and Closures Explained in Plain English
· 2 min read
This lesson covers the idea beginners find hardest, so take it slowly: scope and closures.
This lesson covers the idea beginners find hardest, so take it slowly: scope and closures. Get comfortable here, because the next four lessons on asynchronous JavaScript all lean on this.
Understanding Scope
Scope is simply: where can a variable be seen and used? We touched this in lesson 2 with let versus var, but now let's build the full picture. JavaScript has function scope and block scope, and variables declared inside one are invisible outside it.
function outer() {
const secret = "only visible in outer";
console.log(secret);
}
outer();
console.log(secret);
Calling outer() prints the message fine. But the final console.log(secret), outside the function, throws a ReferenceError — secret doesn't exist out there. This containment is deliberate and useful: it means two completely different functions can each use a variable named result or data without any risk of collision.
The Scope Chain
Nested functions, though, can see variables from the scope they're defined inside — this is the scope chain.
function makeGreeting() {
const greeting = "Hello";
function sayIt(name) {
console.log(greeting + ", " + name);
}
sayIt("Tony Stark");
}
makeGreeting();
sayIt can use greeting even though greeting belongs to makeGreeting, because sayIt is defined inside makeGreeting's scope. JavaScript looks outward, from the inner function to the outer one, whenever it can't find a variable locally.
What Closures Actually Are
Now the main event: a closure is what you get when an inner function keeps access to its outer function's variables, even after the outer function has already finished running. This sounds abstract, so here's the concrete case that makes it click.
function makeCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter());
console.log(counter());
console.log(counter());
makeCounter() runs once and returns an inner function. Normally you'd expect count to disappear once makeCounter finishes — but it doesn't, because the returned inner function "closed over" count, keeping it alive in memory for as long as something still references that inner function. Each call to counter() increments the same count, printing 1, then 2, then 3. This is a closure: private state that persists between calls, with no global variable involved and no way for outside code to reach in and change count directly, except through the function we deliberately exposed.
This exact pattern — private data plus a controlled way to access it — is how a lot of real JavaScript achieves something similar to "private" variables in other languages, well before any class syntax gets involved.
Closures Inside Loops
Closures also explain a classic beginner bug involving loops and asynchronous code (which we'll formally meet next lesson) — but you can see the scoping half of it right now.
for (var i = 1; i <= 3; i++) {
setTimeout(function () {
console.log("var i is now:", i);
}, 100);
}
for (let j = 1; j <= 3; j++) {
setTimeout(function () {
console.log("let j is now:", j);
}, 100);
}
setTimeout delays running its function (more on this next lesson). With var, all three delayed functions share the exact same i, because var is function-scoped, not block-scoped — by the time the delayed functions actually run, the loop has already finished, and i sits at 4. So you get "var i is now: 4" printed three times. With let, each loop iteration gets its own fresh j, because let is block-scoped — so each delayed function closes over a different j, correctly printing 1, 2, and 3. This single example is a huge, concrete reason modern JavaScript prefers let over var, beyond the general reasoning from lesson 2.
A practical tip: whenever you see a function defined inside another function, ask what outer variables it's using. If it's using any, you're looking at a closure, and that inner function will keep those variables alive for as long as it exists — which is powerful, but also means closures can accidentally keep large data in memory longer than you intended if you're not careful about what you reference.
The Memory Cost Of Closures
It's worth being honest about the cost side of closures too, not just the benefit. Because a closure keeps its outer variables alive in memory for as long as the inner function exists, a closure that accidentally holds onto a large array or a big object (rather than a small counter like our example) can quietly prevent that memory from ever being freed, even long after you're done needing it. This is rarely a problem in small scripts, but in a long-running page — like a single-page app that never fully reloads — it's worth occasionally asking what a closure is actually holding onto, especially inside event listeners that get attached once and never removed.
A final practical habit: whenever a bug involves a value seeming to have the "wrong" or stale value inside a function, closures and scope are one of the first places worth checking, right alongside the type-related checks from earlier lessons. Asking "which variable is this function actually closing over, and where was it defined" resolves a surprising number of otherwise confusing bugs, especially once loops, timers, and event listeners start combining together in the same piece of code.
Hands-on check: write your own makeCounter-style function, but make it return two inner functions from one call — one that increments and one that just reads the current count without changing it — both sharing the same private variable. Seeing two separate functions share one closed-over variable is the clearest way to understand why closures matter.
KEEP READING