"Hoisting" Doesn't Exist. Here's What Actually Happens.

Indraneel Sinare
Indraneel Sinare
April 23, 2026April 24, 202617 min read
Hero image for "Hoisting" Doesn't Exist. Here's What Actually Happens.

Search the official ECMAScript specification for the word "hoisting" and you'll find absolutely nothing. The term is completely absent from the normative text. It isn't a defined mechanism, a formal phase, or even a technical keyword. Instead, the spec describes the behavior we colloquially call "hoisting" through a much more precise rule:

"The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated."

That is it. The spec never defines "hoisting" as a mechanism and it never describes "moving declarations to the top." The term was invented by the community as a shorthand for a behavior that is actually a side effect of JavaScript's two-phase execution model. Understanding this real mechanism makes the so-called "Temporal Dead Zone" go from mysterious to obvious.

JavaScript Runs Your Code Twice (Sort Of)

Every time JavaScript executes a block of code, whether it is a script, a function body, or a block statement, it goes through two distinct phases. The ECMAScript specification describes these in Sections 9.1 (Environment Records) and 9.4 (Execution Contexts).

Phase 1: Creation (Parsing/Compilation)

The engine reads through the entire code without executing a single line. During this phase, it:

  1. Creates a new Execution Context (think of it as a workspace for that code block)
  2. Sets up the Environment Record, which is the spec's term for the place where variable bindings live. Think of it as a plain JavaScript object where the keys are your variable names and the values are their current state. It is like the engine's internal dictionary.
  3. Scans for all declarations (var, let, const, function, class) and creates bindings for them in the Environment Record.

During this phase, the Environment Record for a block of code might conceptually look like this:

// Conceptual Environment Record at the end of Phase 1
{
  "score": undefined,          // var: created and initialized to undefined
  "name": "<uninitialized>",   // let: created but left uninitialized (TDZ)
  "PI": "<uninitialized>",     // const: created but left uninitialized (TDZ)
  "greet": "<function object>" // function: created, initialized, and assigned
}

The critical detail is how each binding gets created. Different declaration types get different treatment in this phase.

Phase 2: Execution

The engine goes back to the first line and starts running code top-to-bottom, one statement at a time. Assignments happen here. Function calls happen here. Everything you think of as "running code" happens here.

"Hoisting" is what happens when the creation phase makes a binding available before the execution phase reaches the line where you wrote the declaration.

Let me show you exactly how this plays out for every declaration type.

Translating the Spec: A Mini Glossary

Before we dive into the specific behaviors, let's translate the "spec-speak" into developer terms:

  • Binding: This is just a fancy word for a variable or function name. When you "bind" a name to a value, you are essentially saying "from now on, this name refers to this piece of data."
  • Environment Record: The internal dictionary where all your bindings live for a specific scope.
  • VarDeclaredNames: A list the engine creates during Phase 1 containing every variable name it found using the var keyword.
  • VarScopedDeclarations: The actual statements in your code that declared those var names.
  • LexicallyDeclaredNames: Similar to the above, but for names found using let or const.
  • LexicallyScopedDeclarations: The actual statements that declared those let or const names.
  • CreateMutableBinding(name): The engine's command to "reserve a spot in the dictionary for this name, and make sure we can change its value later."
  • InitializeBinding(name, value): The engine's command to "give this newly reserved name its starting value."
  • LexicalBinding: The spec's way of describing the actual connection between a name (like let x) and its initialization.

Now, let's look at how these are applied.

var: Created and Initialized to undefined

The ECMAScript specification (Section 14.3.2) describes var declarations as using VarDeclaredNames and VarScopedDeclarations. During the creation phase, the spec calls CreateMutableBinding for the variable name and then immediately calls InitializeBinding with the value undefined.

Two things to note: var is function-scoped (or globally-scoped if declared at the top level), and its binding is initialized to undefined the moment the Environment Record is created.

console.log(score);  // undefined
var score = 42;
console.log(score);  // 42

Here is what actually happens in each phase:

Phase 1: Creation

  • Scan: The engine finds var score.
  • Create: Calls CreateMutableBinding("score") on the Environment Record.
  • Initialize: Calls InitializeBinding("score", undefined).

