JavaScript Event Loop

Understand why JavaScript is non-blocking: the call stack, Web APIs, microtask queue, macrotask queue, and the event loop that ties them together.

Advanced 11 min read 9 examples

JavaScript Concurrency Model

JavaScript has a single call stack - only one function runs at a time. Yet it can handle many concurrent operations (network, timers, events) without blocking. This works because of the event loop.

ComponentWhat it isWhere code lives
Call StackSingle thread - runs JS codeYour JavaScript code
Web APIsBrowser/Node native APIssetTimeout, fetch, addEventListener
Microtask QueueHigh-priority callbacksPromise.then, queueMicrotask, MutationObserver
Macrotask QueueRegular async callbackssetTimeout, setInterval, I/O, UI events
Event LoopMoves tasks to the call stackThe runtime mechanism

The Call Stack

The call stack is a LIFO (last in, first out) data structure. Each function call pushes a frame; returning pops it. When the stack is empty, the event loop picks the next task.

JavaScript
function third()  { console.log('third');  }
function second() { third(); console.log('second'); }
function first()  { second(); console.log('first'); }

first();
// Stack trace:
// 1. first()  pushed
// 2. second() pushed (called from first)
// 3. third()  pushed (called from second)
// 4. third()  pops - logs 'third'
// 5. second() pops - logs 'second'
// 6. first()  pops  - logs 'first'
// Stack empty

// Stack overflow - too many nested calls
function infinite() { return infinite(); }
// infinite(); // RangeError: Maximum call stack size exceeded

// A stack trace error shows you the call path
function a() { b(); }
function b() { c(); }
function c() { throw new Error('oops'); }
try { a(); } catch (e) {
    console.log(e.stack);
    // Error: oops
    //   at c (file.js:3)
    //   at b (file.js:2)
    //   at a (file.js:1)
}

Web APIs and the Queue

When you call a Web API (setTimeout, fetch), the browser handles it off the main thread. When it completes, the callback goes into a queue - not the call stack directly.

JavaScript
console.log('1 - script starts');

setTimeout(function timeout() {
    console.log('4 - setTimeout callback');
}, 0); // 0ms delay - goes to Web API, then macro queue

console.log('2 - after setTimeout call');

// At this point: stack is empty
// Event loop checks queues, picks up timeout callback
console.log('3 - end of script (synchronous)');
// Logs: 1, 2, 3, then 4 (setTimeout fires after all sync code)

// How setTimeout works internally:
// 1. setTimeout() is called - pushes to call stack momentarily
// 2. Browser hands the timer to the Web API
// 3. setTimeout call returns, pops from stack
// 4. Code continues (logs 2 and 3)
// 5. After delay, browser places callback in the macrotask queue
// 6. Event loop sees empty stack, moves callback to stack
// 7. Callback runs (logs 4)

// This is why you cannot "sleep" synchronously in JS:
// while (Date.now() < start + 1000) {} // BAD - blocks the entire thread
// Use setTimeout or await delay() instead

Microtasks

Microtasks run after the current synchronous code and before any macrotask. The microtask queue drains completely (including new microtasks added during draining) before the next macrotask runs.

JavaScript
// Promise .then callbacks are microtasks
console.log('1 - sync');

Promise.resolve().then(() => console.log('3 - microtask 1'));
Promise.resolve().then(() => console.log('4 - microtask 2'));

console.log('2 - sync');
// Output: 1, 2, 3, 4
// All sync code runs first, then all microtasks

// Microtasks added during microtask draining also run before macrotasks
Promise.resolve().then(() => {
    console.log('microtask A');
    Promise.resolve().then(() => {
        console.log('microtask B (added during A)');
        // could go infinite - be careful!
    });
});
setTimeout(() => console.log('macrotask'), 0);
// Output: microtask A, microtask B, macrotask
// macrotask waits for ALL microtasks including nested ones

// queueMicrotask - explicit microtask scheduling
queueMicrotask(() => console.log('queued microtask'));
console.log('sync after queueMicrotask');
// Output: sync after queueMicrotask, queued microtask

// Sources of microtasks:
// - Promise.then() / Promise.catch() / Promise.finally()
// - await (resumes as a microtask)
// - queueMicrotask()
// - MutationObserver callbacks

Macrotasks

The event loop picks ONE macrotask per iteration, then drains the full microtask queue before picking the next one.

JavaScript
// setTimeout and setInterval create macrotasks
setTimeout(() => console.log('macro 1'), 0);
setTimeout(() => console.log('macro 2'), 0);
Promise.resolve().then(() => console.log('micro 1'));
Promise.resolve().then(() => console.log('micro 2'));

// Order: micro 1, micro 2, macro 1, macro 2
// All microtasks drain first, then macrotasks run one at a time

