Variables, hoisting, and the temporal dead zone

var, let, and const don't just differ in whether reassignment is allowed — they differ in scope rules and in exactly when a variable becomes usable, and "hoisting" is the specific, mechanical reason a var can be read before its own declaration line while a let cannot.

Beginner

3 min read

var is function-scoped and hoisted with an initial value of undefined

console.log(name); // undefined — NOT an error, even though the declaration is below
var name = "Ada";
console.log(name); // "Ada"

Before any code in a function (or the global scope) actually runs, the JavaScript engine scans it and moves every var declaration to the top of its enclosing function — this is "hoisting." The declaration itself moves up, but the assignment stays where it was written, which is why name is genuinely accessible (as undefined, not an error) before the line that assigns it. This is a real, common source of confusing bugs: code that reads a var before its assignment doesn't crash, it just silently reads undefined, which can hide a real logic error for a long time.

let and const are block-scoped, and hoisted differently: the temporal dead zone

console.log(city); // ReferenceError: Cannot access 'city' before initialization
let city = "Cairo";

let and const are hoisted too — but instead of being initialized to undefined like var, they stay in an uninitialized state called the temporal dead zone (TDZ), from the top of their scope until their actual declaration line executes. Accessing a let/const variable during the TDZ throws a real, hard error, rather than silently returning undefined — a deliberate design choice that turns the exact class of "used before assignment" bug that var hides into an immediately visible error instead.

Block scope vs function scope: the classic loop-variable bug

// var — function-scoped, so all three callbacks share the SAME i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // prints 3, 3, 3 — the loop already finished by the time these run
}
 
// let — block-scoped, a genuinely NEW i is created for each iteration
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // prints 0, 1, 2 — each callback closed over its OWN i
}

var has no concept of block scope at all — a var declared inside a for loop's body is actually scoped to the entire enclosing function, meaning all three setTimeout callbacks close over the exact same variable, which has already reached 3 by the time any of them actually runs. let creates a genuinely fresh binding per loop iteration, so each callback closes over its own distinct i — this specific difference is one of the most cited real, practical reasons let replaced var as the default recommendation in modern JavaScript.

const: the binding is immutable, not the value

const user = { name: "Ada" };
user.name = "Grace"; // completely legal — the OBJECT'S CONTENTS can still change
user = { name: "Bob" }; // TypeError — the BINDING itself cannot be reassigned
 
const numbers = [1, 2, 3];
numbers.push(4); // also completely legal, for the same reason

const only prevents the variable itself from being reassigned to point at a different value — it says nothing about whether the value it points to is mutable. An object or array declared with const can still have its properties changed or its contents mutated freely; what's actually locked is the binding, not the data. This is a genuinely common point of confusion, since "constant" sounds like it should mean the whole value is frozen, when it only means the reference can't be reassigned.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why does `console.log(name); var name = 'Ada';` print `undefined` instead of throwing an error?

2. What is the temporal dead zone, and how does it differ from var's hoisting behavior?

3. Why does a `var`-based for loop with setTimeout callbacks print the same final value for every callback, while a `let`-based one prints each iteration's value?