Phase 2: Execution

  • Line 1: console.log(score) → engine looks up score → finds undefinedPrints: undefined
  • Line 2: var score = 42 → the var part is a no-op; assignment happens → score is now 42.
  • Line 3: console.log(score) → engine looks up score → finds 42Prints: 42

Nothing moved. The declaration was never relocated to the top of the function. The engine simply created the binding and initialized it to undefined before execution began.

The Global Object Pollution

One of the quirkier side effects of var (and function declarations) is how they interact with the global object: window in browsers or global in Node.js. When you declare a var at the top level, the engine doesn't just create a binding; it actually attaches that variable as a property to the global object.

var theme = "dark";
console.log(window.theme); // "dark"

Contrast this with let and const. While they are globally accessible, they do NOT pollute the global object.

let layout = "grid";
console.log(window.layout); // undefined

This happens because the Global Environment Record is actually composed of two parts: an Object Environment Record (where var and function declarations live, tied to the global object) and a Declarative Environment Record (where let and const live, safely tucked away from the global object).

Here is a more involved example showing var is function-scoped, not block-scoped:

function demo() {
  console.log(x);  // undefined
  if (true) {
    var x = 10;
  }
  console.log(x);  // 10
}
demo();

The var x inside the if block does not stay inside the block. The spec attaches var bindings to the nearest function (or global) Environment Record, not the block. During the creation phase of demo(), the engine finds var x and creates the binding in demo's function-level Environment Record, initialized to undefined.

var also ignores block boundaries in loops. This catches developers off-guard constantly:

function processItems() {
  for (var i = 0; i < 3; i++) {
    // i is scoped to processItems(), NOT this block
  }
  console.log(i); // 3: i leaked out of the loop!
}

processItems();
// Output: 3

Another classic var trap: duplicate declarations are silently allowed:

var userName = "Alice";
var userName = "Bob"; // No error: same variable, just re-assigned

console.log(userName);
// Output: Bob

let: Created but NOT Initialized

Section 14.3.1 of the ECMAScript specification handles let and const declarations differently from var. These use LexicallyDeclaredNames and LexicallyScopedDeclarations. During the creation phase, the spec calls CreateMutableBinding for let (or CreateImmutableBinding for const), but critically, it does NOT call InitializeBinding.

The binding exists in the Environment Record, but it is in an uninitialized state. Any attempt to access an uninitialized binding triggers a ReferenceError. The spec's note in Section 14.3.2 puts it plainly:

"The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated."

This zone between creation and initialization is what the community calls the Temporal Dead Zone (TDZ). It is not a special mechanism. It is just what happens when a binding exists but has not been initialized yet.

console.log(name);  // ReferenceError: Cannot access 'name' before initialization
let name = "JavaScript";
console.log(name);  // never reached

Block scoping works as expected:

let outerVal = "outer";

if (true) {
  let outerVal = "inner"; // completely different binding
  console.log(outerVal);  // "inner"
}

console.log(outerVal); // "outer"

// Output:
// inner
// outer

let cannot be redeclared within the same scope:

let product = "laptop";
let product = "phone"; // SyntaxError: Identifier 'product' has already been declared

What happens in each phase:

Phase 1: Creation

  • Scan: The engine finds let name.
  • Create: Calls CreateMutableBinding("name") on the block's Environment Record.
  • Initialize: None. The binding is left uninitialized.

Phase 2: Execution

  • Line 1: console.log(name) → engine finds the binding but it is uninitialized → Throws: ReferenceError
  • Line 2: Never reached.

Compare this error message carefully. It says "Cannot access 'name' before initialization," not "name is not defined." The engine knows the variable exists (it was created in Phase 1), but it has not been initialized yet.

Now compare with a truly non-existent variable:

console.log(ghost);  // ReferenceError: ghost is not defined

The error message is different because ghost was never created in any Environment Record. The engine has no record of it at all.

const: Block-Scoped, Must Be Initialized

const behaves like let in Phase 1 (registered as uninitialized, TDZ applies), with two additional constraints:

  1. Must be initialized at declaration. You cannot write const x;
  2. Cannot be reassigned after initialization
