DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/JavaScript/Hoisting
beginner15 min read·Updated Jun 2025

Hoisting

Hoisting is what lets you call a function before its declaration but not read a let variable before its line runs. Both behaviors come from the same two-phase process the engine runs before executing a single line of your code.

hoistingtdztemporal-dead-zonevarletfundamentals

Knowledge Check

6 questions · pass at 70%

0/6
easypredict output

1. What does this log?

code
console.log(typeof greet);
function greet() {}

Interview Questions

3 questions

Cheatsheet

Download-ready reference
Cheatsheet
Two phases per scope: CREATION (set up bindings) → EXECUTION (run code top to bottom)

function foo() {}      → hoisted WHOLE (name + value) — callable before its line
var x                  → hoisted, initialized to undefined
let / const / class    → hoisted, but in TDZ until their declaration line runs

Access before declaration:
  var        → undefined, no error
  let/const  → ReferenceError (TDZ)
  function decl → works normally

typeof safety:
  typeof neverDeclared  → "undefined"      (safe)
  typeof letInTDZ       → ReferenceError   (NOT safe)

Function expression (var/let fn = () => {}) follows its VARIABLE's
hoisting rules, not the function-declaration rules.

Rule of thumb: write code in the order it executes. Never rely on hoisting.

Related topics

ScopeClosuresExecution ContextPro

On this page

15m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: "hoisting" is the (slightly misleading) name for the fact that declarations seem to move to the top of their scope before any code runs. It's why this works:

sayHi(); // "Hi!" — works, even though sayHi is called before its declaration
 
function sayHi() {
  console.log("Hi!");
}

The precise mental model: nothing actually moves. Before executing any code in a scope, the JavaScript engine runs a creation phase: it scans the scope for every var, function, let, const, and class declaration and registers a binding for each one before the execution phase (running your code top to bottom) begins. What differs is how each binding is initialized during that creation phase — and that difference is the entire story of hoisting.

The engine-level view — three distinct behaviors, not one:

DeclarationCreation phaseResult of using it before its line
function foo() {}Binding created and fully assigned the functionWorks completely
var xBinding created, initialized to undefinedReads as undefined, no error
let / const / class

Function declarations are hoisted "whole" — both the name and the value — which is why you can call a function before the line that defines it. var is hoisted "half-way" — the name exists, but its value doesn't arrive until the assignment line actually executes. let/const are hoisted too (they must be, for scoping to work at all — see the shadowing example in Scope) but stay in a locked state called the Temporal Dead Zone (TDZ) until their declaration line runs.

var hoisting vs. let's temporal dead zone
step 1 / 6
console.log(typeof x); // ?
console.log(typeof z); // ?
 
var x = 1;
 
console.log(typeof y); // ?
let y = 2;
Global (creation phase)
x: undefined (var — hoisted + pre-initialized)
y: «TDZ» (let — hoisted, not initialized)
z: — not declared anywhere —

Before any line runs, the engine scans for declarations. var x is hoisted AND pre-initialized to undefined. let y is hoisted but left in the Temporal Dead Zone. z isn't declared at all, so it gets no binding.

Why the TDZ exists#

Before let/const, var's "declared but undefined" behavior was a steady source of bugs — code that silently read undefined instead of failing loudly when a variable was used too early. The TDZ is a deliberate design choice: fail fast with a clear ReferenceError instead of silently propagating undefined.

console.log(count); // undefined — var, no error
var count = 5;
 
console.log(total); // ReferenceError — TDZ
let total = 5;

Function declarations vs. function expressions#

Only function declarations get the "hoisted whole" treatment. Function expressions — including ones assigned to var, let, or const — follow their variable's hoisting rules instead, not the function's.

sayA(); // ✅ "A" — declaration, hoisted whole
function sayA() { console.log("A"); }
 
sayB(); // ❌ TypeError: sayB is not a function — `sayB` is `undefined` at this point (var hoisting)
var sayB = function () { console.log("B"); };
 
sayC(); // ❌ ReferenceError — `sayC` is in the TDZ (let hoisting)
let sayC = function () { console.log("C"); };

Try It#

Predict the output, then check yourself.

