Skip to content

The Event Loop — Fundamentals

What it is and why it affects you

JavaScript runs your code on one thread with one call stack — one function at a time, to completion. And yet a page handles clicks, timers, and network responses “at once”. The trick is the event loop: a scheduler that runs one task, then picks the next from a queue.

You’ve relied on this without naming it:

console.log('1')
setTimeout(() => console.log('3'), 0)
console.log('2')
// prints 1, 2, 3 — the timeout callback waits its turn even at 0ms

The consequence that bites you: while your code runs, nothing else can. No clicks processed, no rendering, no timers. A 3-second loop means a 3-second frozen page — that’s the mechanism behind half the cases in these playbooks.

“Single-threaded” doesn’t mean “no concurrency”, though. I/O (network, disk, timers) happens off your thread — the platform does the waiting and queues a callback when ready. JS is single-threaded in your code, concurrent in its I/O. What it can’t do concurrently is your CPU work — for that you need actual extra threads (Web Workers / worker_threads).

The mental model

One loop, one rule: finish the current task, drain all microtasks, maybe render, take the next task.

┌─────────────────────────────┐
│ task (macrotask) │ ← script, setTimeout cb, click handler,
│ runs to completion, no │ fetch response handler
│ interruptions │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ drain microtask queue │ ← promise .then/await continuations,
│ (ALL of them, even newly │ queueMicrotask
│ queued ones) │
└──────────────┬──────────────┘
┌─────────────────────────────┐
│ render if needed (browser)│ ← style, layout, paint — only between
└──────────────┬──────────────┘ tasks, never mid-task
next task from queue ──► (loop)

Three rules cover almost every situation:

  1. Tasks are atomic. Nothing preempts running JS. A long task delays input handling and rendering — the browser can only paint between tasks.
  2. Microtasks run before anything else gets a turn. Every await/.then continuation runs before the next task or render. An infinite chain of microtasks starves rendering just like a while(true).
  3. await yields, it doesn’t unblock. await fetch(...) frees the loop while the network works (the waiting is elsewhere). But await heavyComputation() where the function is plain synchronous JS blocks exactly the same — async doesn’t create threads.

Node’s loop adds phases (timers, poll, check/setImmediate) and process.nextTick (ahead of even microtasks), but the model is the same: callbacks run one at a time on one thread; blocking it blocks every request the process is serving, not just one user’s.

Step through one full turn of the loop yourself:

Reference table

You writeIt’s queued asWhen it runs
synchronous codecurrent tasknow, blocking everything
promise.then(cb) / code after awaitmicrotaskafter current task, before render/next task
queueMicrotask(cb)microtasksame as above
process.nextTick(cb) (Node)nextTick queueeven before microtasks
setTimeout(cb, 0) / setInterval(macro)taska later loop turn — after render can happen
setImmediate(cb) (Node)check phaseafter I/O callbacks this turn
I/O callback (fetch resolution, fs cb)task (+ microtasks for promise APIs)when the platform finishes the I/O
requestAnimationFrame(cb)rendering stepright before the next paint
requestIdleCallback(cb)idle periodwhen the loop has nothing better to do
Web Worker / worker_threads codeanother thread entirelyin parallel; talks via message passing

The practical budget: at 60fps the browser paints every ~16ms, and web.dev’s long-task guideline flags any task over 50ms as jank territory. A task of 500ms is a visible freeze.

How this connects to the cases

This area is also the base for the others: frontend jank (The UI freezes, The input lags while typing) and backend stalls (Heavy jobs block requests) are this same loop, blocked in different places.

Primary sources