Lesson 1 • Advanced
Deep Dive Into JavaScript Execution Context & Call Stack
Master the hidden mechanics that power JavaScript execution.
What You'll Learn in This Lesson
- ✓Global vs Function Execution Context
- ✓The two phases: Creation & Execution
- ✓Hoisting mechanics deep dive
- ✓The Call Stack (LIFO) explained
- ✓Scope Chain resolution
- ✓How JavaScript manages memory
💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a
.htmlfile with<script>tags and open it in your browser
Understanding JavaScript's Hidden System
JavaScript looks simple when you write console.log("Hello"), but behind the scenes the engine performs an extremely structured process every time your code runs. This hidden system — the Execution Context and the Call Stack — determines how variables are created, which functions run first, why hoisting exists, how recursion works, why errors appear in a certain order, and even how asynchronous JavaScript works.
Once you understand this system deeply, debugging becomes easier, writing clean code becomes natural, and you can think like the JavaScript engine itself.
What is an Execution Context?
At the highest level, an Execution Context represents the environment in which a piece of JavaScript code is evaluated. Every context contains:
- A Variable Environment (var, function declarations)
- A Lexical Environment (let, const, and scope chain)
- A value for this
- A reference to the outer environment
These details determine how your code behaves, which variables are available, and how nested functions access different scopes.
Example: Environment Creation
Environment Creation
See how JavaScript creates environments for variables and functions
var a = 10;
let b = 20;
function sum(x, y) {
let result = x + y;
return result;
}
console.log(sum(5, 3));Even before this code runs, JavaScript performs a Memory Creation Phase:
| Variable / Function | Stored As… |
|---|---|
| a | Created, value = undefined |
| b | Created, but not initialized |
| sum | Created and stored with full function body |
This explains why the following happens:
Hoisting Demonstration
Understand why var and let behave differently before initialization
console.log(a); // undefined
console.log(b); // ❌ ReferenceErrorUnderstanding why this happens is the entire point of Execution Context mechanics.
🔥 The Two-Phase Creation of Every Execution Context
PHASE 1 — Memory Creation Phase (Hoisting Phase)
Before executing anything, the engine scans your code and sets up memory:
varvariables → allocated + initialized toundefinedletandconst→ allocated but not initialized ("Temporal Dead Zone")- Function declarations → fully stored in memory, ready to call
PHASE 2 — Execution Phase
JavaScript then runs your code line-by-line:
- Assigns actual values to variables
- Executes functions
- Evaluates expressions
- Pushes and pops Execution Contexts from the Call Stack
Code Example: Both Phases in Action
Two Phases in Action
Watch how memory creation and execution phases work
console.log(msg);
var msg = "Hello World";
function greet() {
console.log("Inside greet");
}
greet();What really happens:
Memory Creation Phase:
| Identifier | Memory Value |
|---|---|
| msg | undefined |
| greet | function stored fully |
Execution Phase (line-by-line):
console.log(msg)→ printsundefinedmsg = "Hello World"- Call
greet()→ pushes new Execution Context on the stack - Inside
greet→ prints "Inside greet" - Pop
greet→ return to Global Execution Context
Understanding this hidden process is what separates beginners from advanced developers.
🔥 The Call Stack — How JavaScript Keeps Order
The Call Stack is a LIFO (Last-In-First-Out) stack structure used to manage Execution Contexts.
- When a function is called → JavaScript pushes a new Execution Context
- When the function finishes → JavaScript pops that context off the stack
Call Stack Example
Call Stack Example
Visualize how functions push and pop from the call stack
function one() {
console.log("One");
two();
}
function two() {
console.log("Two");
three();
}
function three() {
console.log("Three");
}
one();The Call Stack evolution:
- Global Execution Context is created
one()is pushed- Inside
one(), we pushtwo() - Inside
two(), we pushthree() three()finishes → poppedtwo()finishes → poppedone()finishes → popped- Return to global
Output:
One Two Three🔥 Mistake Example — Stack Overflow
Stack Overflow Example
See what happens when the call stack exceeds its limit
function recurse() {
return recurse();
}
recurse();Error:
RangeError: Maximum call stack size exceededThis happens because Execution Contexts keep stacking infinitely until memory explodes.
🔥 Execution Context + Call Stack + Closures
Closures and Call Stack
Understand how closures preserve lexical environments
function outer() {
let counter = 0;
function inner() {
counter++;
console.log(counter);
}
return inner;
}
const fn = outer();
fn(); // 1
fn(); // 2
fn(); // 3Why does counter stay alive even after outer() has finished?
Because:
- The
innerfunction's Execution Context keeps a reference to the lexical environment ofouter() - This preserved reference is called a closure
- It survives even when the outer Execution Context is removed from the Call Stack
This is one of the most advanced concepts — and it relies entirely on Execution Context rules.
Async JavaScript and the Event Loop
JavaScript becomes truly powerful when you understand how the Execution Context system interacts with asynchronous behaviour, the event loop, and browser APIs. Most beginners mistakenly believe JavaScript runs "many things at once," but in reality, JavaScript is a single-threaded, synchronous language. It can only execute one piece of code at a time.
All "asynchronous" behaviour is an illusion powered by the browser or Node.js environment.
The first key idea is this: JavaScript does NOT do async tasks itself. The environment (browser / Node) does. JavaScript only manages Execution Contexts and the Call Stack.
🔥 The Event Loop: The Heart of Async JavaScript
The Event Loop continuously checks:
- Is the Call Stack empty?
- Are there callbacks waiting in the microtask queue?
- Are there callbacks waiting in the macrotask queue?
The rules for which tasks run first control how your app behaves — especially when mixing promises, timers, DOM events, and async/await.
Simple Example of Async Behaviour
Async Behavior Example
See why setTimeout doesn't run immediately even with 0ms delay
console.log("Start");
setTimeout(() => {
console.log("Timeout finished");
}, 0);
console.log("End");Output:
Start End Timeout finishedEven though setTimeout is 0ms, it does NOT run immediately. Why?
What actually happens:
console.log("Start")→ runs immediatelysetTimeout(...)is handed off to the Web API, NOT the Call Stackconsole.log("End")runs- The call stack is now empty → Event Loop checks queues
- The timeout callback enters the Call Stack
This makes the runtime predictable once you understand the sequence.
🔥 Microtasks (Promises) Run BEFORE Timers
The microtask queue includes:
Promise.then()Promise.catch()async/await(after await resolves)MutationObserver(browser)
These are higher priority than setTimeout, making them extremely important for performance-critical code.
Microtask Priority Example
Microtask Priority
Learn why Promises run before setTimeout
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");Output:
A D C BWhy?
- Microtasks run before timers.
- Promises always fire before
setTimeout.
Understanding this behaviour prevents async bugs and race conditions.
🔥 Execution Contexts in Async Code
Async Execution Contexts
Understand how await pauses and resumes execution contexts
async function test() {
console.log("1");
await new Promise(resolve => resolve());
console.log("2");
}
console.log("3");
test();
console.log("4");Output:
3 1 4 2Breakdown:
- Global Execution Context logs "3"
- A new Execution Context is created for
test() console.log("1")runsawaitpauses the context → promise resolution becomes a microtask- Global continues with "4"
- When call stack is empty, microtask resolves → prints "2"
Even async/await relies on Execution Context pausing, not true multithreading.
🔥 Closures in Async Code — Famous Problem
Closures in Async - The Problem
See the famous var loop closure bug
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}Beginners think it prints:
1 2 3But the real output is:
4 4 4Because:
varcreates one shared binding- All callbacks run after the loop finishes
- When they execute,
iequals 4
Fix using let (block scope):
Closures in Async - The Fix
See how let creates proper block-scoped closures
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}Now it prints:
1 2 3Because each iteration creates a new Execution Context for the block scope. This is why mastering Execution Contexts is mandatory for advanced JavaScript.
🔥 Async Stack Trace Confusion (Very Common Bug)
Async Stack Trace Confusion
See how async callbacks lose the original call stack
function first() {
second();
}
function second() {
setTimeout(() => {
third();
}, 0);
}
function third() {
throw new Error("Something went wrong");
}
first();Stack trace:
Error: Something went wrong at thirdNotice: The stack trace does NOT show second() or first().
Why?
third()executed in a brand-new Execution Context after the async delay- The original stack contexts were popped long before
- Errors inside asynchronous callbacks lose the original call stack
This is why logging and tracing async code is harder — and why tools like Sentry, Datadog, and OpenTelemetry exist.
🔥 "Run to Completion" — The Rule Most Beginners Don't Know
JavaScript always finishes the current function before checking for async tasks.
Run to Completion
See how synchronous code blocks async tasks
function longTask() {
for (let i = 0; i < 5e8; i++) {}
console.log("Done");
}
console.log("Start");
setTimeout(() => console.log("Async"), 0);
longTask();
console.log("End");Even with 0ms delay:
Output:
Start Done End AsyncWhy?
longTaskblocks the Call Stack- The Event Loop cannot push anything until the stack is empty
- Timers don't interrupt running code
This explains UI freezes, laggy buttons, and slow animations.
🔥 The Microtask Queue vs Macrotask Queue — Deepest Explanation
JavaScript has two primary task queues:
Microtasks
High-priority tasks, executed immediately after the current call stack finishes.
Includes:
- Promise callbacks (then, catch, finally)
- async/await resolution
- MutationObservers
queueMicrotask()
Macrotasks
Lower-priority tasks:
setTimeoutsetInterval- fetch event handlers
setImmediate(Node)- I/O callbacks (Node)
The event loop ALWAYS runs all microtasks first, before processing any macrotask.
Complete Queue Priority Example
Complete Queue Priority
See the order of microtasks vs macrotasks
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
queueMicrotask(() => console.log("D"));
console.log("E");Output:
A E C D BWhy?
- Microtasks (C and D) fire before timers.
- The event loop empties the microtask queue fully before touching macrotasks.
- This rule explains 90% of async "confusing" behaviour.
🔥 "Microtask Starvation" — A Real Bug in Big Apps
You can accidentally block ALL timers forever by continuously adding microtasks:
Microtask Starvation
See how infinite microtasks can block timers forever
function blockForever() {
Promise.resolve().then(blockForever);
}
blockForever();
setTimeout(() => console.log("This will NEVER run"), 0);Because microtasks always run before timers, the event loop never reaches the timer queue.
This bug happens in real production apps when:
- infinite promise chains happen
- recursive async functions never break
- frameworks accidentally loop microtasks
Understanding microtask starvation is essential for performance.
🔥 Real-world Example: React Render Timing
React heavily uses microtasks for state updates.
React Render Timing
Understand how React batches state updates using microtasks
setState(1);
console.log("Render?");
Promise.resolve().then(() => console.log("Microtask"));
setState(2);Output:
Render? MicrotaskReact batches updates using microtasks, not immediate re-renders. Understanding Execution Context timing explains:
- why React state updates seem "delayed"
- why effects sometimes run later
- why UI updates batch together
🔥 Real-World Bug: Lost Execution Context
Developers often assume this prints 1 then 2:
Lost Execution Context - let
See how let preserves the expected behavior
for (let i = 0; i < 2; i++) {
setTimeout(() => console.log(i), 0);
}It does. But this breaks:
Lost Execution Context - var
See how var causes unexpected behavior in async loops
for (var i = 0; i < 2; i++) {
setTimeout(() => console.log(i), 0);
}Output:
2 2Because:
- One shared
varbinding - Timeout executes AFTER loop finishes
- Execution Context for loop long gone
Real apps suffer from bugs like:
- incorrect index values
- stale closures
- undefined references
- async callback mismatch
🔥 How the Call Stack Creates Performance Bottlenecks
Heavy synchronous code blocks the entire thread:
Performance Bottleneck
See how heavy synchronous code blocks the entire thread
function block() {
let x = 0;
while (x < 1e9) x++;
}
block();During execution:
- No buttons respond
- No animations play
- No input events fire
- No timers execute
- No fetches resolve
Because the Call Stack must be empty before async events process.
This is why you break heavy work using:
- Web Workers
requestIdleCallbacksetTimeoutbatching- streaming / chunked processing
🔥 Advanced Pattern: Chunking Work to Avoid UI Freezing
Example of splitting heavy tasks:
Chunking Work Pattern
Learn how to split heavy tasks to avoid UI freezing
function processArray(arr) {
function chunk(start) {
const end = Math.min(start + 500, arr.length);
for (let i = start; i < end; i++) {
arr[i] = arr[i] * 2;
}
if (end < arr.length) {
setTimeout(() => chunk(end), 0); // non-blocking
}
}
chunk(0);
}This avoids blocking the Call Stack.
🔥 Event Loop Priority Trick: queueMicrotask
You can force code to run before timers and UI rendering:
queueMicrotask Priority
Force code to run before timers using queueMicrotask
queueMicrotask(() => {
console.log("Runs before timeout");
});
setTimeout(() => {
console.log("Timeout");
}, 0);Output:
Runs before timeout TimeoutThis is used to schedule high-priority UI updates, reactivity systems, virtual DOM patches, and transitions.
🔥 How Async/Await Really Works Internally
async functions return promises automatically:
Async Function Returns
See how async functions automatically return promises
async function demo() {
return 10;
}
// Equivalent to:
function demoEquivalent() {
return Promise.resolve(10);
}
demo().then(console.log);
demoEquivalent().then(console.log);await pauses the Execution Context:
Await Pauses Context
See how await splits execution into pre and post phases
async function test() {
console.log("1");
await Promise.resolve();
console.log("2");
}
test();This splits the Execution Context into two phases:
- pre-await
- post-await
The post-await portion runs inside a new microtask.
🔥 Professional-Level Example: Combining Everything
Professional-Level Example
Combine closures, async, and execution contexts
function createCounter() {
let count = 0;
return async function () {
const current = count;
await new Promise(r => setTimeout(r, 100));
count = current + 1;
return count;
}
}
const counter = createCounter();
counter().then(console.log);
counter().then(console.log);Output:
1 1Because:
- Each async call captures its own Execution Context
- They both capture
current = 0 - They update count independently
To fix, use a queue, mutex, or atomic updates.
🔥 Real-World Performance Example: Scroll Event Flooding
This will lag massively because scroll fires dozens of times per second:
Scroll Event Flooding
See why unoptimized scroll handlers cause lag
// This will lag massively
window.addEventListener("scroll", () => {
heavyFunction();
});
function heavyFunction() {
console.log("Heavy work...");
}Optimise using:
Optimized Scroll Handler
Use requestAnimationFrame to prevent scroll event flooding
let running = false;
window.addEventListener("scroll", () => {
if (!running) {
running = true;
requestAnimationFrame(() => {
heavyFunction();
running = false;
});
}
});
function heavyFunction() {
console.log("Heavy work (throttled)");
}Execution Context + Event Loop knowledge gives you perfect control over performance.
Key Takeaways
- Execution Contexts are created in two phases: memory creation and execution
- The Call Stack is LIFO and manages all Execution Contexts
- Hoisting happens during the memory creation phase
- Closures preserve lexical environments even after functions finish
- JavaScript is single-threaded — async is handled by the environment
- The Event Loop checks the Call Stack, then microtasks, then macrotasks
- Microtasks (Promises) run before macrotasks (setTimeout)
- Run-to-completion means no interruptions — blocking code blocks everything
- Understanding these mechanics makes debugging predictable and performance optimization natural
Sign up for free to track which lessons you've completed and get learning reminders.