hoisting in JavaScript
Hoisting is JavaScript's behavior of registering variable and function declarations in memory during compilation, before the first line of code runs.
Theory
TL;DR
- Think of a roll call before class: the teacher marks everyone present before the lesson starts. Declarations are noted, but actual values come later.
- Function declarations are fully hoisted. You can call them before they appear in the file.
vardeclarations are hoisted and initialized toundefined. Accessing one before its assignment line returnsundefined, not an error.letandconstare hoisted but not initialized. Accessing them before their declaration throws aReferenceError. This gap is called the temporal dead zone (TDZ).- Default choice:
constfor values that won't change,letfor variables that will,varonly in legacy codebases.
Quick example
// Function declaration - fully hoisted, this works
console.log(add(2, 3)); // 5
function add(a, b) {
return a + b;
}
// var - hoisted as undefined
console.log(name); // undefined (not an error)
var name = "Alice";
// let/const - temporal dead zone
console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 25;The engine already knows about add, name, and age before line 1 runs. But only add has its full value at that point.
Key difference
The JavaScript engine makes two passes over your code. In the first pass (compilation), it scans for all declarations and registers them in memory. Function declarations get their full function object right away. var variables get undefined. let and const get registered but stay uninitialized, which is why accessing them early throws instead of quietly returning undefined. That difference is intentional: the TDZ exists to catch bugs that var used to hide.
When to use
- Function declarations: When you need to call a helper at the top of a file while defining it below. Common in Express route files and Node.js modules.
var: Avoid in modern code. The only real case is maintaining code that must run in IE8 or older environments.let/const: Default for all new code.constprevents accidental reassignment;letsignals the value will change.
How the engine handles this
V8 (Chrome/Node.js) and SpiderMonkey (Firefox) both do two passes. Pass one: scan the scope, allocate memory, set initial values. Pass two: execute line by line. For var and function declarations, pass one sets a real initial value. For let/const, pass one only registers the name. The value comes in pass two, at the exact declaration line.
Common mistakes
Mistake 1: Expecting var to respect block scope
function test() {
console.log(y); // undefined - not an error
if (true) {
var y = 10;
}
console.log(y); // 10 - y is function-scoped, not block-scoped
}
test();var ignores block boundaries. y is hoisted to the function scope, so both console.log calls see it. The first returns undefined, the second returns 10. Switching to let makes the first line throw a ReferenceError, which is usually the behavior you actually want.
Mistake 2: Refactoring a function declaration into an arrow function
// Works today
processData(rawData);
function processData(data) {
return data.map(x => x * 2);
}
// After a refactor - breaks
processData(rawData); // TypeError: processData is not a function
const processData = (data) => data.map(x => x * 2);Arrow functions and function expressions are not hoisted. If someone converts a function declaration to a const, any call above the definition breaks immediately. I've seen this burn teams during refactors where CI passed locally but failed once file order changed in the build.
Mistake 3: The classic var loop closure bug
// Prints 3, 3, 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints 0, 1, 2
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}var i is hoisted to the function scope. All three callbacks reference the same i, which equals 3 by the time they run. let i creates a new binding per iteration.
Mistake 4: Thinking function expressions are hoisted
foo(); // TypeError: foo is not a function
var foo = function() { return 42; };var foo is hoisted as undefined. The function body is not. Calling foo() before the assignment is calling undefined().
Real-world usage
- Express.js: Route handlers as function declarations can reference middleware defined below them in the same file.
- Node.js modules: Public functions at the top, private helpers at the bottom. Works because function declarations are fully hoisted within the module.
- Jest/Mocha:
describe()blocks can reference test helper functions defined after them. - React class components: Lifecycle methods and
render()can call helper methods defined anywhere in the class body.
Follow-up questions
Q: What is the temporal dead zone?
A: The TDZ is the period between entering a scope and reaching the let/const declaration line. During this window, the variable is registered but not initialized. Accessing it throws a ReferenceError. This differs from var, which is initialized to undefined the moment the scope starts.
Q: Why does console.log(x) print undefined instead of throwing when var x is declared below?
A: Because var x is hoisted and immediately initialized to undefined. The engine treats it as var x = undefined; console.log(x); x = 5;. The assignment stays on the original line, but the declaration with its default value moves up.
Q: Can you hoist a function expression or arrow function?
A: No. Only function declarations hoist with their full body. Function expressions assigned to var hoist as undefined. Assigned to let/const, they sit in the TDZ. Calling either before the line throws.
Q (Senior): let and const are hoisted but not initialized. Why does the spec bother hoisting them at all?
A: Because hoisting determines scope ownership. If let x is inside a block, JavaScript needs to know that x belongs to that block, not an outer scope. Without registration in pass one, the engine would walk up the scope chain and potentially find an outer x, giving you a value instead of an error. The TDZ guarantees the variable is claimed by its block before becoming accessible.
Examples
Function declaration hoisting inside a module
// Calling before definition - works because of hoisting
const logger = createLogger("APP");
logger("Server started"); // [APP] Server started
function createLogger(prefix) {
return function(message) {
logMessage(message);
};
// logMessage is hoisted within createLogger's scope
function logMessage(msg) {
console.log(`[${prefix}] ${msg}`);
}
}createLogger is hoisted to the module scope. Inside it, logMessage is hoisted to createLogger's scope. The returned function can call logMessage safely even though it appears after the return statement.
The var loop closure bug and the fix
// Classic interview trap
function withVar() {
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
}
withVar(); // 3, 3, 3 - i is shared across all iterations
// Fix with let
function withLet() {
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
}
withLet(); // 0, 1, 2 - each iteration gets its own iOne of the most common hoisting questions in JavaScript interviews. var hoists i to the function scope, so all three closures capture the same variable. By the time the callbacks run, the loop has finished and i is 3. let creates a fresh binding per iteration.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.