// setInterval - repeating macrotask
const id = setInterval(() => {
    console.log('tick');
}, 1000);
setTimeout(() => clearInterval(id), 5000); // stop after 5 ticks

// Macrotask sources:
// - setTimeout / setInterval callbacks
// - User events (click, keydown, mousemove)
// - I/O callbacks (Node.js)
// - MessageChannel.postMessage
// - requestAnimationFrame (browser rendering queue - separate from macro queue)

// requestAnimationFrame - special: runs before each screen repaint (~60fps)
// More reliable than setTimeout for animations
function animate(timestamp) {
    draw(timestamp);
    requestAnimationFrame(animate); // schedules next frame
}
requestAnimationFrame(animate);

Execution Order

Putting it all together - the complete execution order with async/await, Promises, and setTimeout.

JavaScript
// Classic interview question - predict the order
console.log('1');

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

Promise.resolve()
    .then(() => console.log('3'))           // microtask
    .then(() => console.log('4'));          // microtask (after 3 settles)

async function asyncFn() {
    console.log('5');                       // sync (inside async fn, before await)
    await Promise.resolve();                // microtask checkpoint
    console.log('6');                       // microtask (resumes after await)
}
asyncFn();

console.log('7');

// Order: 1, 5, 7, 3, 6, 4, 2
// Explanation:
// Sync: 1 -> asyncFn() starts -> logs 5 -> hits await -> pauses asyncFn -> logs 7
// Microtask drain: Promise chain logs 3 -> asyncFn resumes logs 6 -> chain logs 4
// Macrotask: setTimeout logs 2

// Rule of thumb for predicting order:
// 1. Run ALL synchronous code top to bottom
// 2. Run ALL microtasks (Promise callbacks, await resumes) until queue empty
// 3. Run ONE macrotask (setTimeout callback)
// 4. Go to step 2

Practical Implications

JavaScript
// Implication 1: Long sync tasks block everything
// BAD - blocks rendering and events for the entire loop duration
function expensiveSync(data) {
    return data.reduce((acc, x) => acc + heavyCompute(x), 0);
}

// GOOD - yield control via setTimeout for large work
async function expensiveChunked(data, chunkSize = 100) {
    let result = 0;
    for (let i = 0; i < data.length; i++) {
        result += heavyCompute(data[i]);
        if (i % chunkSize === 0) {
            await new Promise(r => setTimeout(r, 0)); // let browser breathe
        }
    }
    return result;
}

// Implication 2: DOM updates inside Promise chains render in microtasks
// but the ACTUAL repaint happens on the next frame (macro-level)
button.addEventListener('click', async () => {
    status.textContent = 'Loading...'; // DOM update
    const data = await fetch('/api/data').then(r => r.json());
    status.textContent = 'Done';       // DOM update - both visible together on next repaint
});

// Implication 3: queueMicrotask for "just after this synchronous block"
class Observable {
    constructor() { this.listeners = []; }

    emit(value) {
        queueMicrotask(() => {
            // notify after current call stack unwinds, before any macrotask
            this.listeners.forEach(fn => fn(value));
        });
    }
}
Starvation: microtasks can block macrotasks

If your microtask queue never empties (e.g., a microtask keeps scheduling new microtasks in a loop), macrotasks - including rendering and user event callbacks - will never run. This freezes the UI. Always ensure your microtask chains terminate.

Frequently Asked Questions

JavaScript runs on a single thread - only one piece of code runs at a time. But the browser (or Node.js) provides Web APIs (setTimeout, fetch, addEventListener) that run off the main thread. When they complete, their callbacks are placed in a queue. The event loop continuously checks: if the call stack is empty, pick the next task from the queue and run it. This is how long operations (network, timers) are handled without blocking the thread.

Microtasks (Promise callbacks, queueMicrotask, MutationObserver) run after the current task and before the next macrotask - they drain completely before any macrotask runs. Macrotasks (setTimeout, setInterval, I/O, UI events) are picked one at a time by the event loop. Priority order: call stack runs to completion, then ALL microtasks drain (including microtasks added by microtasks), then ONE macrotask runs, then microtasks again, and so on.

Even setTimeout(fn, 0) schedules a macrotask - it goes through the Web API timer and into the callback queue. It will only run after: (1) the current synchronous code finishes, and (2) all microtasks (Promises) are drained. In practice the minimum delay is about 4ms in browsers. setTimeout(fn, 0) is useful to defer work to the next event loop iteration, but it is not truly "zero delay".

queueMicrotask(fn) schedules a function to run as a microtask - after the current synchronous code, before the next macrotask, at the same priority as resolved Promises. Use it when you need to defer work until "just after this call" without the overhead of creating a Promise. It is the lowest-level way to schedule a microtask explicitly.