Hoisting has a reputation for being confusing, and most of that confusion comes from one bad metaphor: "JavaScript moves your declarations to the top of the file." Nothing moves. Your source code stays exactly as you wrote it.
What actually happens is subtler — and once you understand it, every hoisting quirk becomes completely predictable.
What Hoisting Actually Is
Before JavaScript executes any code in a scope, it does a pass through that scope and registers all declarations. It finds every var, every function, every let, every const, and sets them up in the environment. Then — and only then — execution begins from the top.
The effect looks like declarations moved to the top because they're available before their source line runs. But the code never moved. The engine just knew about them before running the first line.
var: Declared and Initialized Early
var declarations get the most generous treatment. They're registered and initialized to undefined before execution starts:
console.log(name) // undefined — not an error
var name = 'Alice'
console.log(name) // 'Alice'
Mentally, the engine sees this as:
var name = undefined // registered + initialized (hoisting effect)
console.log(name) // undefined name = 'Alice' // the assignment stays here console.log(name) // 'Alice'
The declaration hoisted. The assignment did not. This distinction — declaration vs assignment — is the core of understanding hoisting.
var is function-scoped, not block-scoped:
function example() {
if (true) {
var x = 10 // var ignores the block — scoped to the function
}
console.log(x) // 10 — x is visible throughout the function
}
This is why var inside loops, ifs, and any block leaks into the containing function — because var doesn't see blocks as scope boundaries.
Function Declarations: Fully Hoisted
Function declarations — the function name() {} syntax — are hoisted completely. Name and body, before anything runs:
greet('Alice') // 'Hello, Alice' — works before the declaration
function greet(name) { return Hello, ${name} }
This is intentional and useful. You can organize code by putting utility functions at the bottom and calls at the top. The engine has already registered greet before line 1 executes.
let and const: The Temporal Dead Zone
let and const are also hoisted — but not initialized. From the start of the block to the declaration line, the variable exists in a state where it cannot be read or written. This region is the Temporal Dead Zone (TDZ).
console.log(age) // ReferenceError: Cannot access 'age' before initialization
let age = 25
The error message is precise: "cannot access before initialization." The variable exists — it was hoisted — but it hasn't been initialized yet. That's the TDZ.
{
// TDZ starts here for 'color'
console.log(color) // ReferenceError
// TDZ ends here ↓
let color = 'blue'
console.log(color) // 'blue'
}
Why does the TDZ exist? var's behavior — being undefined before its assignment — is genuinely confusing. You read a variable and get undefined, with no indication whether it was intentionally undefined or just unset. The TDZ makes the mistake loud and immediate: a hard error instead of a silent undefined.
Function Expressions and Arrow Functions: Not Hoisted
Only the variable is hoisted — not the function. And since const/let variables start in the TDZ, accessing them before assignment throws:
// With var — partially hoisted, but wrong result
console.log(add(1, 2)) // TypeError: add is not a function
var add = function(a, b) { return a + b }
// With const — TDZ error console.log(multiply(2, 3)) // ReferenceError: Cannot access 'multiply' before initialization const multiply = (a, b) => a * b
This is a critical distinction. A function declaration can be called before its definition. A function expression cannot.
Class Declarations: Hoisted but TDZ-Protected
Classes hoist like let and const — they're registered but not initialized, so accessing them before the declaration throws:
const obj = new MyClass() // ReferenceError: Cannot access 'MyClass' before initialization
class MyClass { constructor() { this.x = 1 } }
This is deliberate. A class defined before its base class would create impossible situations. The TDZ prevents it.
Hoisting in the Same Scope: When Names Collide
What happens when a var and a function declaration share the same name?
console.log(typeof foo) // 'function'
var foo = 'string' function foo() { return 42 }
console.log(typeof foo) // 'string'
Function declarations win over var during hoisting — when both are processed, the function takes priority. Then execution runs: foo is reassigned to 'string'. By the end, foo is a string. This is a sign that something has gone wrong in the code — names should never collide like this.
The Output Question You'll See in Interviews
function outer() {
console.log(x) // what logs here?
var x = 1
console.log(x) // what logs here?
function inner() { console.log(x) // what logs here? var x = 2 console.log(x) // what logs here? }
inner() console.log(x) // what logs here? } outer()
Output: undefined, 1, undefined, 2, 1
Trace: outer hoists var x (undefined) and function inner. First log: undefined. Assignment: x = 1. Second log: 1. Calling inner: inner has its own var x hoisted to undefined. Third log: undefined. Assignment x = 2. Fourth log: 2. Back in outer, x is still 1 — inner had its own x. Fifth log: 1.
The key: every function scope has its own hoisting. inner's var x does not affect outer's var x.
A Practical Rule Set
Use const by default. No hoisting surprise, no accidental reassignment, intention is clear.
Use let when reassignment is needed. TDZ protects you from use-before-declaration bugs.
Use function declarations for named utilities. Intentional hoisting lets you put the important code at the top and helpers at the bottom.
Never use var in new code. Its function-scoped, initialization-to-undefined behavior has caused more bugs than it has ever prevented. Every var can be replaced with const or let.
Never call a function expression before its line. Unlike declarations, expressions aren't callable until execution reaches their assignment.
The Mental Model That Makes It Click
Think of JavaScript execution as two-phase:
Phase 1 — Setup (hoisting): The engine scans the scope. Every var is registered and set to undefined. Every function declaration is registered with its full body. Every let, const, and class is registered but left uninitialized (TDZ).
Phase 2 — Execution: Code runs line by line. Assignments happen. Function expressions are created. let and const become initialized when their declaration line is reached.
With this model, every hoisting behavior follows logically. var before its assignment: undefined (initialized in phase 1, assigned in phase 2). Function declaration before its definition: works (fully set up in phase 1). let before its declaration: ReferenceError (registered but not initialized — TDZ).
Nothing moves. The engine just knows some things before you tell it.