// This throws at parse time, not even a ReferenceError
const API_URL; // SyntaxError: Missing initializer in const declaration
console.log(PI);  // ReferenceError: Cannot access 'PI' before initialization
const PI = 3.14159;

Phase 1: Creation

  • Scan: The engine finds const PI.
  • Create: Calls CreateImmutableBinding("PI").
  • Initialize: None. The binding is left uninitialized.

Phase 2: Execution

  • Line 1: console.log(PI) → engine finds the binding but it is uninitialized → Throws: ReferenceError

The only difference between let and const surfaces after initialization:

let count = 0;
count = 1;   // Fine. let bindings are mutable.

const max = 100;
max = 200;   // TypeError: Assignment to constant variable.

Important distinction: const prevents reassignment, not mutation. Objects and arrays declared with const can still be modified internally:

const user = { name: "Alice", age: 30 };
user.age = 31; // This works. We are mutating, not reassigning.
console.log(user); // { name: "Alice", age: 31 }

user = { name: "Bob" }; // TypeError: Assignment to constant variable.

Function Declarations: Created, Initialized, AND Assigned

Function declarations get the most aggressive treatment during the creation phase. The spec (Section 10.2.11, FunctionDeclarationInstantiation) does all three steps at once: it creates the binding, initializes it, and assigns the actual function object to it.

This is why you can call a function declaration before the line where it appears:

greet("World");  // "Hello, World!"

function greet(name) {
  console.log("Hello, " + name + "!");
}

Phase breakdown:

Phase 1: Creation

  • Scan: The engine finds function greet(name) { ... }.
  • Create: Calls CreateMutableBinding("greet").
  • Initialize: Calls InitializeBinding("greet", <function object>).
  • Ready: greet is fully usable before any code runs.

Phase 2: Execution

  • Line 1: greet("World") → engine finds the function object → Prints: "Hello, World!"
  • Line 3: function greet(...) → No-op (already processed).

This is the only declaration type where the full value (not just undefined) is available from the very beginning of the scope. The spec does this because function declarations are treated as part of the scope's structure, not as sequential statements.

Here is a more practical example showing order does not matter:

console.log(add(2, 3));      // 5
console.log(multiply(4, 5)); // 20

function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

Both functions work because both were fully initialized during Phase 1.

Function Expressions: They Follow Their Variable's Rules

A function expression is a function assigned to a variable. The "hoisting" behavior depends entirely on which keyword (var, let, or const) you use.

Function Expression with var

console.log(typeof sayHi);  // "undefined"
sayHi();                     // TypeError: sayHi is not a function

var sayHi = function() {
  console.log("Hi!");
};

sayHi();                     // "Hi!"

Phase 1: Creation

  • Scan: The engine finds var sayHi.
  • Create: Calls CreateMutableBinding("sayHi").
  • Initialize: Calls InitializeBinding("sayHi", undefined).
  • Note: The function expression itself is not evaluated yet.

Phase 2: Execution

  • Line 1: typeof sayHi → engine finds undefinedReturns: "undefined"
  • Line 2: sayHi() → engine tries to call undefinedThrows: TypeError
  • Line 4: sayHi = function... → Assignment happens; sayHi now holds the function.
  • Line 8: sayHi() → engine finds the function → Prints: "Hi!"

The variable sayHi is created and initialized to undefined (because var), but the function is not assigned until the engine reaches line 4 during execution.

Function Expression with let or const

sayHello();  // ReferenceError: Cannot access 'sayHello' before initialization

const sayHello = function() {
  console.log("Hello!");
};

Phase 1: Creation

  • Scan: The engine finds const sayHello.
  • Create: Calls CreateImmutableBinding("sayHello").
  • Initialize: None. Binding is uninitialized (TDZ).

Phase 2: Execution

  • Line 1: sayHello() → engine finds uninitialized binding → Throws: ReferenceError

Since const (and let) do not initialize the binding during creation, you cannot access the variable at all before the declaration line.

Arrow Functions: Same Rules as Function Expressions

Arrow functions are syntactic sugar. Under the hood, they behave exactly like function expressions for purposes of hoisting. Whatever keyword you use to declare the variable (var, let, const) determines the Phase 1 behavior.

Arrow Function with var

console.log(double);  // undefined
double(5);            // TypeError: double is not a function

var double = (n) => n * 2;

