The JavaScript Event Loop Isn't Magic

TL;DR

JavaScript runs on a single thread with an event loop. Synchronous code runs to completion. Async callbacks wait in queues. Microtasks (Promises) run before macrotasks (setTimeout). Blocking the call stack freezes everything.

A senior developer told me he got through five years of JavaScript without understanding the event loop. Then he tried to optimize a React app with 500ms freezes and had to learn it all in one weekend. Understanding it upfront is faster.

Here's how JavaScript actually handles concurrency.

Single Thread, But Not Sequential

JavaScript has one thread. One call stack. One thing runs at a time.

console.log('1');
console.log('2');
console.log('3');
// Output: 1, 2, 3
// Obvious

But:

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// Output: 1, 3, 2
// Why does 2 come last even with 0ms delay?

This is the event loop.

The Components

┌─────────────────────────────────────┐
│           Call Stack                │
│                                     │
│  main()                             │
│  console.log()                      │
└──────────────┬──────────────────────┘
               │
               ↓
┌─────────────────────────────────────┐
│        Web APIs / Node APIs         │
│                                     │
│  setTimeout, setInterval            │
│  fetch, XMLHttpRequest              │
│  fs.readFile, net.connect           │
│  DOM events                         │
└──────────────┬──────────────────────┘
               │
               ↓
┌──────────────────────┬──────────────┐
│   Microtask Queue    │ Macrotask    │
│   (Promise .then)    │ Queue        │
│   (queueMicrotask)   │ (setTimeout) │
│   (MutationObserver) │ (setInterval)│
│                      │ (I/O events) │
└──────────────┬───────┴──────────────┘
               │
               ↓
         Event Loop checks:
         "Call stack empty?
          Run all microtasks first,
          then one macrotask."

Step-by-Step Walk-Through

console.log('start');

setTimeout(() => {
    console.log('timeout');
}, 0);

Promise.resolve()
    .then(() => {
        console.log('promise 1');
    })
    .then(() => {
        console.log('promise 2');
    });

console.log('end');

// Output: start, end, promise 1, promise 2, timeout

What happens:

1. console.log('start')    → runs → prints "start"
2. setTimeout(cb, 0)       → registered with Timer API → cb queued in macrotask
3. Promise.resolve().then(cb1).then(cb2) → cb1 queued in microtask queue
4. console.log('end')      → runs → prints "end"
5. Call stack empty. Event loop checks:
   - Microtask queue: cb1 → runs → prints "promise 1"
   - cb1 resolved, cb2 queued in microtask → runs → prints "promise 2"
   - Microtask queue empty
   - Macrotask queue: setTimeout cb → runs → prints "timeout"

Key rule: All microtasks run before the next macrotask.

Microtasks vs Macrotasks

// Microtasks (drain completely before next macrotask):
Promise.resolve().then(() => {});
queueMicrotask(() => {});

