The Runtime Theory
RuntimeInternalsexecution

What happens in one tick of the event loop?

A step-by-step walk of a browser/Node event-loop turn: the macrotask, timer and I/O phases, the microtask drain, and the render step.

The Runtime Theory Team1 min read05 steps

layer stack

Runtime

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

adjacent altitudes in this subsystem are still being traced

trace spine

  1. 01 A macrotask runs to completion
  2. 02 Timer and I/O callbacks run
  3. 03 Microtask queue drains
  4. 04 Render step (browsers)
  5. 05 Next macrotask begins

JavaScript is single-threaded, yet servers and browsers stay responsive. The trick is a cooperative scheduler: work is split into tasks, and the event loop runs them one at a time, draining the fastest queue first. One "tick" of that loop is this trace.

trace stepRuntime

The loop takes the next macrotask off the task queue — a script block, an event handler, a setTimeout callback, an I/O callback — and runs it to completion. Nothing interrupts it: no preemption, no timeslice. A task that computes for 500 ms blocks the entire process — this is the famous long-task problem. The queue itself is a FIFO of callbacks held by the embedding environment (the browser's task source, or libuv's loop in Node).

trace stepRuntime

The task finished; before rendering, the loop services the phase queues in Node (timers → pending → poll → check → close) or the browser's task sources (timers, I/O, microtasks are separate). setTimeout(fn, 0) callbacks do not run now — they're macrotasks for the next tick. Node's poll phase is where I/O completion callbacks run; a missing timer deadline is what wakes the poll wait. This phase runs until all queues are empty or the poll phase times out.

trace stepRuntime

Then the loop drains the microtask queue — and here is the ordering trick that defines promise semantics: after every macrotask (and after every microtask), the loop empties the entire microtask queue before touching anything else. Promise.then, queueMicrotask, and await continuations all land here. Because the drain is exhaustive, one microtask that queues another can keep the loop in the drain indefinitely — starve timers, starve I/O, starve rendering. This is the "infinite promise loop freezes the tab" failure mode, and it's not the scheduler's bug; it's the priority you asked for.

trace stepApplication

In a browser, the tick ends with a potential render step: the browser checks whether a rendering opportunity exists (16.6 ms cadence, or earlier with urgent paint work), and if so runs style, layout, paint, and compositing. The event loop only reaches here if the queues are empty — which is why a busy loop delays paints: requestAnimationFrame callbacks are scheduled here, tied to the render step, not to a timer. Node has no render step; its loop just returns to phase servicing.

bash
node -e "
setTimeout(()=>console.log('timer'),0);
Promise.resolve().then(()=>console.log('microtask'));
console.log('task');"   # task → microtask → timer, every time
trace stepRuntime

The loop returns to step 1: pull the next macrotask. The tick's total time is the sum of everything above, and the loop's one invariant is that only the current task can block. Everything else — timers, I/O, promises, paints — is waiting on the current task's completion. That invariant is the entire contract of JavaScript concurrency: no data races, because nothing runs concurrently — and no fairness, for the same reason.

What the machine actually does is a strict priority auction with three bidders: macrotasks get the turn, microtasks get the leftovers immediately, and rendering gets whatever remains before the next turn. The loop never runs two things at once — its entire performance story is deciding what runs in the gaps.