Hoisting in JavaScript
What the engine does before your code runs — var vs function vs let/const, the TDZ, and the bugs that look like magic until you name the phase.
- javascript
- hoisting
- scope
- var
- let
- const
You write code top-to-bottom. The JS engine does not evaluate it that way on day one of a scope. Before a line runs, the runtime scans the scope for declarations and wires up bindings. People call that hoisting. The word is a teaching shortcut, not a magical “move everything to the top” rewrite of your file.
If you only remember “var is undefined, functions work early,” you’ll still trip on let, class, and default parameters. The useful model is: create bindings first, assign later — and how creation initializes them differs by declaration kind.
The problem hoisting explains
This looks broken until you know the rules:
console.log(x); // undefined — not ReferenceError
var x = 10;
console.log(x); // 10
And this looks fine until it isn’t:
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;
Same “use before line that writes the value” shape. Different binding lifecycle. Interviews and production bugs both live in that gap.
Model: two moments per binding
For every scope (global, function, module, or block for let/const):
- Instantiation — create the binding (and maybe initialize it).
- Evaluation — run statements; assignments happen here.
| Declaration | Scope | Instantiation value | Access before assignment line |
|---|---|---|---|
var |
function / global | undefined |
OK, value is undefined |
function declaration |
function / global (block rules vary) | the function object | OK, fully usable |
let / const |
block | uninitialized (TDZ) | ReferenceError |
class |
block | uninitialized (TDZ) | ReferenceError |
“Hoisted” for var means the binding exists for the whole function. It does not mean the right-hand side ran early.
function demo() {
console.log(a); // undefined
var a = 1;
console.log(a); // 1
}
// Roughly like:
// function demo() {
// var a; // created, undefined
// console.log(a);
// a = 1;
// console.log(a);
// }
Function declarations vs expressions
Function declarations are initialized with the function body during instantiation of the enclosing scope:
// Works: declaration is fully available in the scope
console.log(add(2, 3)); // 5
function add(a, b) {
return a + b;
}
Function expressions only create a value when evaluation reaches the assignment:
console.log(typeof sub); // "undefined" with var
// console.log(sub(5, 1)); // TypeError: sub is not a function
var sub = function (a, b) {
return a - b;
};
// With let/const, even `typeof` of the name can hit TDZ depending on access form:
// console.log(mul); // ReferenceError
const mul = (a, b) => a * b;
Named function expressions hoist only the outer binding you assigned to — not a free-floating name for the whole scope the way a declaration does.
const fact = function factorial(n) {
return n <= 1 ? 1 : n * factorial(n - 1); // internal name works
};
// factorial is not in outer scope
Duplicate var and function merging
var and function declarations in the same function scope can collide. Engines fold them with specific priority rules; the readable takeaway is: don’t dual-declare the same name. Prefer let/const and one declaration style.
function messy() {
console.log(typeof f); // "function" in most engines — declaration wins over var init
var f = 1;
function f() {}
console.log(typeof f); // "number" after assignment
}
If you need to reason about this under pressure, rewrite to one binding. Interviewers care that you know collisions exist, not that you recite ES5 Annex B.
Blocks, var leaks, and let loops
var ignores block braces for scope. Only functions (and modules) fence it:
function f() {
if (true) {
var leaked = 'still function-scoped';
}
console.log(leaked); // works
}
let/const are block-scoped — including for loops, which create a new binding per iteration for let. That interaction is why closures in loops stop returning the final index when you switch from var to let. Full story: Closures and Temporal Dead Zone.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var', i), 0);
}
// var 3, var 3, var 3
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let', j), 0);
}
// let 0, let 1, let 2
Classes and the TDZ
Classes hoist the binding into the TDZ like let. You cannot new them above the declaration:
// new Box(); // ReferenceError
class Box {
constructor(v) {
this.v = v;
}
}
Same idea for const/let used in temporal dependency between declarations in one block.
Default parameters and “temporal” neighbors
Default parameter initializers sit in a special zone relative to the parameter list:
function weird(a = b, b = 2) {
// ReferenceError if called as weird() — `b` is in TDZ when evaluating default for `a`
return [a, b];
}
function ok(a = 1, b = a) {
return [a, b]; // weird(undefined) → [1, 1] once fixed order
}
Order of parameters matters. Treat defaults as sequential let-like bindings.
What hoisting is not
- Not “the engine rewrites your file so assignments move up.”
- Not a reason to call functions before defining them as a style preference.
- Not the same for modules vs scripts in every edge case (
importbindings are live and also in a TDZ until linked). - Not an excuse for
varin new code — useconst/letand put declarations where readers expect them.
Readable code still declares before use. Hoisting knowledge is for debugging, interview precision, and understanding legacy var.
Footguns that show up in real reviews
- Assuming
typeof xis always safe — forvar, yes ("undefined"). Forlet/constin TDZ, accessing the binding throws. - Conditional function declarations — behavior historically differed across browsers in sloppy scripts. Prefer function expressions or always-declared functions.
- Relying on hoisted helpers deep in a file — works for declarations, confuses readers and breaks when someone converts to
const fn = () => {}. - Confusing TDZ with “not hoisted” —
letis hoisted (binding exists); it is not initialized. Say that out loud in interviews.
Interview angle
Prompt: “What is hoisting?”
Strong answer: “During scope instantiation, the engine creates bindings for declarations before evaluating statements. var is initialized to undefined, function declarations are initialized with the function object, and let/const/class stay uninitialized until their declaration line — accessing them early throws (TDZ). Hoisting is about bindings, not moving assignments.”
Follow-ups:
- Difference between function declaration and
const f = function(){} - Why
varin a loop + async callback shares one binding - How this binding is unrelated to hoisting (call-site vs scope creation)
Whiteboard mini-task: predict output of mixed var / function declaration / let in one function without running it.
Related on this site
- Temporal Dead Zone — the
let/consthalf of this story - Closures — loop bindings and captured environments
- this Binding Rules — call-site, not declaration order
- The Event Loop — when deferred callbacks actually read those bindings
- var vs let vs const — choice guide for new code
Further reading
- Hoisting — MDN glossary
- let — MDN
- function declaration — MDN
- javascript.info — Variable scope, closure
Related guides
- Temporal Dead ZoneWhy let and const throw if you touch them early — binding creation vs initialization, TDZ edges with defaults, typeof, and closures.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.