Execution Context
An execution context is the environment in which a piece of JavaScript code runs, holding its variables, scope, and this value, while the call stack tracks which contexts are currently executing.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Deep Dive Into JavaScript Execution Context & Call Stack
Master the hidden mechanics that power JavaScript execution.
What You'll Learn in This Lesson
💡 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 .html file 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
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
Understanding 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:
- var variables → allocated + initialized to undefined
- let and const → allocated but not initialized ("Temporal Dead Zone")
- Function declarations → fully stored in memory, ready to call
PHASE 2 — Execution Phase
- Assigns actual values to variables
- Executes functions
- Evaluates expressions
- Pushes and pops Execution Contexts from the Call Stack
Code Example: Both Phases in Action
Identifier
Memory Value
msg
undefined
greet
function stored fully
- console.log(msg) → prints undefined
- msg = "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
- Global Execution Context is created
- one() is pushed
- Inside one() , we push two()
- Inside two() , we push three()
- three() finishes → popped
- two() finishes → popped
- one() finishes → popped
- Return to global
🔥 Mistake Example — Stack Overflow
This happens because Execution Contexts keep stacking infinitely until memory explodes.
🔥 Execution Context + Call Stack + Closures
Why does counter stay alive even after outer() has finished?
- The inner function's Execution Context keeps a reference to the lexical environment of outer()
- 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
- 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
Even though setTimeout is 0ms, it does NOT run immediately. Why?
- console.log("Start") → runs immediately
- setTimeout(...) is handed off to the Web API, NOT the Call Stack
- console.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
- 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
- Microtasks run before timers.
- Promises always fire before setTimeout .
Understanding this behaviour prevents async bugs and race conditions.
🔥 Execution Contexts in Async Code
- Global Execution Context logs "3"
- A new Execution Context is created for test()
- console.log("1") runs
- await pauses 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
- var creates one shared binding
- All callbacks run after the loop finishes
- When they execute, i equals 4
Because 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)
Notice: The stack trace does NOT show second() or first() .
- 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.
- longTask blocks 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
Microtasks
High-priority tasks, executed immediately after the current call stack finishes.
- Promise callbacks (then, catch, finally)
- async/await resolution
- MutationObservers
- queueMicrotask()
Macrotasks
- 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
- 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:
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 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:
- One shared var binding
- Timeout executes AFTER loop finishes
- Execution Context for loop long gone
- incorrect index values
- stale closures
- undefined references
- async callback mismatch
🔥 How the Call Stack Creates Performance Bottlenecks
Heavy synchronous code blocks the entire thread:
- 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.
- requestIdleCallback
- setTimeout batching
- streaming / chunked processing
🔥 Advanced Pattern: Chunking Work to Avoid UI Freezing
🔥 Event Loop Priority Trick: queueMicrotask
You can force code to run before timers and UI rendering:
This 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:
This splits the Execution Context into two phases:
The post-await portion runs inside a new microtask.
🔥 Professional-Level Example: Combining Everything
- 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:
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
Practice quiz
What does an execution context hold?
- Only the return value
- Just the function name
- The environment for code: variables, scope, and the 'this' value
- The HTML of the page
Answer: The environment for code: variables, scope, and the 'this' value. An execution context is the environment in which code runs, holding its variables, scope, and this value.
What are the two phases of every execution context?
- Memory Creation (hoisting) and Execution
- Parse and run
- Compile and link
- Push and pop
Answer: Memory Creation (hoisting) and Execution. First the engine sets up memory (creation/hoisting phase), then it runs the code line-by-line (execution phase).
During the memory creation phase, a var variable is...
- Left uninitialized (TDZ)
- Fully assigned its value
- Deleted
- Allocated and initialized to undefined
Answer: Allocated and initialized to undefined. var variables are allocated and initialized to undefined during the creation phase.
During the memory creation phase, let and const are...
- Initialized to undefined
- Allocated but not initialized (the Temporal Dead Zone)
- Fully assigned
- Ignored
Answer: Allocated but not initialized (the Temporal Dead Zone). let and const are allocated but uninitialized, creating the Temporal Dead Zone until their declaration.
console.log(a); var a = 5; — what prints?
- undefined
- 5
- ReferenceError
- null
Answer: undefined. var a is hoisted and initialized to undefined, so reading it before the assignment prints undefined.
console.log(b); let b = 5; — what happens?
- Prints undefined
- Prints 5
- Throws a ReferenceError (TDZ)
- Prints null
Answer: Throws a ReferenceError (TDZ). let b is in the Temporal Dead Zone before its declaration, so accessing it throws a ReferenceError.
What is the call stack?
- A queue of timers
- A LIFO structure that manages execution contexts
- The microtask queue
- A list of global variables
Answer: A LIFO structure that manages execution contexts. The call stack is a LIFO structure: calling a function pushes a context, finishing pops it.
What error does an infinitely self-calling function produce?
- TypeError
- SyntaxError
- Nothing, it loops forever
- RangeError: Maximum call stack size exceeded
Answer: RangeError: Maximum call stack size exceeded. Contexts keep stacking until the stack overflows, throwing RangeError: Maximum call stack size exceeded.
console.log('A'); setTimeout(()=>console.log('B'),0); Promise.resolve().then(()=>console.log('C')); console.log('D'); — order?
- A B C D
- A D C B
- A D B C
- A C D B
Answer: A D C B. Sync A and D first, then the microtask C, then the macrotask B: A, D, C, B.
Why does for (var i...) with setTimeout print the final value of i for every callback?
- var is asynchronous
- setTimeout copies i
- All callbacks share one var binding, which has its final value by the time they run
- var is block scoped
Answer: All callbacks share one var binding, which has its final value by the time they run. var creates one shared binding, so every deferred callback sees i's final value; let fixes it with a per-iteration binding.
Continue this course
- Previous: JavaScript Performance Optimization
- Next: Back to Course