// Macrotasks (run one at a time, after all microtasks):
setTimeout(() => {}, 0);
setInterval(() => {}, 100);
// Also: I/O callbacks, UI rendering events
// Microtasks can starve macrotasks
function infiniteMicrotasks() {
    Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
// setTimeout callbacks NEVER run
// Microtask queue is always non-empty

Why async/await Works the Way It Does

async/await is syntactic sugar over Promises:

async function fetchData() {
    console.log('before await');      // Runs synchronously
    const result = await fetch('/api'); // Suspends, returns to caller
    console.log('after await');       // Queued as microtask when fetch resolves
    return result;
}

console.log('before call');
fetchData();
console.log('after call');

// Output: before call, before await, after call, after await

await suspends the function and returns control to the event loop. The continuation runs as a microtask when the awaited value resolves.

The Call Stack and Blocking

// BAD - blocks the entire thread for seconds
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(45);  // ~5 seconds of blocked execution
// During these 5 seconds:
// - No user interactions respond
// - No timers fire
// - No network responses are processed
// - Browser tab is completely frozen
// GOOD - yield to event loop periodically
function processBatch(items, chunkSize = 1000) {
    return new Promise((resolve) => {
        let index = 0;
        const results = [];

        function processChunk() {
            const end = Math.min(index + chunkSize, items.length);

            for (; index < end; index++) {
                results.push(heavyOperation(items[index]));
            }

            if (index < items.length) {
                // Yield to event loop, continue next tick
                setTimeout(processChunk, 0);
            } else {
                resolve(results);
            }
        }

        processChunk();
    });
}

Node.js Event Loop Phases

Node.js has a more specific event loop with named phases:

timers          → setTimeout, setInterval callbacks
pending callbacks → I/O errors from previous iteration
poll            → Retrieve new I/O events, run their callbacks
check           → setImmediate callbacks
close callbacks → socket.on('close', ...)
// Node.js: setImmediate vs setTimeout(0)
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
// Order can vary at top level

// Inside an I/O callback, setImmediate always runs first
fs.readFile('file.txt', () => {
    setImmediate(() => console.log('immediate'));  // Runs first
    setTimeout(() => console.log('timeout'), 0);  // Runs second
});

// process.nextTick runs before other microtasks
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Output: nextTick, promise

Real-World Problems This Explains

Why UI freezes

// BAD - parse large JSON on main thread
document.getElementById('upload').addEventListener('change', (e) => {
    const reader = new FileReader();
    reader.onload = (event) => {
        const data = JSON.parse(event.target.result);  // 500ms on large file
        // UI frozen during parse
        renderData(data);
    };
    reader.readAsText(e.target.files[0]);
});

// GOOD - offload to Web Worker
const worker = new Worker('parser.worker.js');

document.getElementById('upload').addEventListener('change', (e) => {
    worker.postMessage({ file: e.target.files[0] });
    // Main thread free immediately
});

worker.onmessage = (event) => {
    renderData(event.data);  // Receives parsed result
};

// parser.worker.js (runs in separate thread, can block freely)
self.onmessage = async (event) => {
    const text = await event.data.file.text();
    const data = JSON.parse(text);
    self.postMessage(data);
};

Why Promise chains work

// Each .then() returns a new Promise
// Resolved value flows through the chain
fetch('/api/user')
    .then(response => response.json())       // Microtask 1
    .then(user => fetch(`/api/posts/${user.id}`))  // Microtask 2 (new fetch)
    .then(response => response.json())       // Microtask 3
    .then(posts => render(posts));            // Microtask 4

// Each callback runs as a microtask
// Runs after current stack clears, before any setTimeout

Why setTimeout(fn, 0) defers execution

// This runs BEFORE the DOM updates in the same synchronous block
button.click();
console.log(button.textContent);  // May see old value

// This runs AFTER current synchronous code + DOM update
setTimeout(() => {
    console.log(button.textContent);  // Sees new value
}, 0);

// Common pattern: defer until after render
function afterRender(callback) {
    requestAnimationFrame(() => {
        setTimeout(callback, 0);
    });
}

Common Mistakes

Expecting sequential behavior from async code

// BAD - assumes getData finishes before log
getData();
console.log(data);  // undefined! getData is async

// GOOD
const data = await getData();
console.log(data);

Blocking with synchronous operations in async functions

// BAD - sync I/O blocks event loop even inside async function
async function handler(req, res) {
    const data = fs.readFileSync('huge-file.json');  // Blocks everything!
    res.json(JSON.parse(data));
}

// GOOD - async I/O
async function handler(req, res) {
    const data = await fs.promises.readFile('huge-file.json', 'utf8');
    res.json(JSON.parse(data));
}

The Bottom Line

The event loop is JavaScript's solution to concurrency on a single thread. Async operations wait in queues and run when the call stack is empty.

Key points:

  • Synchronous code blocks everything—never do CPU-intensive work on the main thread
  • Microtasks (Promises) run before macrotasks (setTimeout), after each stack frame empties
  • await suspends and returns control, it doesn't block the thread
  • Long synchronous operations freeze UI and prevent callbacks from running
  • Use Web Workers for CPU work, async I/O for everything else

Once you see the event loop, setTimeout(fn, 0) tricks, Promise ordering, and UI freezes all make sense. It's not magic—it's a queue.