console.log(a);
console.log(b);
console.log(c());
 
var a = 1;
let b = 2;
function c() {
  return "c!";
}
Solution
undefined
ReferenceError: Cannot access 'b' before initialization

Execution never reaches console.log(c()) — it throws on line 2. If you comment out the b line: a logs undefined (var, hoisted + pre-initialized), and c() logs "c!" (function declarations are hoisted whole, so calling it before its source position works fine).


Implement It Yourself#

Write a tiny static analyzer that reports, for a given list of statements, which variables would be accessible (and with what initial value) at the very top of a scope — a miniature model of what the engine's creation phase does:

function simulateHoisting(declarations) {
  const bindings = {};
 
  // Pass 1: function declarations — hoisted whole
  for (const d of declarations) {
    if (d.kind === "function") bindings[d.name] = "function";
  }
 
  // Pass 2: var — hoisted, initialized to undefined (don't clobber a function of the same name)
  for (const d of declarations) {
    if (d.kind 















This mirrors the real two-pass process closely enough to build real intuition: function declarations always win the "already usable" slot, var gets a placeholder value, and let/const/class get a binding that exists but isn't safe to touch yet.


In React#

Hoisting rarely causes bugs inside components — the classic gotcha is at module scope: declaring a component with a const arrow function and referencing it (e.g., in a route config or another component) above its declaration line throws the same TDZ error as any other let/const.

// ❌ ReferenceError — Header is in the TDZ here
const routes = [{ path: "/", component: Header }];
 
const Header = () => <h1>Home</h1>;
// ✅ function declarations are hoisted whole — order doesn't matter
function Header() { return <h1>Home</h1>; }
 
const routes = [{ path: "/", component: Header }];

This is one of the few remaining practical arguments for function Component() {} over const Component = () => {} at the top level of a file — declaration order stops mattering.


Common Mistakes#

1. Assuming var hoisting means the value moves#

console.log(status); // undefined, not "active" — only the DECLARATION hoists
var status = "active";

Only the binding is hoisted; the assignment happens exactly where it's written.

2. Expecting typeof to be TDZ-safe#

typeof neverDeclared; // "undefined" — safe
typeof inTdz;          // ReferenceError!
let inTdz = 1;

typeof only protects you against variables that don't exist at all. It offers zero protection against a let/const that's hoisted but not yet initialized — a very common source of confusion, since most people learn "typeof is always safe" from the undeclared-variable case and generalize incorrectly.

3. Redeclaring var and expecting an error#

var count = 1;
var count = 2; // totally legal — same binding, silently overwritten
console.log(count); // 2

let/const throw SyntaxError: Identifier has already been declared for the same thing — another reason they're safer defaults.


Best Practices#

  • Declare functions and variables before use, in source order — don't rely on hoisting as a feature, even where it technically works. Code that reads top-to-bottom in execution order is easier to reason about.
  • Prefer function declarations for top-level, hoist-order-independent utilities; use const arrow functions for anything that's conceptually a value (callbacks, component props) where hoisting isn't relevant anyway.
  • Treat any TDZ ReferenceError as a signal to reorder your code, not to fall back to var — the error is doing you a favor by catching a real ordering bug early.

Performance Tips#

  • Hoisting itself has no runtime performance cost — the creation phase is part of how the engine already has to parse and set up a scope; you're not paying extra for it.
  • The TDZ has a negligible, effectively unmeasurable check cost. Don't avoid let/const for "hoisting performance" reasons — that tradeoff doesn't exist in any modern engine.

Binding created, not initialized — in the Temporal Dead Zone
ReferenceError
===
"var"
&&
!
(d.name
in
bindings)) bindings[d.name]
=
undefined
;
}
// Pass 3: let/const/class — hoisted but in the TDZ
for (const d of declarations) {
if (["let", "const", "class"].includes(d.kind)) bindings[d.name] = "<TDZ>";
}
return bindings;
}
simulateHoisting([
{ kind: "var", name: "x" },
{ kind: "let", name: "y" },
{ kind: "function", name: "greet" },
]);
// { x: undefined, y: '<TDZ>', greet: 'function' }