let, const and var: Scope, Hoisting and the Temporal Dead Zone

September 19, 2026 · 7 min read

JavaScript has three keywords for declaring a variable. Older code is full of var; modern code uses let and const. They look interchangeable, but they behave differently in ways that cause real bugs — and the usual explanation of why, "declarations are moved to the top of the scope", is wrong in a way that stops being harmless the moment you hit "Cannot access 'x' before initialization".

This post covers the practical differences first, then what's actually going on underneath.

if (true) {
var a = 1
let b = 2
}
console.log(a)
console.log(b)
 
for (var i = 0; i < 3; i++) setTimeout(() => log(i))
for (let j = 0; j < 3; j++) setTimeout(() => log(j))
 
const user = { name: 'Ada' }
user.name = 'Grace'
user = {}
variables
a (var)1 · whole function
b (let)2 · this block only
output
—

var belongs to the whole function (or the whole script). let and const belong to the nearest { } block. Inside the block both exist.

0 / 6

Scope: where a variable exists

A variable's scope is the part of the code where it can be used.

let and const are block-scoped: they exist only inside the nearest pair of curly braces { } — an if, a loop, a function body.

if (true) {
  let message = 'hi';
  console.log(message);   // 'hi'
}
console.log(message);     // ReferenceError: message is not defined

var is function-scoped: it ignores blocks and belongs to the whole function it's declared in (or the whole script, outside any function).

if (true) {
  var message = 'hi';
}
console.log(message);     // 'hi' — it leaked out of the block

Block scope is what you'd expect from almost any other language, and it keeps variables close to where they're used.

The loop bug

This is the most famous var problem:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
// 3, 3, 3

There's only one i for the whole loop. The timers run after the loop has finished, when that single i is 3 — so all three print 3.

Switch to let, and each iteration gets its own i:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}
// 0, 1, 2

let in a for head creates a new binding for each iteration and copies the value forward, so each callback remembers the i from its own iteration — a closure doing exactly what you'd want.

const: can't reassign, can still change

const means the variable can't be pointed at a different value:

const max = 10;
max = 20;   // TypeError: Assignment to constant variable.

But it does not make the value itself unchangeable. If it holds an object or array, you can still modify what's inside:

const user = { name: 'Ada' };
user.name = 'Grace';   // fine — same object, changed contents
user = {};             // TypeError — can't point user at a new object

const list = [1, 2];
list.push(3);          // fine

const protects the variable, not the data. To freeze the data, there's Object.freeze — but you rarely need it.

Using a variable too early

With var, reading a variable before its declaration line gives undefined instead of an error:

console.log(count);   // undefined
var count = 5;

With let and const, it's an error — which is better, because using a variable before you've set it is almost always a mistake:

console.log(total);   // ReferenceError: Cannot access 'total' before initialization
let total = 5;

Why the difference? That's hoisting.

Hoisting: two phases, not code moving

Nothing moves to the top of the file. The engine processes a scope in two phases: before a single statement executes, it scans the scope and creates a binding for every declaration it finds. Then it runs the code. The declarations differ in what state their binding starts in:

creation phase

1console.log(count)
2greet()
3console.log(name)
4var count = 1
5let name = 'Ada'
6function greet() { console.log('hi') }
 

scope bindings

scanning…

Before a single line runs, the engine scans the scope and creates every binding it declares. "Hoisting" is not code moving upwards — it is bindings existing before execution starts.

0 / 6

Four kinds of declaration, four starting states:

DeclarationBinding createdInitial stateAccess before the line
function f() {}YesThe whole functionWorks
var xYesundefinedReturns undefined
let xYesUninitialized (TDZ)ReferenceError
const xYesUninitialized (TDZ)ReferenceError
class C {}YesUninitialized (TDZ)ReferenceError

Every one of them is hoisted — the binding exists before the line runs. The difference is only whether it holds something usable yet.

The error message is the clue

These two errors mean different things, and the wording distinguishes them:

console.log(nope);
// ReferenceError: nope is not defined
//   → no binding anywhere. Typo, or a missing import.

console.log(later);
let later = 1;
// ReferenceError: Cannot access 'later' before initialization
//   → the binding exists. You are in its temporal dead zone.

The second message is telling you the engine knows about later — it found the let during the scan — and is refusing to let you read it yet.

The dead zone is a region of code, not a period of time

Despite the name, "temporal" is a little misleading. The TDZ is the stretch of code from the top of the scope to the initializing statement. Whether that stretch executes in a microsecond or a minute doesn't matter:

function demo() {
  // TDZ for `value` starts here
  const read = () => value;      // fine to *define* — nothing runs yet
  // read();                     // would throw: still in the TDZ
  let value = 42;                // TDZ ends
  return read();                 // 42
}

Closing over a TDZ variable is legal. Only reading it before initialization throws — which is exactly the behaviour that makes mutual recursion between functions work.

Why the TDZ exists

It's not gratuitous strictness. It makes const honest and catches a real class of bug.

With var, reading too early gives undefined, and undefined flows silently through your program until it fails somewhere else entirely:

function totalPrice(items) {
  const total = items.reduce((sum, i) => sum + i.price * taxRate, 0);
  var taxRate = 1.2;      // declared below — undefined during the reduce
  return total;           // NaN, blamed on `items` in the bug report
}

The same code with let throws at the exact line that's wrong, with the variable's name in the message.

The TDZ is also what allows const to mean never unassigned. If const bindings started as undefined like var, there would be a window where a constant held a value it was never assigned — and the engine would have to allow writing to a const at least once, undermining the guarantee.

Function declarations vs function expressions

Only declarations are fully hoisted:

hoisted();      // works
function hoisted() {}

notHoisted();   // TypeError: notHoisted is not a function
var notHoisted = function () {};

alsoNot();      // ReferenceError: Cannot access 'alsoNot' before initialization
const alsoNot = () => {};

Three different failures for three declaration styles, and each error names its cause: the var binding exists and holds undefined (so calling it is a type error), while the const binding is in its TDZ (a reference error).

One more sharp edge: a function declaration inside a block is block-scoped in strict mode and ES modules, but has messy legacy behaviour in sloppy scripts. Declare functions at the top level of a function or module, not inside if blocks.

Side by side

varletconst
ScopeFunctionBlockBlock
Can reassignYesYesNo
Can redeclare in same scopeYesNoNo
Hoisted binding starts asundefinedUninitialized (TDZ)Uninitialized (TDZ)
Use before declarationundefinedReferenceErrorReferenceError
New copy per loop iterationNoYesYes

The rule

  1. Use const by default.
  2. Use let when you genuinely need to reassign — a counter, a value that changes in a loop.
  3. Don't use var in new code. This isn't style preference — let and const turn a silent undefined into an immediate, named error.

Starting from const has a nice side effect: when you see let, it's a signal to the reader that this value is going to change, so it's worth watching. Declare variables close to where they're used — the TDZ only bites code that reads a variable above its declaration, which is worth not writing anyway. And read the error message carefully: "is not defined" and "cannot access before initialization" point at completely different mistakes.