JavaScript Event Loop

5 minute read

The event loop is the mechanism that lets JavaScript — a single-threaded language — handle network calls, timers, clicks, and animations without blocking. Once you understand the loop’s parts (call stack, web APIs, task queue, microtask queue), every “weird timing” issue with setTimeout, promises, and async/await suddenly makes sense.

Single-Threaded JavaScript

JavaScript runs on one main thread. Only one piece of JS code executes at any moment. So how does a long network request not freeze the browser? The browser hands work off to its own threads (network, timers, rendering) and JS just gets a callback when each task completes.

The Four Pieces

┌─────────────────────┐      ┌────────────────────────┐
│    Call Stack       │      │    Web APIs            │
│ (currently running) │      │  setTimeout, fetch,    │
│                     │      │  DOM events, …         │
└──────────┬──────────┘      └────────────┬───────────┘
           │                              │ when work is done,
           │ JS runs here                 │ push callback into
           │                              │ a queue
           ▼                              ▼
       (pop frames)            ┌──────────────────────┐
           ▲                   │  Microtask Queue     │ ← promises, queueMicrotask
           │                   │  (high priority)     │
           │                   └──────────┬───────────┘
           │                              │
           │                   ┌──────────▼───────────┐
           └─────────────────  │  Task Queue          │ ← setTimeout, click, etc.
              event loop picks │  (a.k.a. macrotask)  │
              the next task    └──────────────────────┘

The loop’s job, repeated forever:

  1. If the call stack has work, run it to completion.
  2. Then drain all microtasks (one after another, until the queue is empty).
  3. Render if the browser wants to.
  4. Pick one task from the task queue and push it onto the stack. Back to step 1.

The key insight: microtasks always run before the next task.

Demonstrating the Order

console.log("1");

setTimeout(() => console.log("2"), 0);     // task queue

Promise.resolve().then(() => console.log("3")); // microtask queue

console.log("4");

// Output:
// 1
// 4
// 3
// 2

Walk-through:

  1. Synchronous: "1", "4".
  2. After the script finishes, the loop drains microtasks → "3".
  3. Then picks one task → "2".

setTimeout(fn, 0) doesn’t mean “run now” — it means “run as soon as possible, after the current sync code and after any pending microtasks”.

Microtasks vs Tasks

Microtasks (high priority) Tasks / Macrotasks
Promise.then/catch/finally setTimeout, setInterval
queueMicrotask(fn) setImmediate (Node-only)
MutationObserver DOM events (click, input)
await continuation (essentially .then) fetch response handling triggers tasks; the JSON parse continuation is microtask
  requestAnimationFrame (special, runs before paint)
  MessageChannel, postMessage

Microtasks always drain to empty before the next task. If a microtask schedules more microtasks, those run too — same loop iteration.

queueMicrotask(() => {
  console.log("micro 1");
  queueMicrotask(() => console.log("micro 2"));
});
setTimeout(() => console.log("task"), 0);
console.log("sync");

// Output:
// sync
// micro 1
// micro 2          ← drained before the task
// task

A Classic Puzzle

async function run() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

console.log("1");
run();
console.log("2");

// Output:
// 1
// A
// 2
// B

What happened:

  1. "1".
  2. run() starts synchronously → "A". Hits await → pauses, schedules the rest as a microtask.
  3. Caller continues → "2".
  4. Sync code done → drain microtasks → "B".

await is essentially Promise.then(continuation), where continuation is everything after the await.

Blocking the Loop

Because JS is single-threaded, any long-running synchronous code stops everything — no events fire, no animations, no rendering.

btn.addEventListener("click", () => {
  for (let i = 0; i < 1e9; i++) { /* heavy work */ }
  console.log("done");
});

While that loop runs, the page is frozen. The classic fixes:

  • Chunk the work into smaller pieces with setTimeout/requestIdleCallback
  • Offload to a Web Worker (separate thread)
  • Use streaming APIs that yield naturally (async iterators, fetch streams)
async function chunkedWork(items, fn) {
  for (let i = 0; i < items.length; i++) {
    fn(items[i]);
    if (i % 1000 === 0) await new Promise(r => setTimeout(r, 0));
  }
}

requestAnimationFrame — Just Before Paint

A special “render” callback that runs before the next paint:

function animate(t) {
  ball.style.transform = `translateX(${t / 10}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

It’s batched with the browser’s render loop (typically 60 fps) — way smoother than setInterval(fn, 16).

Node.js Event Loop — Brief Note

Node has the same loop concept but with phases: timers, pending callbacks, poll (I/O), check, close. The microtask queue (promises + process.nextTick) drains between each phase, just like browsers.

The main difference you’ll notice: setImmediate(fn) vs setTimeout(fn, 0) ordering depends on context, and process.nextTick is highest-priority of all (microtask-like, before promise microtasks).

A Realistic Example — Order Predictor

console.log("script start");

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

Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));

queueMicrotask(() => console.log("queueMicrotask"));

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

console.log("script end");

// Output:
// script start
// script end
// promise 1
// queueMicrotask
// promise 2
// timeout 1
// timeout 2

Trace:

  1. Sync: "script start", "script end".
  2. Microtasks drain: "promise 1" → fires the next .then (another microtask) and "queueMicrotask" → next iteration of drain → "promise 2".
  3. Task queue: "timeout 1". Drain microtasks (none). Next task: "timeout 2".

If you can confidently explain this, you’ve internalised the loop.

Common Mistakes

// ❌ Expecting setTimeout 0 to run before pending promises
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
// promise → timer

// ❌ "Awaiting" inside forEach (forEach doesn't await)
[1, 2, 3].forEach(async n => await save(n));   // all start in parallel, function returns immediately

// ✅
for (const n of [1, 2, 3]) await save(n);    // sequential
await Promise.all([1, 2, 3].map(save));       // parallel

// ❌ Long sync loop in an event handler — UI freezes
btn.onclick = () => { for (let i = 0; i < 1e9; i++); };

// ✅ Yield to the loop every N items, or use a worker

// ❌ Microtask starvation — endless chain blocks rendering
Promise.resolve().then(function recurse() {
  // forever
  Promise.resolve().then(recurse);
});
// Page never paints — microtask queue never drains

Warning: A microtask that schedules more microtasks can starve the task queue and the renderer. If you’re recursing through promises, break with await new Promise(r => setTimeout(r, 0)) periodically.

Best Practices Checklist

  • ✅ Use await over chained .then for readability — same loop semantics
  • ✅ Use requestAnimationFrame for animation, not setInterval
  • ✅ Use requestIdleCallback (or scheduler.postTask) for low-priority work
  • ✅ Chunk heavy CPU work; offload to Web Workers when possible
  • ✅ Memorise: sync → microtasks → render → next task
  • ❌ Don’t block the loop with long sync work
  • ❌ Don’t await inside forEach
  • ❌ Don’t recurse microtasks without yielding to a task

What’s Next?

You understand the engine that runs async code. Now we trace the history of async patterns starting with the original: callbacks — what they are, why they were everywhere, and the “callback hell” that pushed the language toward promises.

← Previous: JS Events Next: JS Callbacks →

Updated: