Courses/JavaScript/Execution Context & Call Stack
    Back to Course

    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 .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

    Environment Creation

    See how JavaScript creates environments for variables and functions

    Try it Yourself »
    JavaScript
    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 / FunctionStored As…
    aCreated, value = undefined
    bCreated, but not initialized
    sumCreated and stored with full function body

    This explains why the following happens:

    Hoisting Demonstration

    Understand why var and let behave differently before initialization

    Try it Yourself »
    JavaScript
    console.log(a); // undefined
    console.log(b); // ❌ ReferenceError

    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

    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

    Try it Yourself »
    JavaScript
    console.log(msg); 
    var msg = "Hello World";
    
    function greet() {
        console.log("Inside greet");
    }
    
    greet();

    What really happens:

    Memory Creation Phase:

    IdentifierMemory Value
    msgundefined
    greetfunction stored fully

    Execution Phase (line-by-line):

    1. console.log(msg) → prints undefined
    2. msg = "Hello World"
    3. Call greet() → pushes new Execution Context on the stack
    4. Inside greet → prints "Inside greet"
    5. 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

    Try it Yourself »
    JavaScript
    function one() {
        console.log("One");
        two();
    }
    
    function two() {
        console.log("Two");
        three();
    }
    
    function three() {
        console.log("Three");
    }
    
    one();

    The Call Stack evolution:

    1. Global Execution Context is created
    2. one() is pushed
    3. Inside one(), we push two()
    4. Inside two(), we push three()
    5. three() finishes → popped
    6. two() finishes → popped
    7. one() finishes → popped
    8. Return to global

    Output:

    One Two Three

    🔥 Mistake Example — Stack Overflow

    Stack Overflow Example

    See what happens when the call stack exceeds its limit

    Try it Yourself »
    JavaScript
    function recurse() {
        return recurse();
    }
    
    recurse();

    Error:

    RangeError: Maximum call stack size exceeded

    This 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

    Try it Yourself »
    JavaScript
    function outer() {
        let counter = 0;
    
        function inner() {
            counter++;
            console.log(counter);
        }
    
        return inner;
    }
    
    const fn = outer();
    fn(); // 1
    fn(); // 2
    fn(); // 3

    Why does counter stay alive even after outer() has finished?

    Because:

    • 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

    The Event Loop continuously checks:

    1. Is the Call Stack empty?
    2. Are there callbacks waiting in the microtask queue?
    3. 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

    Try it Yourself »
    JavaScript
    console.log("Start");
    
    setTimeout(() => {
        console.log("Timeout finished");
    }, 0);
    
    console.log("End");

    Output:

    Start End Timeout finished

    Even though setTimeout is 0ms, it does NOT run immediately. Why?

    What actually happens:

    1. console.log("Start") → runs immediately
    2. setTimeout(...) is handed off to the Web API, NOT the Call Stack
    3. console.log("End") runs
    4. The call stack is now empty → Event Loop checks queues
    5. 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

    Try it Yourself »
    JavaScript
    console.log("A");
    
    setTimeout(() => console.log("B"), 0);
    
    Promise.resolve().then(() => console.log("C"));
    
    console.log("D");

    Output:

    A D C B

    Why?

    • 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

    Try it Yourself »
    JavaScript
    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 2

    Breakdown:

    1. Global Execution Context logs "3"
    2. A new Execution Context is created for test()
    3. console.log("1") runs
    4. await pauses the context → promise resolution becomes a microtask
    5. Global continues with "4"
    6. 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

    Try it Yourself »
    JavaScript
    for (var i = 1; i <= 3; i++) {
        setTimeout(() => {
            console.log(i);
        }, 1000);
    }

    Beginners think it prints:

    1 2 3

    But the real output is:

    4 4 4

    Because:

    • var creates one shared binding
    • All callbacks run after the loop finishes
    • When they execute, i equals 4

    Fix using let (block scope):

    Closures in Async - The Fix

    See how let creates proper block-scoped closures

    Try it Yourself »
    JavaScript
    for (let i = 1; i <= 3; i++) {
        setTimeout(() => {
            console.log(i);
        }, 1000);
    }

    Now it prints:

    1 2 3

    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)

    Async Stack Trace Confusion

    See how async callbacks lose the original call stack

    Try it Yourself »
    JavaScript
    function first() {
        second();
    }
    
    function second() {
        setTimeout(() => {
            third();
        }, 0);
    }
    
    function third() {
        throw new Error("Something went wrong");
    }
    
    first();

    Stack trace:

    Error: Something went wrong at third

    Notice: 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

    Try it Yourself »
    JavaScript
    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 Async

    Why?

    • 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

    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:

    • setTimeout
    • setInterval
    • 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

    Try it Yourself »
    JavaScript
    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 B

    Why?

    • 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

    Try it Yourself »
    JavaScript
    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

    Try it Yourself »
    JavaScript
    setState(1);
    console.log("Render?");
    
    Promise.resolve().then(() => console.log("Microtask"));
    
    setState(2);

    Output:

    Render? Microtask

    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:

    Lost Execution Context - let

    See how let preserves the expected behavior

    Try it Yourself »
    JavaScript
    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

    Try it Yourself »
    JavaScript
    for (var i = 0; i < 2; i++) {
        setTimeout(() => console.log(i), 0);
    }

    Output:

    2 2

    Because:

    • One shared var binding
    • 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

    Try it Yourself »
    JavaScript
    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
    • requestIdleCallback
    • setTimeout batching
    • 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

    Try it Yourself »
    JavaScript
    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

    Try it Yourself »
    JavaScript
    queueMicrotask(() => {
        console.log("Runs before timeout");
    });
    
    setTimeout(() => {
        console.log("Timeout");
    }, 0);

    Output:

    Runs before timeout Timeout

    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:

    Async Function Returns

    See how async functions automatically return promises

    Try it Yourself »
    JavaScript
    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

    Try it Yourself »
    JavaScript
    async function test() {
        console.log("1");
        await Promise.resolve();
        console.log("2");
    }
    
    test();

    This splits the Execution Context into two phases:

    1. pre-await
    2. 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

    Try it Yourself »
    JavaScript
    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 1

    Because:

    • 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service