JS
Frontend

JavaScript Event Loop Explained

Mayur Dabhi
Mayur Dabhi
May 12, 2026
14 min read

If you've ever wondered why setTimeout(fn, 0) doesn't execute immediately, why Promises resolve before setTimeout callbacks, or how JavaScript handles thousands of concurrent requests while being single-threaded — the answer lies in the Event Loop. Understanding this mechanism is one of those pivotal moments that transforms how you think about writing async JavaScript code.

JavaScript runs in a single thread, meaning it can only execute one piece of code at a time. Yet modern web applications fetch data from APIs, respond to user interactions, and animate the UI simultaneously without locking up the browser. This apparent contradiction is resolved entirely by the event loop — an elegant concurrency model that uses non-blocking I/O to keep applications responsive.

Key Insight

JavaScript itself is single-threaded, but the runtime environment (browser or Node.js) is multi-threaded. The event loop is the bridge that coordinates between JavaScript's single thread and the multi-threaded host environment.

The JavaScript Runtime Environment

Before diving into the event loop itself, you need to understand the components that make up the JavaScript runtime. In a browser, this consists of several distinct parts working in concert:

JavaScript Engine (V8) Call Stack main() greet() ← LIFO Memory Heap Objects & Variables stored here Web APIs setTimeout fetch / XHR DOM Events rAF / IO Microtask Queue Promise.then · queueMicrotask ← Higher priority Callback Queue setTimeout · setInterval · I/O ← Lower priority (macrotasks) Event Loop calls callback push to call stack

Complete JavaScript Runtime Environment — how all components interact

The Call Stack: How JavaScript Executes Code

The call stack is a LIFO (Last In, First Out) data structure that tracks which function is currently executing and where to return when it finishes. Every time you call a function, a new stack frame is pushed. When the function returns, that frame is popped.

Call Stack Visualization
function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);  // multiply pushed onto stack
}

function printSquare(n) {
    const result = square(n);  // square pushed onto stack
    console.log(result);
}

printSquare(4);  // printSquare pushed onto stack

// Stack progression:
// 1. [printSquare]
// 2. [printSquare, square]
// 3. [printSquare, square, multiply]
// 4. [printSquare, square]   ← multiply returns 16
// 5. [printSquare]           ← square returns 16
// 6. []                      ← printSquare logs 16, returns

When the call stack is executing synchronous code, nothing else can run. This is what "blocking the event loop" means — if you run a long loop or heavy computation on the main thread, the browser can't process clicks, render frames, or run any callbacks until the stack is empty.

Blocking the Event Loop

Never run CPU-intensive operations synchronously. A while loop running for 5 seconds freezes the entire page. Offload heavy work to Web Workers, or break it into chunks using setTimeout(fn, 0) to yield control back to the event loop between iterations.

Web APIs: Delegating Async Work

When JavaScript calls a Web API like setTimeout, fetch, or addEventListener, the browser handles the actual work in its own threads — completely outside the JavaScript engine. The JS engine immediately continues executing other code without waiting.

Web API Delegation Example
console.log('1: Start');

// setTimeout hands off to the Web API (browser timer)
// JS does NOT pause — it continues immediately
setTimeout(() => {
    console.log('3: Timeout callback (runs after stack clears)');
}, 1000);

// fetch hands off to the Web API (network layer)
fetch('/api/data')
    .then(res => res.json())
    .then(data => {
        console.log('4: Fetch resolved');
    });

console.log('2: End');

// Output order:
// 1: Start
// 2: End
// 3: Timeout callback (runs after stack clears, ~1 second later)
// 4: Fetch resolved (runs after fetch completes, order relative to timeout varies)

The key insight: setTimeout doesn't pause JavaScript. It tells the browser "run this callback after at least 1000ms". Meanwhile, JavaScript moves on. When the timer fires, the browser places the callback into the Callback Queue, where it waits for the event loop to pick it up.

How Web APIs Work Internally

1

JS calls setTimeout(fn, 1000)

The JS engine passes the callback and delay to the browser's Web API. The Web API starts an internal timer and immediately returns control to JavaScript.

2

Browser timer runs in the background

The browser's C++ timer runs in a separate system thread. After 1000ms, it enqueues the callback into the Callback Queue (not directly onto the stack).

3

Event Loop checks the stack

The event loop continuously monitors the call stack. Only when the stack is completely empty does it pick up the callback from the queue and push it onto the stack for execution.

Callback Queue vs Microtask Queue

This is where most developers get confused. There are actually two separate queues with different priorities. The microtask queue (for Promises) always runs to completion before the event loop processes any macrotask from the callback queue.

Feature Microtask Queue Callback Queue (Macrotask)
Priority High — processed first Low — processed after all microtasks
Sources Promise.then/catch/finally, queueMicrotask(), MutationObserver setTimeout, setInterval, I/O callbacks, UI events
Drain behavior All microtasks run before next macrotask One per event loop iteration
Can starve macrotasks? Yes — infinite Promise chain blocks macrotasks No
Spec HTML5 Microtask Checkpoint HTML5 Event Loop Processing Model
Microtask vs Macrotask Ordering
console.log('1: Synchronous start');

setTimeout(() => console.log('5: setTimeout (macrotask)'), 0);

Promise.resolve()
    .then(() => console.log('3: Promise .then (microtask)'))
    .then(() => console.log('4: Chained Promise (microtask)'));

queueMicrotask(() => console.log('2.5: queueMicrotask (microtask)'));

console.log('2: Synchronous end');

// Output:
// 1: Synchronous start
// 2: Synchronous end
// 2.5: queueMicrotask (microtask)
// 3: Promise .then (microtask)
// 4: Chained Promise (microtask)
// 5: setTimeout (macrotask)
//
// WHY: After the synchronous code finishes (stack empties),
// ALL microtasks drain before the macrotask queue is checked.

The Event Loop Algorithm

The event loop follows a precise algorithm on every iteration (often called a "tick"). Understanding this sequence is the key to predicting async execution order with confidence:

1. Execute Script Run synchronous code Stack empty? No Keep running current code Yes 3. Drain Microtasks Run ALL pending microtasks 4. One Macrotask Dequeue & execute one callback Loop repeats

Event Loop execution order — microtasks always drain before the next macrotask

Complete Execution Order Challenge
console.log('A');  // sync

setTimeout(() => console.log('B'), 0);  // macrotask

Promise.resolve()
    .then(() => {
        console.log('C');  // microtask
        setTimeout(() => console.log('D'), 0);  // schedules new macrotask
    })
    .then(() => console.log('E'));  // microtask (chained)

setTimeout(() => console.log('F'), 0);  // macrotask

console.log('G');  // sync

// Output: A, G, C, E, B, F, D
//
// Breakdown:
// Sync:       A, G
// Microtasks: C → E (all before any macrotask)
// Macrotask:  B (first setTimeout registered)
// Macrotask:  F (second setTimeout registered)
// Macrotask:  D (setTimeout registered inside Promise .then, after B and F)

Async/Await Under the Hood

Async/await is syntactic sugar over Promises. Under the hood, every await expression pauses the async function and schedules the rest as a microtask via Promise.then. This means async functions participate in exactly the same microtask queue as regular Promises.

Traditional Promise chaining:

function fetchUserData(id) {
    return fetch(`/api/users/${id}`)
        .then(res => {
            if (!res.ok) throw new Error('Network response was not ok');
            return res.json();
        })
        .then(user => {
            return fetch(`/api/posts?userId=${user.id}`);
        })
        .then(res => res.json())
        .then(posts => {
            return { user, posts };
        })
        .catch(err => {
            console.error('Failed to fetch:', err);
            throw err;
        });
}

Same logic with async/await — cleaner and easier to read:

async function fetchUserData(id) {
    try {
        const userRes = await fetch(`/api/users/${id}`);
        if (!userRes.ok) throw new Error('Network response was not ok');

        const user = await userRes.json();
        const postsRes = await fetch(`/api/posts?userId=${user.id}`);
        const posts = await postsRes.json();

        return { user, posts };
    } catch (err) {
        console.error('Failed to fetch:', err);
        throw err;
    }
}

What the engine actually does with await:

async function example() {
    console.log('Before await');   // sync — runs now
    const result = await somePromise;
    // ↑ Pauses here. Equivalent to:
    //   somePromise.then(result => {
    //       /* rest of function runs as microtask */
    //       console.log('After await:', result);
    //   });
    console.log('After await:', result);  // microtask
}

// Async functions always return a Promise, even if you return a plain value:
async function getValue() {
    return 42;  // same as: return Promise.resolve(42)
}

getValue().then(v => console.log(v));  // 42

Async Function Execution Order

Async/Await with Event Loop
async function asyncFn() {
    console.log('2: Inside async function (sync part)');
    await Promise.resolve();  // yields to microtask queue
    console.log('4: After await (microtask)');
}

console.log('1: Before calling asyncFn');
asyncFn();
console.log('3: After calling asyncFn (sync continues)');

// Output:
// 1: Before calling asyncFn
// 2: Inside async function (sync part)
// 3: After calling asyncFn (sync continues)
// 4: After await (microtask)
//
// The async function runs synchronously until the first await,
// then control returns to the caller. The rest resumes as a microtask.

Common Pitfalls and Best Practices

Pitfall 1: setTimeout(fn, 0) is not truly immediate

Even with a 0ms delay, setTimeout callbacks are macrotasks and always wait until the current script and all microtasks complete.

// This is wrong if you expect it to run immediately:
Promise.resolve().then(() => console.log('microtask'));
setTimeout(() => console.log('timeout 0'), 0);

// Output:
// microtask  ← runs first (microtask queue)
// timeout 0  ← runs second (macrotask queue)

Pitfall 2: Unhandled Promise rejections

If a Promise rejects and there's no .catch() or try/catch around an await, it becomes an unhandled rejection that can crash Node.js processes or produce hard-to-debug errors.

// Bad — unhandled rejection
async function badFetch() {
    const data = await fetch('/api/nonexistent');  // throws if network fails
    return data.json();
}

