JavaScriptPublished 8/14/2023

Hoisting: What's Actually Getting Hoisted

A deep dive into how hoisting really works — why var comes back undefined, why let and const throw instead, and why you can call a function before you've even written it.

A Snippet That Breaks Your Intuition

Take a look at this code and guess what happens:

js
console.log(name) // ?
var name = '小明'

If your gut says this should throw, since name hasn't been declared yet, you'd be wrong. It logs undefined, not a ReferenceError.

Here's another one:

js
sayHi() // ?
 
function sayHi() {
  console.log('嗨!')
}

This one runs fine too, and logs 嗨!. The function hasn't even been defined yet at that point in the code — so how can it already be callable?

The mechanism behind both of these is what this post is about: hoisting.

Hoisting Isn't "Moving Code to the Top"

A lot of people's mental model stops at "JavaScript moves variable and function declarations to the top of the file." That's directionally right, but not precise enough.

To actually understand hoisting, you need to know that the JavaScript engine runs code in two phases:

  1. Creation phase: the engine scans through the entire block of code first, reserving a spot in memory for every name declared with var, let, const, or function.
  2. Execution phase: only now does the engine actually start running the code line by line — this is when assignments happen and functions get called.

Hoisting describes what happens in that creation phase: the name gets registered early, but the assignment doesn't. What differs is what happens when you try to read that name right after it's registered — and that answer depends entirely on how it was declared. That's why var comes back undefined, let throws, and a function declaration can be called immediately. Let's break each one down.

var: Hoisted With an undefined Placeholder

A variable declared with var gets its name registered during the creation phase, and it's initialized to undefined right away. So the snippet from earlier is effectively equivalent to this, as far as the engine is concerned:

js
var name = undefined // done during the creation phase
 
console.log(name) // logs: undefined
name = '小明' // the actual assignment happens here, during execution

That's why console.log(name) logs undefined instead of throwing. The name name already exists by the time the creation phase finishes — it just hasn't been assigned its real value yet.

Function Declarations: The Whole Function Gets Hoisted

Function declarations — the function sayHi() {} style — get the most generous treatment of all. During the creation phase, the engine doesn't just register the name; it sets up the entire function body too. That's why calling sayHi() at the very top of the file just works: by the time the creation phase is done, sayHi is already a fully callable function, not undefined.

But this only holds for function declarations. Swap in a function expression and you get a completely different result:

js
sayHi() // throws: sayHi is not a function
 
var sayHi = function () {
  console.log('嗨!')
}

Here, sayHi is a var-declared variable that happens to be assigned a function. Following the var rules from before, the creation phase only initializes sayHi to undefined — the actual assignment (plugging in the function) doesn't happen until execution. So calling sayHi() before that assignment is really calling undefined(), which throws.

let and const: Hoisted, But Locked

This is where most people get it wrong. A lot of people assume let and const aren't hoisted at all — but they are. Variables declared with let and const also get their names registered during the creation phase. The difference is they're not initialized to undefined like var; instead, they enter a state called the Temporal Dead Zone (TDZ).

While a variable sits in the TDZ, its name already exists, but touching it in any way throws immediately:

js
console.log(age) // throws: Cannot access 'age' before initialization
let age = 18

Look closely at that error message — it's a different error from name is not defined. is not defined means the name never existed in the first place. Cannot access before initialization means the engine knows age exists; it's just locked in the TDZ and off-limits for now.

That lock releases the moment execution actually reaches the let age = 18 line — only then can age be accessed normally. const behaves identically to let in this regard; the only difference is you can't reassign it after that initial assignment.

Comparing All Three

Laying them all out side by side makes this much clearer:

DeclarationWhat happens in the creation phaseResult of accessing it before assignment
varName registered, initialized to undefinedLogs undefined, no error
let / constName registered, enters the TDZThrows: Cannot access before initialization
function declarationName registered, entire function ready to goCan be called normally

Why Should You Care About Hoisting?

Understanding this mechanism actually helps you dodge two common traps:

  1. You can diagnose the problem from the error message alone. Seeing xxx is not defined means that name was never even hoisted — usually a typo or the wrong scope. Seeing Cannot access 'xxx' before initialization means you've hit the let/const TDZ, which usually means the declaration comes after the code that uses it.
  2. Don't write code that relies on var hoisting. Sure, var hoisting won't make your code throw outright, but reading undefined from a variable before it's assigned is usually a breeding ground for bugs, not a feature worth exploiting. This is exactly why modern JavaScript pushes you toward let/const over var: the TDZ forces you to catch the problem before the declaration, instead of letting a silent undefined slip through and blow up somewhere downstream.

Wrapping Up

At its core, hoisting is just this: before the JavaScript engine runs your code for real, it makes a pass through a creation phase and registers every name declared with var, let, const, or function ahead of time. What differs is the state that name ends up in afterward — var gets initialized to undefined and can be read freely; let and const get locked in the TDZ and throw the moment you touch them; function declarations get the whole function set up and ready to call immediately. Next time you see something that "works before it's declared" or "exists but can't be accessed," trace it back to this: is hoisting actually at play here, or did you just fall for the "code gets moved to the top" myth?