Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
20 minLesson 21 of 35
Asynchronous JavaScript

The Event Loop & Call Stack

The Event Loop & Call Stack

JavaScript is single-threaded — it can only execute one thing at a time. Yet it handles timers, network requests, and user events without freezing the page. The event loop is how this works, and understanding it eliminates an entire class of confusing bugs.

The Call Stack

JavaScript keeps track of function calls with a stack (last in, first out):

function greet(name) {
    return `Hello, ${name}!`;
}

function printGreeting() {
    const msg = greet("Alice");
    console.log(msg);
}

printGreeting();

// Call stack at each step:
// 1. [printGreeting]
// 2. [greet, printGreeting]   ← greet pushed on top
// 3. [printGreeting]          ← greet returned, popped
// 4. []                       ← printGreeting returned

When the stack is empty, JavaScript is idle — ready to pick up the next task from the queue.

Web APIs and the Task Queue

Browsers (and Node.js) provide asynchronous Web APIs that run outside the call stack:

console.log("1 - Start");

setTimeout(() => {
    console.log("3 - Timeout");
}, 0);

console.log("2 - End");

// Output: 1, 2, 3
// Even setTimeout(fn, 0) doesn't run immediately!
// The callback goes to the task queue and waits for the call stack to empty

The event loop constantly checks: "Is the call stack empty? If so, move the next task from the queue to the stack."

Microtasks vs Macrotasks

Not all async operations are equal. There are two queues:

Microtask queue (higher priority):

  • Promise .then() / .catch() / .finally() callbacks
  • queueMicrotask()
  • MutationObserver

Macrotask queue (lower priority):

  • setTimeout / setInterval callbacks
  • DOM events
  • I/O, network responses

Rule: Microtasks run to completion before any macrotask runs.

console.log("1");

setTimeout(() => console.log("2 - setTimeout"), 0);

Promise.resolve().then(() => console.log("3 - Promise"));

console.log("4");

// Output: 1, 4, 3, 2
// 1 and 4: synchronous, run immediately
// 3: microtask, runs before setTimeout
// 2: macrotask, runs last

A More Complex Example

console.log("Start");

setTimeout(() => console.log("Timeout 1"), 0);

Promise.resolve()
    .then(() => {
        console.log("Promise 1");
        return Promise.resolve("inner");
    })
    .then(v => console.log("Promise 2:", v));

setTimeout(() => console.log("Timeout 2"), 0);

console.log("End");

// Output:
// Start
// End
// Promise 1     ← microtask
// Promise 2     ← chained microtask runs before macrotasks
// Timeout 1     ← macrotask
// Timeout 2     ← macrotask

Why Blocking the Stack Is Bad

// This freezes the browser — no UI updates, no events
function blockingOperation() {
    const end = Date.now() + 5000;
    while (Date.now() < end) { /* spin */ }
    console.log("Done — but UI was frozen for 5 seconds!");
}

// The correct approach: break long work into chunks
function nonBlockingOperation(items) {
    let i = 0;
    
    function processChunk() {
        const end = Date.now() + 5;  // process for 5ms max
        while (i < items.length && Date.now() < end) {
            process(items[i++]);
        }
        
        if (i < items.length) {
            setTimeout(processChunk, 0);  // yield to event loop
        } else {
            console.log("Done!");
        }
    }
    
    processChunk();
}

// Modern approach: Web Workers for truly CPU-heavy work
const worker = new Worker("heavy-task.js");
worker.postMessage(data);
worker.onmessage = (e) => console.log("Result:", e.data);

setTimeout and setInterval

// One-time delay
const id = setTimeout(callback, 1000);
clearTimeout(id);  // cancel before it fires

// Repeating
const intervalId = setInterval(() => {
    updateClock();
}, 1000);

// Stop after some condition
const interval = setInterval(() => {
    progress += 10;
    if (progress >= 100) {
        clearInterval(interval);
        onComplete();
    }
}, 100);

// More reliable recursion pattern (no drift)
function tick() {
    doWork();
    setTimeout(tick, 1000);  // schedule next after this one completes
}
tick();

requestAnimationFrame

For smooth animations — runs before the browser paints:

function animate() {
    // Update your animation state
    element.style.left = (x += 2) + "px";
    
    // Schedule next frame (≈60fps, or display refresh rate)
    if (x < 500) {
        requestAnimationFrame(animate);
    }
}
requestAnimationFrame(animate);

// Cancel
const frameId = requestAnimationFrame(animate);
cancelAnimationFrame(frameId);

queueMicrotask

Schedule a function in the microtask queue without wrapping in a Promise:

console.log("1");
queueMicrotask(() => console.log("3 - microtask"));
console.log("2");

// Output: 1, 2, 3

The Practical Takeaway

// These execute in this order:
// 1. Synchronous code
// 2. Microtasks (promises, queueMicrotask)
// 3. Rendering updates
// 4. Macrotasks (setTimeout, events)

// This matters when:
async function fetchAndRender() {
    const data = await fetch("/api/data").then(r => r.json());
    
    // This code runs in the microtask queue after the await resolves
    // The DOM won't update between these two lines
    updateData(data);
    renderUI(data);
}

// setTimeout(fn, 0) is useful for "run after current rendering"
element.classList.add("animating");
setTimeout(() => element.classList.add("running"), 0);
// Forces the browser to paint the first class before adding the second

Next lesson: Callbacks & Callback Hell — the original async pattern and why Promises replaced it.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!