Demystifying Hoisting: What Really Happens Inside the JavaScript Engine
Most JavaScript developers have heard the common statement: "JavaScript moves variables and functions to the top of your file." While this explanation is widespread, it is technically incorrect. Hoisting is not code movement. Nothing is physically shifted around in your source file. Instead, hoisting is the direct result of how the JavaScript engine prepares memory before executing a single line of your code.
Once you understand what happens internally during this preparation step, hoisting becomes one of the easiest JavaScript concepts to grasp.
Why Hoisting Exists: The Two-Pass Model
JavaScript does not just execute your code immediately, line by line. Before execution starts, the JavaScript engine performs a crucial preparation step where it scans your code, identifies variables and functions, and allocates memory for them.
This process happens in two distinct phases:
- Memory Creation Phase: The engine scans the code and allocates memory slots for all declarations. This is when hoisting happens.
- Execution Phase: The engine runs your code line by line, assigning values and executing functions.
A Simple Real-Life Analogy
Imagine students entering an examination hall. Before they begin writing their papers:
- Answer sheets are distributed.
- Roll numbers are assigned to desks.
- Seating arrangements are completed.
Only after all preparations are finished does the actual exam begin. JavaScript behaves exactly the same way. Before executing code, variables and functions are discovered, and memory space is allocated.
Defining Hoisting Accurately
- The Beginner-Friendly Definition: Hoisting is JavaScript's behavior of making declarations available before execution starts.
- The Technically Accurate Definition: Hoisting is the memory allocation process performed during the creation phase of an Execution Context.
Deep Dive: 4 Core Examples of Hoisting
Example 1: Variable Hoisting with var
console.log(a);
var a = 10;
Output:
undefined
Many beginners expect a ReferenceError here. Why does JavaScript print undefined instead?
What Happens Internally?
During the Memory Creation Phase, the engine encounters var a = 10;. It splits this into declaration and assignment, allocates memory for a, and automatically initializes it with a default value of undefined.
| Variable | Initial Value |
|---|---|
a |
undefined |
When the Execution Phase starts:
-
Step 1:
console.log(a);runs. At this exact moment,aholds the valueundefined. -
Step 2:
a = 10;runs, updating the value in memory fromundefinedto10.
This is why var variables can be accessed before their formal declaration without throwing an error.
Example 2: Function Hoisting
sayHello();
function sayHello() {
console.log("Hello");
}
Output:
Hello
Function declarations are treated differently from variables. During the creation phase, JavaScript stores the entire function definition in memory right away.
| Name | Stored Value |
|---|---|
sayHello |
function sayHello() { ... } (Complete Function) |
Because the complete function is fully hoisted and available before execution begins, you can safely invoke it at the very top of your script.
Example 3: Function Expressions
What happens when you combine a function with a variable declaration?
sayHi();
var sayHi = function () {
console.log("Hi");
};
Output:
TypeError: sayHi is not a function
This surprises many developers. Why does it fail?
During the Memory Creation Phase, the engine treats var sayHi like any other variable. It allocates memory and initializes it to undefined. The function assignment itself is ignored until the execution phase.
| Variable | Value |
|---|---|
sayHi |
undefined |
During execution, the engine reaches sayHi(); and translates it to undefined();. Because undefined is not a callable function data type, JavaScript throws a TypeError. The variable container is hoisted, but the function assignment is not.
Example 4: Hoisting with let and const
console.log(a);
let a = 5;
Output:
ReferenceError: Cannot access 'a' before initialization
At first glance, it appears that let and const are not hoisted. But that is a misconception—they are hoisted.
When the engine scans code during the memory phase, it allocates space for let and const variables. However, unlike var, they are not initialized with undefined. Instead, they are placed in an uninitialized state known as the Temporal Dead Zone (TDZ).
| Variable | State |
|---|---|
a |
Uninitialized (Temporal Dead Zone) |
Attempting to read or write to a variable while it sits in the TDZ results in a ReferenceError. The variable only becomes safely usable after the execution flow reaches the line where it is formally initialized.
Quick Reference Summary
The following tables break down how different declarations are treated during the internal memory creation phase.
Declaration Behaviors
| Feature | var |
let |
const |
|---|---|---|---|
| Hoisted | Yes | Yes | Yes |
| Initial Value | undefined |
Uninitialized | Uninitialized |
| Access Before Dec. | undefined |
ReferenceError |
ReferenceError |
| Scope | Function | Block | Block |
Memory Phase Blueprint
| Type | Memory Phase State |
|---|---|
var |
undefined |
function |
Stores Entire Function |
let |
uninitialized (TDZ) |
const |
uninitialized (TDZ) |
How JavaScript Executes Code: Behind the Scenes
To truly tie this all together, we have to look at the execution environment itself. When a web browser encounters a script tag, it passes the source code down to its respective engine (such as Google Chrome's V8, Mozilla Firefox's SpiderMonkey, or Safari's JavaScriptCore).
The engine processes your file using a mechanism called the Execution Context—an environment wrapper that manages the execution variables, functions, scopes, and the this evaluation.
The Call Stack
JavaScript handles execution contexts using a Call Stack. Think of it as a stack of dishes where the last item added is the first one to be cleared:
function one() {
two();
}
function two() {
console.log("Hello");
}
one();
- Global Execution Context is created and pushed to the bottom of the stack when the script loads.
-
one()is invoked $\rightarrow$ Its execution context is pushed onto the stack. -
two()is invoked insideone()$\rightarrow$ Its execution context is pushed to the top. -
two()finishes $\rightarrow$ Popped off the stack. -
one()finishes $\rightarrow$ Popped off the stack.
Complete Internal Flow Example
Let's look at a complete program to see how memory creation and execution phases pair together:
var x = 5;
function square(n) {
return n * n;
}
var result = square(x);
console.log(result);
Phase 1: Memory Creation
Before running line one, the global memory environment maps out your declarations:
-
xis registered and set toundefined. -
squareis registered, storing its entire block logic. -
resultis registered and set toundefined.
Phase 2: Code Execution
-
Line 1:
xis assigned the value5. - Line 2: The engine skips over the function declaration since it's already stored.
-
Line 3:
square(x)is evaluated. A new local function execution context is spawned on the Call Stack, passing5into the parametern. It computes $5 \times 5$, returns25, and destroys its local environment context.resultis updated to25. -
Line 4: Outputs
25directly to the console.
Wrapping Up: The Ultimate Mental Model
Whenever your JavaScript programs run, remember this four-step mental checklist:
- Create an Execution Context.
- Scan for variable and function declarations.
- Allocate memory slots (initializing variables based on their keywords).
- Execute the remaining source code line by line.
Hoisting isn't a magical code-lifting feature. It is simply the JavaScript engine taking a moment to set up its memory grid before letting your program loose!
Top comments (0)