// Good — always handle errors
async function goodFetch() {
    try {
        const res = await fetch('/api/nonexistent');
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
    } catch (err) {
        console.error('Fetch failed:', err.message);
        return null;  // or re-throw for the caller to handle
    }
}

Pitfall 3: Sequential awaits when parallel is possible

Awaiting independent Promises sequentially is a common performance mistake. Use Promise.all() to run them in parallel.

// Slow — each awaits the previous (total: ~3 seconds)
async function slowVersion() {
    const user = await fetchUser(1);        // 1s
    const posts = await fetchPosts(1);      // 1s
    const comments = await fetchComments(1); // 1s
    return { user, posts, comments };
}

// Fast — all run in parallel (total: ~1 second)
async function fastVersion() {
    const [user, posts, comments] = await Promise.all([
        fetchUser(1),
        fetchPosts(1),
        fetchComments(1)
    ]);
    return { user, posts, comments };
}

// When some can fail independently, use Promise.allSettled
async function resilientVersion() {
    const results = await Promise.allSettled([
        fetchUser(1),
        fetchPosts(1),
        fetchComments(1)
    ]);
    return results.map(r => r.status === 'fulfilled' ? r.value : null);
}

Pitfall 4: Mixing async and sync in loops

Array.forEach does not await Promises. Use for...of for sequential async operations.

const ids = [1, 2, 3, 4, 5];

// WRONG — forEach doesn't await, all fire simultaneously
ids.forEach(async (id) => {
    const data = await fetchData(id);
    processData(data);  // race condition risk
});

// CORRECT — sequential processing
for (const id of ids) {
    const data = await fetchData(id);
    processData(data);
}

// CORRECT — parallel processing with controlled concurrency
const results = await Promise.all(ids.map(id => fetchData(id)));
results.forEach(processData);

Practical Performance Patterns

Understanding the event loop enables you to write more performant async code. Here are patterns used in real production applications:

Breaking Up Long Tasks with setTimeout
// Process a large array without blocking the UI
async function processLargeArray(items) {
    const chunkSize = 100;

    for (let i = 0; i < items.length; i += chunkSize) {
        const chunk = items.slice(i, i + chunkSize);

        // Process this chunk synchronously
        chunk.forEach(item => processItem(item));

        // Yield control back to the event loop between chunks
        // This lets the browser render frames and handle user input
        await new Promise(resolve => setTimeout(resolve, 0));
    }
}

// In Node.js, use setImmediate for better performance:
await new Promise(resolve => setImmediate(resolve));
Implementing a Retry Mechanism with Exponential Backoff
async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
    for (let attempt = 0; attempt <= retries; attempt++) {
        try {
            const res = await fetch(url, options);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            return await res.json();
        } catch (err) {
            if (attempt === retries) throw err;

            // Wait with exponential backoff before retrying
            const backoff = delay * Math.pow(2, attempt);
            console.warn(`Attempt ${attempt + 1} failed. Retrying in ${backoff}ms...`);
            await new Promise(resolve => setTimeout(resolve, backoff));
        }
    }
}

// Usage
const data = await fetchWithRetry('/api/unstable-endpoint', {}, 4, 500);

Monitoring the Event Loop in Node.js

Detecting Event Loop Lag (Node.js)
// Measure how long the event loop is blocked
function measureEventLoopLag(intervalMs = 500) {
    let last = Date.now();

    setInterval(() => {
        const now = Date.now();
        const lag = now - last - intervalMs;
        if (lag > 50) {
            console.warn(`Event loop lag detected: ${lag}ms`);
        }
        last = now;
    }, intervalMs).unref();  // .unref() prevents this from keeping the process alive
}

measureEventLoopLag();

// In production, use the built-in perf_hooks module:
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
setInterval(() => {
    console.log(`P99 event loop delay: ${histogram.percentile(99) / 1e6}ms`);
}, 5000);

Key Takeaways

The JavaScript event loop is not magic — it's a well-specified algorithm that you can reason about precisely once you understand its rules. Here's what every developer should internalize:

Event Loop Rules to Remember

  • JavaScript is single-threaded, but the runtime environment is not. Web APIs run outside the JS engine.
  • The call stack must be empty before any callback from a queue is executed.
  • Microtasks (Promises) always run before macrotasks (setTimeout) — the entire microtask queue drains after each task.
  • One macrotask per event loop tick — then back to checking the microtask queue again.
  • await creates a microtask checkpoint — it yields execution and resumes via the microtask queue.
  • Blocking the call stack blocks everything — keep synchronous work short; use async for I/O.
  • Use Promise.all for independent async operations — don't await them sequentially when parallelism is possible.
"The event loop is the heart of JavaScript's concurrency model. It's not complicated once you understand that there's only one rule: the microtask queue always drains before the next macrotask."

Mastering the event loop changes how you debug async code, design application architectures, and reason about performance bottlenecks. The next time you see an unexpected execution order or a UI that freezes, you'll know exactly where to look — and how to fix it.

JavaScript Event Loop Async Promises Web APIs Performance Node.js
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.