console.log(double(5));  // 10

Same as a var function expression: binding exists as undefined, function assigned later.

Arrow Function with const

triple(3);  // ReferenceError: Cannot access 'triple' before initialization

const triple = (n) => n * 3;

Same as a const function expression: binding exists but is uninitialized (TDZ).

Arrow functions have no this binding of their own, no arguments object, and cannot be used as constructors. But none of those differences affect their hoisting behavior.

Named Function Expressions: The Scope Trick

A named function expression has a name, but that name is only accessible inside the function body itself:

const factorial = function computeFactorial(n) {
  if (n <= 1) return 1;
  return n * computeFactorial(n - 1); // name accessible inside
};

console.log(factorial(5));      // 120
console.log(computeFactorial);  // ReferenceError: computeFactorial is not defined

// Output:
// 120
// ReferenceError: computeFactorial is not defined

For hoisting: factorial follows const rules (TDZ). computeFactorial is never registered in the outer scope at all.

The Full Comparison

JavaScript Hoisting Comparison Table

The pattern is consistent: var always initializes to undefined during the creation phase, while let and const are left uninitialized. Function declarations are unique because they are assigned their full function object immediately. Everything else follows the specific variable keyword's rules.

The TDZ Is Not Magic, It Is Just "Uninitialized"

The Temporal Dead Zone is the period between when a binding is created (Phase 1) and when it is initialized (Phase 2 reaching the declaration). Here is a visual timeline:

// --- TDZ for `x` starts here (binding created, not initialized) ---

console.log(x);  // ReferenceError

// --- TDZ for `x` continues ---

let x = 42;      // InitializeBinding("x", 42) → TDZ ends

// --- x is now accessible ---

console.log(x);  // 42

The TDZ is temporal (time-based), not spatial (position-based). Here is proof that it is about when code runs, not where it is written:

  // TDZ for `score` starts here
  const getScore = () => score; // declaring a function that references score is fine

  let score = 42; // TDZ ends here

  console.log(getScore()); // 42: works because score is initialized by the time this runs

// Output:
// 42

The function captures score, but since the function is called after score is initialized, no TDZ error occurs. The TDZ ended the moment execution crossed let score = 42.

Here is a subtler TDZ example that trips up experienced developers:

let a = 1;

function check() {
  console.log(a);  // ReferenceError, not 1
  let a = 2;
}

check();

You might expect console.log(a) to print 1 (the outer a), but it does not. When check() runs, Phase 1 scans the function body and finds let a. It creates a binding for a in check's block Environment Record. This shadows the outer a. When Phase 2 tries to execute console.log(a), it finds the local a (not the outer one), but the local a is uninitialized. ReferenceError.

Why the Spec Designed It This Way

var was the original variable keyword in JavaScript (1995). Its behavior of initializing to undefined was a pragmatic choice for a language that needed to be simple and forgiving. No errors, just undefined.

When TC39 designed let and const for ES2015, they had the benefit of two decades of experience with var's footguns. The TDZ was introduced primarily because of const. A const variable that could be read before its initializer runs would sometimes return undefined, which directly contradicts const's guarantee of having exactly one value. TDZ prevents that inconsistency, and let was given the same treatment for consistency.

Here is the difference in practice:

// With var: silent bug
function buggy() {
  console.log(count);  // undefined (no error, but this is almost always a mistake)
  // ... 50 lines of code ...
  var count = items.length;
}

// With let: loud error
function safe() {
  console.log(count);  // ReferenceError (you know immediately something is wrong)
  // ... 50 lines of code ...
  let count = items.length;
}

The trade-off was: var is forgiving but hides bugs. let/const are strict but surface mistakes immediately.

Putting It Together: The Classic var-in-a-Loop Problem

This is one of the most famous JavaScript gotchas, and it ties directly back to everything we have discussed:

// The infamous loop + closure problem
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}

// Output after 100ms:
// 3
// 3
// 3

Why? var i is function-scoped. There is only one i across all loop iterations. By the time the callbacks run, the loop is done and i is 3.

The fix is let, which creates a new block-scoped binding per iteration:

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

// Output after 100ms:
// 0
// 1
// 2

