The Event Loop and Concurrency Model

โ–ถ Try It Yourself

JavaScript is single-threaded โ€” only one piece of code runs at a time. Yet browsers handle network requests, timers, and user events simultaneously without freezing. This apparent contradiction is resolved by the event loop: a mechanism that coordinates the execution of synchronous code, asynchronous callbacks, microtasks, and macrotasks. Understanding how the event loop works is the key to understanding why setTimeout(fn, 0) does not run immediately, why Promises resolve before timers, and why blocking the main thread freezes the UI. Everything in async JavaScript builds on this foundation.

The JavaScript Runtime Components

Component Role Holds
Call Stack Executes synchronous code โ€” LIFO Currently running function frames
Heap Memory allocation for objects Objects, closures, variables
Web APIs Browser-provided async operations setTimeout, fetch, DOM events, geolocation
Macrotask Queue Scheduled callbacks โ€” runs one per loop tick setTimeout, setInterval, I/O, UI events
Microtask Queue High-priority callbacks โ€” drains completely before next macrotask Promise .then/.catch/.finally, queueMicrotask
Event Loop Coordinator โ€” picks next task when stack is empty Logic: check microtasks โ†’ macrotask โ†’ render โ†’ repeat

Event Loop Tick Order

Order Step Notes
1 Execute synchronous code until call stack is empty Runs to completion โ€” cannot be interrupted
2 Drain the entire microtask queue All .then callbacks, even ones added during drain
3 Render if needed (browser) requestAnimationFrame runs here
4 Execute ONE macrotask from the queue One setTimeout/setInterval callback per tick
5 Drain microtask queue again Any microtasks queued by the macrotask
6 Repeat from step 3 Continues until both queues are empty

Macrotasks vs Microtasks

Feature Macrotask Microtask
Examples setTimeout, setInterval, DOM events, fetch callbacks Promise.then/catch/finally, queueMicrotask, MutationObserver
Processing One per event loop tick Entire queue drained before next macrotask
Priority Lower โ€” runs after microtasks Higher โ€” runs before next macrotask
Starvation risk No โ€” always gets a turn eventually Yes โ€” infinite microtasks will block macrotasks forever
Note: setTimeout(fn, 0) does not mean “run immediately” โ€” it means “schedule as a macrotask with minimum delay.” The callback will only run after the current synchronous code finishes AND all pending microtasks are drained. In practice, even setTimeout(fn, 0) has a minimum delay of ~4ms in browsers, and may be delayed further if the system is busy.
Tip: Use queueMicrotask(fn) when you need to defer a callback to run after the current synchronous code but before any timers or I/O callbacks. It is semantically clearer than Promise.resolve().then(fn) and avoids creating an unnecessary Promise object. Both schedule a microtask, but queueMicrotask makes the intent explicit.
Warning: Long-running synchronous code blocks the event loop โ€” the browser cannot process events, render animations, or run any callbacks until the stack is empty. If you need to process a large dataset, break it into chunks using setTimeout(processNextChunk, 0) or use a Web Worker to run the computation off the main thread entirely.

Basic Example

// โ”€โ”€ Execution order demonstration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
console.log('1 โ€” sync start');

setTimeout(() => console.log('4 โ€” macrotask (setTimeout 0)'), 0);

Promise.resolve()
    .then(() => console.log('3 โ€” microtask (Promise.then)'));

queueMicrotask(() => console.log('3b โ€” microtask (queueMicrotask)'));

console.log('2 โ€” sync end');

// Output order:
// 1 โ€” sync start
// 2 โ€” sync end
// 3 โ€” microtask (Promise.then)
// 3b โ€” microtask (queueMicrotask)
// 4 โ€” macrotask (setTimeout 0)

// โ”€โ”€ Multiple microtasks drain completely โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
setTimeout(() => console.log('macrotask'), 0);

Promise.resolve()
    .then(() => {
        console.log('microtask 1');
        // Adding another microtask from inside a microtask:
        return Promise.resolve();
    })
    .then(() => console.log('microtask 2'))
    .then(() => console.log('microtask 3'));

// Output: microtask 1, microtask 2, microtask 3, macrotask
// The macrotask waits until ALL microtasks are drained

// โ”€โ”€ Visualising the call stack โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function third()  { console.log('third'); }
function second() { third(); }
function first()  { second(); }

// Call stack at deepest point:
// [third, second, first, anonymous (global)]
first();   // runs synchronously โ€” all frames pop before event loop checks queue

// โ”€โ”€ Blocking the event loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function blockingWork(ms) {
    const end = Date.now() + ms;
    while (Date.now() < end) {}   // busy-wait โ€” blocks EVERYTHING
}

// During blockingWork(2000): no UI events, no timer callbacks, no renders
// โŒ Never do this for long operations

// โ”€โ”€ Non-blocking with chunked processing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function processInChunks(items, processOne, chunkSize = 100) {
    let index = 0;

    function processChunk() {
        const end = Math.min(index + chunkSize, items.length);
        while (index < end) {
            processOne(items[index++]);
        }
        if (index < items.length) {
            setTimeout(processChunk, 0);   // yield to event loop between chunks
        }
    }

    processChunk();
}

const bigData = Array.from({ length: 10_000 }, (_, i) => i);
processInChunks(bigData, item => {
    // process each item
}, 500);

// โ”€โ”€ Starvation example โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// This will run forever โ€” microtasks starve macrotasks
function starve() {
    Promise.resolve().then(starve);   // โŒ infinite microtask loop
}
// Don't call starve() โ€” here for illustration only

