Scope and Closures
var, let, and const
Section titled “var, let, and const”JavaScript has three declaration keywords, each with different scoping rules:
| Keyword | Scope | Hoisted | Reassignable | Re-declarable |
|---|---|---|---|---|
var | Function | Yes (as undefined) | Yes | Yes |
let | Block | Yes (TDZ) | Yes | No |
const | Block | Yes (TDZ) | No | No |
Block scope means the variable only exists between the nearest {} braces. Function scope means the variable lives for the entire function body, regardless of any inner blocks.
function example() { if (true) { var x = 1; // function-scoped — leaks outside the if let y = 2; // block-scoped — stays inside the if const z = 3; // block-scoped } console.log(x); // 1 — visible here console.log(y); // ReferenceError: y is not defined}Hoisting
Section titled “Hoisting”Declarations are hoisted — moved to the top of their scope by the engine before execution.
var hoisting initialises the variable as undefined immediately. let/const are hoisted but stay in a Temporal Dead Zone (TDZ) — any access before the declaration line throws a ReferenceError.
// @expect-throwconsole.log(a); // undefined — var is hoisted and initialisedvar a = 5;
console.log(b); // ReferenceError — let is in TDZlet b = 10;Function declarations are also fully hoisted — you can call them before the line where they are written. Function expressions (assigned to variables) are not.
Lexical scope
Section titled “Lexical scope”Scope in JavaScript is lexical (also called static): a function’s scope is determined by where it is written in the source, not where it is called from. An inner function can always read variables from its surrounding outer scopes.
const prefix = 'LOG';
function outer() { const level = 'INFO';
function inner(msg) { // inner can read both prefix (module scope) and level (outer scope) console.log('[' + prefix + ':' + level + '] ' + msg); }
inner('started');}
outer(); // [LOG:INFO] startedClosures
Section titled “Closures”A closure is a function that retains access to the variables of its enclosing scope even after that outer function has returned. The inner function does not copy the values — it holds a live reference to the variables themselves.
A classic use case is a counter factory:
function makeCounter(start) { let count = start;
return { increment: function() { count += 1; }, decrement: function() { count -= 1; }, value: function() { return count; }, };}
const counter = makeCounter(0);counter.increment();counter.increment();counter.increment();counter.decrement();console.log(counter.value()); // 2count lives in makeCounter’s scope. Every function returned from makeCounter holds a reference to that same count variable.
Runnable closure demo
Section titled “Runnable closure demo”Note that c1 and c2 are completely independent — each call to makeCounter creates a fresh count variable in a new scope, and each returned object closes over its own copy.
The var loop-closure bug
Section titled “The var loop-closure bug”This is the single most-repeated closure bug: var is function-scoped, so a for loop using var has exactly one i variable shared by every iteration. By the time any deferred callback runs, the loop has already finished and i holds its final value — every callback sees the same, wrong number. let fixes this because the loop creates a fresh binding of i for every iteration, and each callback closes over its own copy.
flowchart TD
subgraph var loop - one binding
V0["i (shared)"] --> V1["callback 0 reads i"]
V0 --> V2["callback 1 reads i"]
V0 --> V3["callback 2 reads i"]
V4["loop finishes: i = 3"] --> V0
end
subgraph let loop - one binding per iteration
L0["i = 0"] --> L1["callback 0 reads i = 0"]
L2["i = 1"] --> L3["callback 1 reads i = 1"]
L4["i = 2"] --> L5["callback 2 reads i = 2"]
end The same bug bites hardest with real async callbacks (setTimeout, a promise .then(), an event listener) because by the time any of them fires, the loop has always already finished — there is never a “fast enough” var loop that avoids this.
Automatic disposal: using and Symbol.dispose
Section titled “Automatic disposal: using and Symbol.dispose”Node 24 adds using (and await using) declarations: a resource with a [Symbol.dispose]() (or [Symbol.asyncDispose]()) method is automatically disposed the moment the enclosing block exits — on a normal return and on a thrown error — without a manual try/finally. This is block scope put to work for resource cleanup: a using binding is scoped exactly like let, and disposal happens at the point the block’s scope ends.
Output (verified on Node 24)
--- normal exit ---
using r1: r1
disposed: r1
after block
--- exit via throw ---
disposed: r2
caught: boomBefore using, the same guarantee required a manual try/finally around every resource — easy to forget, and easy to get wrong when a function has more than one resource to release.
Mental Model
Section titled “Mental Model”Every { } block that introduces let, const, or using bindings is a fresh scope — think of a loop with let as creating a brand-new stack frame per iteration, each with its own i, rather than one shared box that every iteration writes into. var never gets this treatment: it always writes into the single binding that belongs to the nearest enclosing function (or the module, if there is no function). using reuses that same “block creates its own lifetime” idea, but ties disposal — not just variable lifetime — to when the block’s scope ends.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
var | Familiar to anyone from older JS; hoists to the top of the function | Function-scoped, re-declarable, and shares one binding across every loop iteration — the source of the classic closure bug |
let/const in loops | A correct, independent binding per iteration; TDZ catches use-before-declare | Slightly more allocation than a single var (irrelevant in practice) |
Manual try/finally cleanup | Works in every JS version, no new syntax | Verbose with multiple resources; easy to forget on one code path |
using/await using | Guaranteed disposal on every exit path (return, throw, break) with no boilerplate | Node 24+ / very recent browsers only; the resource must implement Symbol.dispose/Symbol.asyncDispose |
Common Misconceptions
Section titled “Common Misconceptions”- “A closure copies the value of the variable it captures.” It captures a live reference. If the variable changes later, every closure over it sees the new value — this is exactly why the
varloop bug happens. - “Using
letinstead ofvaris just a style preference.” In a loop with any deferred callback, it is a correctness fix, not a style choice —varproduces a real, reproducible bug. - “
usingis just a fanciertry/finally.” It is more than sugar: it lets each resource declare its own cleanup right at the point it is created, so cleanup for multiple resources reads top-to-bottom instead of nestingfinallyblocks, and it runs the dispose calls in reverse declaration order automatically.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated code that pushes callbacks (setTimeout, array method callbacks stored for later, event listeners registered in bulk) inside a for loop declared with var is one of the most common and hardest-to-spot bugs to review, because the loop runs correctly and the bug only shows up once the deferred callbacks actually fire — often nowhere near the loop in the file or the stack trace.
🐛 Spot the bug
function scheduleGreetings(names) {for (var i = 0; i < names.length; i++) { setTimeout(function() { console.log('Hello, ' + names[i]); }, 100);}}
scheduleGreetings(['Alice', 'Bob', 'Carol']);Show the answer
var i is function-scoped, so there is exactly one i shared by all three setTimeout callbacks. By the time the first timer fires (100 ms later), the loop has already finished and i is 3 — every callback reads names[3], which is undefined, printing “Hello, undefined” three times instead of greeting each name once. The fix is a one-character change: for (let i = ...). let creates a fresh, independent i binding for each iteration, so each callback closes over its own value.