The Event Loop

One thread runs everything — your code, the DOM, every animation frame. The loop that schedules that thread decides whether a frame ships in 16.6 ms or stalls behind a task that wouldn't yield.

Core Idea

The mote above is redrawn on every turn of a loop — the same loop that runs every line of JavaScript on this page, handles your pointer, and lets the browser paint. There is exactly one thread for all of it. Press inject 40 ms task and you hand that thread a single synchronous job (a real busy-wait, not a simulation): for 40 milliseconds nothing else can happen — no animation, no input, no paint. The timeline underneath is measuring real frame times in your real browser; the terracotta block is the turn that wouldn’t end. Every stutter you have ever seen on the web is some version of that block. The browser is not slow — it is waiting for its one thread back.

One Turn of the Loop

The event loop’s contract is simple and absolute: take one task, run it to completion, then look around. A task is any callback the browser owes you — a click handler, a timer, a network response. Nothing can interrupt a running task; that is why a long one freezes the page, and why no other JavaScript will ever see your data structures mid-mutation. When the task returns, the loop drains the microtask queue — promise reactions, queueMicrotask, MutationObservercompletely, to empty, before doing anything else. Only then, if the display is due a new image, does it run the render steps: requestAnimationFrame callbacks first, then style, layout, and paint (the pipeline that browser-rendering walks through stage by stage).

console.log('task begins')
setTimeout(() => console.log('a new task'), 0)
Promise.resolve().then(() => console.log('microtask'))
requestAnimationFrame(() => console.log('render steps'))
console.log('task ends')
// → task begins · task ends · microtask · … then the other two,
//   in an order that depends on where in the frame you are

The first three lines of output are guaranteed. The last two race — a setTimeout(0) task may slip in before the next frame or after it — which is the honest reason timers are the wrong tool for animation. The instrument below measures the real order, live, in your browser.

Tasks, Microtasks, and Starving the Frame

The queues are not equals. Tasks are polite: the loop runs one, then checks whether a frame is due. Microtasks are greedy by design: the queue drains to empty before the loop moves on — and a microtask may enqueue more microtasks. Press microtask storm above: two thousand chained .then() reactions, each doing a sliver of work, all of it draining inside a single turn while the frame lane waits. A promise chain long enough — or a badly recursive queueMicrotask — can starve rendering exactly like one long task, just in two thousand well-intentioned pieces.

setTimeout deserves its own disillusionment: it is not a scheduler, it is a minimum delay before a task is queued. Nested timers get clamped (typically to ≥ 4 ms), background tabs get throttled to once a second or worse, and the callback still waits its turn behind whatever task is running. requestAnimationFrame is the only callback that is defined to run once per frame, right before the browser renders — which is why every render loop worth shipping is built on it.

The Frame Budget Is a Consequence

A 60 Hz display wants a new image every 16.6 ms. Nobody grants your code that budget — it simply is the length of one loop turn if the animation is to hold: your rAF callback, everything it calls, plus the browser’s own style-layout-paint, all inside one turn. That is the architecture behind the frame budget that cpu-gpu-bound measures against: when the CPU side of a frame runs long, this loop is where it runs long. It is also why TIKI DASH holds a frame budget measured on real phones — physics, spawning, scene-graph updates and the render steps all share one turn of one loop on a mid-range Android, and the loop does not negotiate. A garbage-collection pause is the same theft by a different thief: the collector borrows the thread mid-turn, and the frame pays.

Async Is Not Parallel

async/await changes when code runs, never where. An await parks the rest of the function as a microtask continuation and returns the thread to the loop — cooperative scheduling, not concurrency. While a promise is pending, the thread is free; when the continuation runs, it runs on the same single thread, blocking like everything else. Ten awaited fetches overlap their network time, but every byte of your JavaScript still executes one piece at a time. The only way to genuinely run JavaScript beside this loop is another thread with its own loop — a worker — which is exactly where web-workers picks up.

Event-Loop Citizenship

The loop is a commons, and render loops are its heaviest users — so a well-behaved one yields what it isn’t using. Every instrument in this knowledge base, including the two on this page, disconnects its own requestAnimationFrame chain when it scrolls out of view (an IntersectionObserver flips a flag) and when the tab is hidden (document.hidden): no work scheduled for frames nobody sees. The same courtesy, inverted, is a diagnostic: if a page’s fans spin while it sits in a background tab, some loop on that page never learned to stop asking for turns.

Connections

Everything downstream of this page happens inside one turn of this loop. The render steps at the end of a turn are the subject of browser-rendering — style, layout, paint, composite, and which CSS properties trigger which. The frame budget the loop imposes is the yardstick of cpu-gpu-bound and the standing obsession of mobile-optimization, where slower cores make every turn tighter. Garbage-collection is the pause you cause without writing a slow line. And when one thread is genuinely not enough, web-workers is the door out — a second thread, a second loop, and a rendering canvas that can move there with it.