This is not a special let feature. It is a direct consequence of block scoping. Each iteration of the for loop creates a new block, and let creates a new binding in each block's Environment Record. Each callback closes over its own i.

Best Practices for Every Declaration Type

var

Avoid it in modern JavaScript. There is no case where var does something useful that let or const cannot do better. Its function scoping and implicit undefined initialization create opportunities for silent bugs. If you are maintaining legacy code that uses var, understand it but do not add new var declarations.

let

Use let for values that will be reassigned. Declare it as close to first usage as possible, and always assign a value on the same line when you can.

// Prefer this
let total = 0;
for (let i = 0; i < items.length; i++) {
  total += items[i].price;
}

// Not this
let total;
let i;
// ... 20 lines later ...
total = 0;

const

Use const by default. If you do not need to reassign the variable, use const. It communicates intent (this binding will not change) and triggers an error if someone accidentally reassigns it.

const API_URL = "https://api.example.com";
const users = [];         // The array reference is constant, but you can still push/pop
users.push({ name: "Alice" }); // This works fine
const config = Object.freeze({ debug: false }); // Use freeze if you need deep immutability

Function Declarations

Use function declarations when you want the function to be available throughout the entire scope. They work well for top-level utility functions in a module where you want to organize helper functions at the bottom and main logic at the top.

// Main logic at the top, readable like a story
export function processOrder(order) {
  const validated = validateOrder(order);
  const total = calculateTotal(validated);
  return formatReceipt(validated, total);
}

// Helper functions below
function validateOrder(order) { /* ... */ }
function calculateTotal(order) { /* ... */ }
function formatReceipt(order, total) { /* ... */ }

Function Expressions and Arrow Functions

Use const with arrow functions for callbacks, inline logic, and when you want to prevent the function from being reassigned or used before definition.

// Callbacks
const handleClick = (event) => {
  event.preventDefault();
  // ...
};

// Array methods
const doubled = numbers.map((n) => n * 2);

// Module-private functions that should not be used before defined
const parseConfig = (raw) => {
  // ...
};

The Golden Rule

Declare every binding with const. Change to let only when you need reassignment. Do not use var. For standalone named functions, use function declarations. For everything else, use const with arrow functions.

This approach means you will never run into "hoisting" surprises, because const and let will throw an error if you try to use them before their declaration, and function declarations are deliberately designed to be available everywhere in their scope.

Ace the Interview: "What Is Hoisting?"

If an interviewer asks you to explain hoisting, here is an answer that will set you apart from every other candidate who says "declarations get moved to the top":

"Hoisting is a community-coined term. It does not appear as a defined mechanism in the ECMAScript specification. What actually happens is that JavaScript uses a two-phase execution model. In the first phase, the engine creates an Execution Context and scans the code for declarations. It registers each one in the scope's Environment Record, which is like an internal dictionary. The key difference is how each declaration type is treated during this phase: var bindings are created and initialized to undefined, function declarations are created and initialized with the full function object, and let/const bindings are created but left uninitialized, which is what produces the Temporal Dead Zone. In the second phase, the engine executes the code line by line. No code is physically moved. What we call 'hoisting' is simply the result of bindings being registered before execution begins."

This answer works because it shows you understand three things most candidates do not:

  1. The mechanism, not just the symptom. You are not reciting a rule; you are explaining why the rule exists.
  2. The differences between declaration types. You are not lumping var, let, const, and function together. You know each one gets different treatment.
  3. The correct terminology. Saying "Environment Record" and "Execution Context" instead of vague hand-waving about "the compiler" signals that you have actually read (or at least studied) the specification.

Your Mental Model Cheat Sheet

When you are debugging or explaining code on a whiteboard, use this mental model instead of the "moving to the top" myth:

  • Is this a var? → It is undefined until its line runs.
  • Is this let or const? → It exists but is in the TDZ. Touch it and you will get a ReferenceError.
  • Is this a function declaration? → It is fully available from line 1.
  • Is this a function expression or arrow function? → Follow the variable's rules.

Once you understand that "hoisting" is a community-invented metaphor for a symptom, not a cause, the concept stops being a quirky interview question and becomes a predictable, logical consequence of how the language is designed. No code moves anywhere; it is all just the result of JavaScript's two-phase execution model.

References