Demystifying Hoisting: What Really Happens Inside the JavaScript Engine

Most JavaScript developers have encountered the common explanation for hoisting: "JavaScript moves variables and functions to the top of your file." This explanation, while widely circulated, is technically inaccurate. Hoisting does not involve physical code movement. Instead, it's a direct consequence of how the JavaScript engine prepares memory before executing any line of your code. Understanding this internal preparation step transforms hoisting from a confusing concept into one of the more straightforward aspects of JavaScript.

Why Hoisting Exists: The Two-Pass Model

The JavaScript engine doesn't execute your code line by line immediately. Before execution commences, it performs a critical preparation phase. During this phase, the engine scans your entire code, identifies all declared variables and functions, and allocates memory for them. This process is often described as a two-pass model:

Pass 1: Compilation (Memory Allocation)

In the first pass, the engine analyzes the code for declarations. For variables declared with var, it allocates memory and initializes them with undefined. For functions declared using the function declaration syntax, it allocates memory and stores the entire function definition. Notably, variables declared with let and const are also scanned and their memory space is reserved, but they are not initialized to undefined. Instead, they enter a 'temporal dead zone' (TDZ) until their actual declaration is encountered in the code. Accessing them before this point results in a ReferenceError.

Pass 2: Execution

Once memory is allocated and variables are initialized (or placed in the TDZ), the engine proceeds to the execution pass. It then runs your code line by line, assigning values to variables, calling functions, and performing other operations. Because the memory for variables and functions has already been set up, you can reference them before their physical appearance in the code, provided they are not in the TDZ.

Variable Hoisting: var vs. let and const

The behavior of hoisting differs significantly between var and the block-scoped declarations let and const.

var Declarations

When you declare a variable using var, its declaration is hoisted to the top of its scope (either the global scope or a function scope) and is automatically initialized with the value undefined. This means you can access a var variable before its declaration in the code without throwing an error, although its value will be undefined until the assignment occurs.

console.log(myVar); // Output: undefined
var myVar = 10;
console.log(myVar); // Output: 10

Behind the scenes, the JavaScript engine effectively treats the above code as if it were:

var myVar;
console.log(myVar);
myVar = 10;
console.log(myVar);

let and const Declarations

Variables declared with let and const are also hoisted, but they are not initialized. Instead, they are put into a 'temporal dead zone' (TDZ) from the start of the block until the line where they are declared. Attempting to access a let or const variable within its TDZ will result in a ReferenceError. This behavior helps prevent bugs that might arise from using variables before they are explicitly assigned a value.

console.log(myLetVar); // Throws ReferenceError: Cannot access 'myLetVar' before initialization
let myLetVar = 20;
console.log(myLetVar); // Output: 20

The TDZ is a crucial aspect of modern JavaScript, enforcing a more predictable and less error-prone way of handling variable declarations compared to var. It ensures that variables are used only after they have been explicitly declared and initialized.

Function Hoisting

Function declarations are hoisted differently from variable declarations. When a function is declared using the function functionName() {} syntax, the entire function definition is hoisted to the top of its scope. This means you can call a function before its physical declaration in the code.

greet(); // Output: Hello, world!

function greet() {
  console.log("Hello, world!");
}

This is because, during the compilation phase, the JavaScript engine stores the function's code entirely in memory. This differs from function expressions, where only the variable holding the function is hoisted (and initialized to undefined if declared with var).

Function Expressions

Function expressions, whether assigned to var, let, or const, follow the hoisting rules of the variable they are assigned to. If declared with var, the variable is hoisted and initialized to undefined. If declared with let or const, the variable is hoisted but enters the TDZ.

// Using var
console.log(typeof sayHello);
// Output: undefined (if declared with var before this point)
// sayHello(); // Throws TypeError: sayHello is not a function
var sayHello = function() {
  console.log("Hello!");
};

// Using let
// console.log(sayGoodbye); // Throws ReferenceError
let sayGoodbye = function() {
  console.log("Goodbye!");
};

The key takeaway is that only function declarations are hoisted in their entirety. Function expressions are treated as variable assignments.

The Temporal Dead Zone (TDZ)

The TDZ is a concept tied to let and const declarations. It refers to the period between the start of a scope and the point where a variable declared with let or const is actually declared. During this time, the variable exists in memory but cannot be accessed. Any attempt to access it will result in a ReferenceError.

Consider this example:

function exampleTDZ() {
  // TDZ starts here for 'x' and 'y'
  // console.log(x); // ReferenceError: x is not defined (or in TDZ)
  // console.log(y); // ReferenceError: y is not defined (or in TDZ)

  let x = 5;
  const y = 10;

  // TDZ ends here for 'x' and 'y'
  console.log(x); // Output: 5
  console.log(y); // Output: 10
}
exampleTDZ();

The TDZ is not a bug; it's a feature designed to catch common programming errors. It ensures that you don't accidentally use a variable before you've explicitly declared it, promoting cleaner and more robust code.

Conclusion: A Pre-Execution Process

Hoisting in JavaScript is fundamentally about how the engine prepares your code for execution. It's not about magically moving code around. During the compilation phase, the engine scans for declarations and sets up the necessary memory. Variables declared with var are hoisted and initialized to undefined. Functions declared with function syntax are hoisted in their entirety. Variables declared with let and const are hoisted but remain in a temporal dead zone until their declaration is encountered, preventing access before initialization.

Understanding this two-pass model—compilation followed by execution—provides a clear picture of why certain code patterns appear to work despite variables or functions being used before their written declaration. It's the engine's internal mechanism for managing scope and declarations, ensuring that when your code finally runs, all its components are accounted for.