Skip to content

Scope and Closures

JavaScript has three declaration keywords, each with different scoping rules:

KeywordScopeHoistedReassignableRe-declarable
varFunctionYes (as undefined)YesYes
letBlockYes (TDZ)YesNo
constBlockYes (TDZ)NoNo

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
}

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-throw
console.log(a); // undefined — var is hoisted and initialised
var a = 5;
console.log(b); // ReferenceError — let is in TDZ
let 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.

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] started

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()); // 2

count lives in makeCounter’s scope. Every function returned from makeCounter holds a reference to that same count variable.

JavaScript

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.

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
var: one shared binding for the whole loop — let: a new binding per iteration
JavaScript

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.

Node.js
Output (verified on Node 24)
--- normal exit ---
using r1: r1
disposed: r1
after block
--- exit via throw ---
disposed: r2
caught: boom

Before 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.

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.

ChoiceBenefitCost
varFamiliar to anyone from older JS; hoists to the top of the functionFunction-scoped, re-declarable, and shares one binding across every loop iteration — the source of the classic closure bug
let/const in loopsA correct, independent binding per iteration; TDZ catches use-before-declareSlightly more allocation than a single var (irrelevant in practice)
Manual try/finally cleanupWorks in every JS version, no new syntaxVerbose with multiple resources; easy to forget on one code path
using/await usingGuaranteed disposal on every exit path (return, throw, break) with no boilerplateNode 24+ / very recent browsers only; the resource must implement Symbol.dispose/Symbol.asyncDispose
  • “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 var loop bug happens.
  • “Using let instead of var is just a style preference.” In a loop with any deferred callback, it is a correctness fix, not a style choice — var produces a real, reproducible bug.
  • “using is just a fancier try/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 nesting finally blocks, and it runs the dispose calls in reverse declaration order automatically.

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.

What is the key difference between let and var regarding scope?
What is a closure?
What happens when you access a let variable before its declaration line?
What does this print?
const fns = [];
for (var i = 0; i < 3; i++) {
  fns.push(function() { return i; });
}
console.log(fns.map(function(fn) { return fn(); }));
What prints, and what is the minimal fix?
function scheduleGreetings(names) {
  for (var i = 0; i < names.length; i++) {
    setTimeout(function() {
      console.log(names[i]);
    }, 0);
  }
}
scheduleGreetings(['Alice', 'Bob']);