// โ”€โ”€ requestAnimationFrame timing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// rAF runs between microtask drain and next macrotask
// Guaranteed to run before the browser paints

let frameCount = 0;
function countFrames() {
    frameCount++;
    if (frameCount < 60) requestAnimationFrame(countFrames);
}
requestAnimationFrame(countFrames);
// Will count exactly 60 frames at ~60fps โ€” one per render cycle

How It Works

Step 1 โ€” The Call Stack Is Synchronous and Single-Threaded

When a function is called, a frame is pushed onto the call stack. When it returns, the frame is popped. The engine can only process one frame at a time. A deeply nested call chain like first() โ†’ second() โ†’ third() builds up three stack frames; all three must complete and pop before the event loop can check the task queues.

Step 2 โ€” Web APIs Run Outside the Engine

When you call setTimeout(fn, 1000), the engine hands the timer and callback to the browser’s Web API environment and immediately continues executing. The browser tracks the timer separately. After 1000ms, the browser places fn in the macrotask queue. The engine picks it up only when the call stack is empty.

Step 3 โ€” Microtasks Have Higher Priority Than Macrotasks

After every synchronous code block and after every macrotask, the event loop completely drains the microtask queue before doing anything else โ€” including rendering and running the next macrotask. This guarantees that Promise resolutions are always processed as a group before any timer fires, ensuring predictable ordering in async code.

Step 4 โ€” Rendering Happens Between Microtask Drain and Next Macrotask

The browser decides whether to repaint the screen between each event loop tick โ€” typically targeting 60 frames per second (every ~16ms). requestAnimationFrame callbacks run just before the paint step, giving you a hook to update the DOM at the exact right time for smooth animation.

Step 5 โ€” Long Synchronous Code Blocks Everything

While synchronous code is running, the event loop is frozen. No callbacks fire, no events are processed, and the browser cannot repaint. If your synchronous function takes 500ms, the page is completely unresponsive for 500ms. The solutions are: break work into chunks yielded via setTimeout, or move heavy computation to a Web Worker.

Real-World Example: Task Scheduler

// task-scheduler.js
// Demonstrates event loop by scheduling tasks with different priorities

class TaskScheduler {
    #microtasks  = [];
    #macrotasks  = [];
    #rafTasks    = [];
    #log         = [];

    micro(fn, label) {
        queueMicrotask(() => {
            this.#log.push(`[microtask] ${label}`);
            fn();
        });
        return this;
    }

    macro(fn, label, delay = 0) {
        setTimeout(() => {
            this.#log.push(`[macrotask ${delay}ms] ${label}`);
            fn();
        }, delay);
        return this;
    }

    frame(fn, label) {
        requestAnimationFrame(() => {
            this.#log.push(`[rAF] ${label}`);
            fn();
        });
        return this;
    }

    sync(fn, label) {
        this.#log.push(`[sync] ${label}`);
        fn();
        return this;
    }

    printLog() {
        console.log(this.#log.join('
'));
    }
}

const scheduler = new TaskScheduler();

scheduler
    .sync(  () => {},   'Start')
    .macro( () => {},   'Timer 100ms', 100)
    .micro( () => {},   'Promise 1')
    .frame( () => {},   'Animation frame')
    .micro( () => {},   'Promise 2')
    .macro( () => {},   'Timer 0ms', 0)
    .sync(  () => {},   'End');

// Expected log order:
// [sync]          Start
// [sync]          End
// [microtask]     Promise 1
// [microtask]     Promise 2
// [rAF]           Animation frame
// [macrotask 0ms] Timer 0ms
// [macrotask 100ms] Timer 100ms

Common Mistakes

Mistake 1 โ€” Assuming setTimeout(fn, 0) runs immediately

โŒ Wrong assumption:

setTimeout(() => console.log('first'), 0);
console.log('second');
// Logs: 'second' then 'first' โ€” not 'first' then 'second'

โœ… Understand: setTimeout schedules a macrotask after current sync + microtasks:

// If you need something after sync code, use a microtask:
queueMicrotask(() => console.log('runs after sync but before timers'));

Mistake 2 โ€” Blocking the event loop with heavy computation

โŒ Wrong โ€” freezes the UI for the entire duration:

button.addEventListener('click', () => {
    const result = heavyComputation(1_000_000);  // blocks for 2 seconds
    display.textContent = result;
});

โœ… Correct โ€” use a Web Worker or yield between chunks:

button.addEventListener('click', async () => {
    const worker = new Worker('./heavy-worker.js');
    worker.postMessage({ count: 1_000_000 });
    worker.onmessage = e => display.textContent = e.data.result;
});

Mistake 3 โ€” Infinite microtask loop starving the render

โŒ Wrong โ€” page never updates because microtasks never end:

function loop() { Promise.resolve().then(loop); }
loop();  // renders freeze โ€” browser appears hung

โœ… Correct โ€” use setInterval or requestAnimationFrame for repeating work:

function loop() { requestAnimationFrame(loop); }
requestAnimationFrame(loop);  // runs once per frame โ€” browser can render

▶ Try It Yourself

Quick Reference

Task Mechanism Queue
Immediate deferred callback queueMicrotask(fn) Microtask
After current sync + microtasks Promise.resolve().then(fn) Microtask
Minimum delayed callback setTimeout(fn, 0) Macrotask
Before next paint requestAnimationFrame(fn) Render
Yield between chunks setTimeout(nextChunk, 0) Macrotask
Off-main-thread compute new Worker('./worker.js') Separate thread

🧠 Test Yourself

What is the output order of: setTimeout(() => console.log('A'), 0), Promise.resolve().then(() => console.log('B')), console.log('C')?





โ–ถ Try It Yourself