Community Article

Hoisting: The Mental Model That Finally Stuck

The two-phase entry into every scope: creation phase creates every declaration's binding, execution phase runs the code. Once that order is fixed in your head, var/let/const/TDZ behaviour is mechanical.

Hoisting: The Mental Model That Finally Stuck

The two-phase entry into every scope: creation phase creates every declaration's binding, execution phase runs the code. Once that order is fixed in your head, var/let/const/TDZ behaviour is mechanical.

js-hoisting
scope
js-lexical-scope
fundamentals
interview-prep
oliviadelgado

By @oliviadelgado

February 18, 2026

·

Updated May 18, 2026

362 views

12

4.4 (13)

For years I read the same explanation of hoisting and could not make it stick. "Variable declarations are hoisted to the top of their scope." That sentence is not wrong, but it is too compressed to be useful, and it skips the only part that actually clarifies anything: hoisting is what falls out of how the engine creates a lexical environment, in two phases, before any code runs.

The model that finally clicked for me, after six or seven failed attempts at memorising rules, is that JavaScript does not run code line by line in a single pass. Before any line of a function body or module executes, the engine walks the body and creates bindings for every declaration it finds. Then it runs the code. The order matters; the rules everyone tries to memorise are consequences of that two-phase order, not arbitrary trivia.

Two phases, one body

Every time the engine enters a new scope (a function call, a block, the top level of a module), it does two things in sequence.

First, the creation phase: walk the body, find every var, let, const, function declaration, and class declaration, and create a binding for each one in the lexical environment. var bindings are initialised to undefined. Function declarations are initialised to the function value. let, const, and class bindings are created but left uninitialised; reading them before the actual declaration line runs throws.

Second, the execution phase: run the body line by line, with the bindings already in place.

Two-phase entry into a function
1. creation phase
   walk body
   for each declaration:
     create binding
     var       -> initialise to undefined
     function  -> initialise to function value
     let/const -> create binding, leave uninitialised (TDZ)
     class     -> same as let/const
2. execution phase
   run statements top-to-bottom
   reads/writes hit the existing bindings

That is the whole model. Every "weird" hoisting result is explained by which phase did what.

Why var looks "moved to the top"

console.log(x);   // undefined, not ReferenceError
var x = 5;
console.log(x);   // 5

The phrase "var x is hoisted" is true in the sense that the binding existed before the line that "declares" it. But the binding was not literally moved; it was created during the creation phase, and the assignment = 5 happened only when execution reached that line. Two operations, two phases, no magic.

Function declarations behave the same way, but with the value also available from the start, because the function-declaration binding is initialised during the creation phase.

greet();          // 'hi': declaration was created and initialised in phase 1
function greet() {
    console.log('hi');
}

Function expressions stored in var, on the other hand, are visible only as undefined until the assignment runs:

greet();          // TypeError: greet is not a function
var greet = function () { console.log('hi'); };

The binding greet was created in the creation phase as undefined. By the time execution reached greet(), undefined was the value. Calling undefined as a function is the TypeError.

The temporal dead zone, demystified

let and const give a different behaviour because their bindings are created uninitialised during the creation phase. Reading them between the start of the scope and the line that declares them throws a ReferenceError. That window of "binding exists but cannot be touched" is the temporal dead zone.

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

The binding for y was created when the engine entered this scope. The TDZ ended only at the let y = 7 line. Reading y before then is what the spec catches.

The key insight: the TDZ is not an extra rule layered on top of let/const. It is the natural consequence of "the binding exists but is uninitialised". The behaviour for var (read-as-undefined) is what is unusual; let and const are the spec authors deciding that touching an uninitialised binding should throw, which is the safer default. var predates that opinion.

What the creation phase actually scans

The creation phase looks at declarations only. It does not run any code, so it does not see things like if (cond) var x;. The if is execution-time logic; the var x declaration is hoisted unconditionally, regardless of whether the if would ever take the branch.

function example(cond) {
    if (cond === false) {
        var x = 'never sees daylight';
    }
    console.log(x);   // undefined when cond is true; 'never sees daylight' when false
}

The binding is created in either case. The assignment runs only when the if branch runs. There is no ReferenceError because var declarations ignore block scope; they are scoped to the enclosing function (or to the module/global if outside any function), regardless of how deeply nested in blocks the declaration sits.

let and const are block-scoped, so a let x inside an if is invisible outside it. Trying to read it from outside the block is a regular ReferenceError because the binding does not exist in the outer scope at all.

The two cheat-sheet rules I write at the top of every interview pad

When I am asked a hoisting question, in an interview or on a code review, I run two checks and the answer falls out:

If the binding isBefore the declaration line, reading it gives
varundefined
Function declarationthe function (callable)
let, const, classReferenceError (TDZ)
And the binding is scoped toWhere it exists
var, function declarationthe entire enclosing function (or module/global)
let, const, classthe enclosing block only

Two tiny tables. Every hoisting question I have ever been asked is a special case of these two.

A trick question that shows the model

var x = 1;
function shadow() {
    console.log(x);   // ?
    var x = 2;
}
shadow();

The console.log is undefined. The function shadow has its own x binding, created in the creation phase as undefined. When the log runs, it sees its own x (still undefined), not the outer x. The outer x is shadowed for the entire body, even before the inner var x = 2 line. That last clause is the part that reveals whether the two-phase model has stuck: the shadowing happens because the binding exists from the start of the scope, not from the line that declares it.

If you switch to let, the same shape produces a ReferenceError instead. The inner binding still exists from the start of the block, but it is in the TDZ until let x = 2 runs.

The model is "bindings exist before code runs"

The eight-word version I wish someone had given me on day one: bindings exist before code runs, in their declared scope. Hoisting is what we call the visible side of that fact. var is the messy historical default, let and const add the TDZ as a safer behaviour, function declarations are the only kind of binding that has its full value before execution starts, and every other behaviour is a consequence of these three policies on top of the two-phase entry.

If a hoisting question ever stumps me again, I run the two-phase trace by hand: creation phase first (what bindings exist, in what state), execution phase second (what each line does to those bindings). The trick questions stop being tricks; they become exercises in mechanically applying a two-step rule.

Back to Articles