The Event Loop, Precisely
One call stack, task queues, a microtask checkpoint that drains to empty, and a rendering opportunity between tasks — the model that predicts any ordering puzzle.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why does this code print A, D, C, B — and what makes that ordering a guarantee rather than an accident of implementation?
A person clicks, types or scrolls and expects the interface to answer before they notice waiting. Every bit of work between their input and the resulting pixel is placed in order by one loop.
"JavaScript is single-threaded, and asynchronous code runs later." Both sentences are true, which is exactly why they feel sufficient. Neither one predicts anything: they do not say whether "later" is before or after the next frame, and they do not say which of two pending callbacks wins.
Two callbacks registered in the same function complete in an order that the word "asynchronous" does not predict. A promise callback registered second runs before a timer callback registered first, every time, in every browser — and nothing in the mental model above explains it.
- Two callbacks registered in the same function complete in an order that the word "asynchronous" does not predict. A promise callback registered second runs before a timer callback registered first, every time, in every browser — and nothing in the mental model above explains it.
- A
setTimeout(fn, 0)written to "let the UI update first" often does let it, and aPromise.resolve().then(fn)written with exactly the same intent never does. Same intent, opposite outcome, and the difference is which queue the callback landed in (The Rendering Opportunity). - Adding
console.logcalls to debug the ordering changes nothing about the ordering, so the bug reproduces perfectly and still makes no sense. - A tight
forloop over a large array makes the whole page unresponsive even though it performs no I/O and blocks on nothing. There is nothing to wait for; the thread is simply occupied (Long Tasks). - Code that reads element geometry immediately after a state update sees the old geometry, because the DOM mutation and the layout it implies are scheduled by the browser and not by the line ordering in your function (Layout Thrashing).
What is actually happening
In the browser, not in the framework.
- There is one call stack per page for your JavaScript. A function call pushes a frame; returning pops it. Nothing else runs while a frame is on that stack — the browser has no mechanism to preempt it (What the Main Thread Owns).
- The loop repeatedly does three things: run one task to completion, perform a microtask checkpoint, and possibly update the rendering. That is the entire model. Everything you know about
setTimeout, promises, events and animation is a consequence of where a callback enters it. - A task is one whole callback plus everything it synchronously calls. Tasks come from named task sources — timers, user interaction, DOM manipulation, networking, history traversal,
postMessage— and each source keeps its own queue (Tasks: The Unit That Cannot Be Interrupted). - A microtask checkpoint happens when the JavaScript execution context stack becomes empty. It drains the microtask queue to empty, including microtasks queued *during* the drain. Promise reactions and
queueMicrotaskland here (The Microtask Checkpoint). - A rendering opportunity exists only between tasks, after that checkpoint, and only when the browser decides one is due.
requestAnimationFramecallbacks run at that moment, before style and layout (The Rendering Opportunity). - Asynchronous APIs never "wait".
setTimeout,fetchandaddEventListenerall do the same shape of thing: hand a callback to a task source and return immediately. The waiting is done by the browser, on other threads, and the result arrives as a queued task (The Browser Is a Runtime).
What this makes the browser do
And which of it is avoidable.
- Maintaining a queue per task source and choosing which runnable queue to service next. The specification deliberately leaves that choice to the implementation, which is where the only genuine ordering ambiguity in the model lives.
- Running the microtask checkpoint after every callback that returns to an empty stack — not only at the end of a "big" task. This is more often than most people assume.
- Deciding, each turn, whether to spend time producing a frame: is the page visible, is a display refresh due, has anything actually been invalidated (The Frame Budget).
- Coalescing input. Multiple pointer or scroll events that arrive while the thread is busy are merged rather than replayed one by one, which is why a frozen page does not fire fifty
mousemovehandlers when it recovers (Pointer Events). - Bookkeeping you never see: timer heaps, event retargeting, and the accessibility-tree updates that happen on the same thread (The Accessibility Tree).
Four lines that settle the argument
This is the canonical example, and it is worth being able to derive the answer rather than remember it. Every line of it is synchronous or registers a callback; nothing here waits for anything.
The output is A, D, C, B. Not because promises are "faster" than timers, and not because the timer has a delay — it does not, the delay argument is omitted. It is because the promise callback goes into the microtask queue, which is drained at the end of the current task, and the timer callback goes into a task queue, which cannot be serviced until the current task and its microtask checkpoint have both finished.
1console.log("A");2setTimeout(() => console.log("B"));3Promise.resolve().then(() => console.log("C"));4console.log("D");5 6// A <- synchronous, runs now7// D <- synchronous, runs now8// C <- microtask: end of THIS task9// B <- task: the NEXT turn of the loopThe two asynchronous registrations are in the opposite order to the two asynchronous outputs. That inversion is the whole lesson: registration order does not decide anything across queue kinds.
TURN 1 -- task: "run the script"
step stack microtask queue timer queue output
---- ----------------------------- ---------------- ------------ ------
1 log("A") - - A
2 setTimeout(cb_B) - [cb_B]
3 Promise.resolve().then(cb_C) [cb_C] [cb_B]
4 log("D") [cb_C] [cb_B] D
5 (script returns; stack empty) [cb_C] [cb_B]
--> MICROTASK CHECKPOINT: drain to empty
6 cb_C - [cb_B] C
7 (queue empty; checkpoint ends)
--> rendering opportunity? maybe. If due, a frame is produced HERE.
TURN 2 -- task: the timer callback
8 cb_B - - BOne turn of the loop
The loop is small enough to hold in your head, which is what makes it useful. Each turn does at most one task, then drains microtasks completely, then decides whether to render. The decision to render is the browser's, not yours: a turn is a rendering opportunity only when the browser judges one is due, which depends on the display refresh, whether the page is visible, and whether anything was actually invalidated.
The single most important structural fact is that the microtask checkpoint sits *between* your task and any possible frame. That is why "just wrap it in a promise" never gives the user a repaint, and why the fix for a frozen page is always to end the task (Yielding and Scheduling).
- 1Select a task
Pick a task queue with a runnable task and take the oldest one from it. Queues are FIFO within a source; the choice *between* sources is the browser's.
fails by Cross-source ordering assumptions. Two callbacks from different sources are not ordered by the specification, so a sequence that is stable in one browser can invert in another.
- 2Run it to completion
Execute the callback and everything it synchronously calls. Nothing preempts it — not input, not a timer that has come due, not rendering.
fails by One long task. Input queues, frames stop, and the accessibility tree freezes with the DOM (Long Tasks).
- 3Microtask checkpoint
Drain the microtask queue to empty, including microtasks queued during the drain itself.
fails by An unbounded microtask chain never lets the checkpoint end, so the page hangs with no long task recorded (The Microtask Checkpoint).
- 4Maybe update the rendering
If this turn is a rendering opportunity, run animation-frame callbacks and observer steps, then style, layout, paint and composite.
fails by Skipped entirely when the page is hidden, offscreen or throttled — so animation logic driven by frames simply stops (The Rendering Opportunity).
Loop. The gap between two turns is the only place the user can be served.
Where each callback actually lands
Most ordering questions are answered by looking up the API rather than by reasoning. The table is worth internalising because it turns "I think this runs first" into a decidable claim.
Note the two rows that surprise people most. await is not a task boundary — resuming an awaited value is a microtask, so an await on already-available data does not give the browser anything. And MutationObserver is a microtask while ResizeObserver and IntersectionObserver are delivered during the rendering steps, which means a DOM change and its observed consequence can land in different phases of the same turn.
| API | Where the callback lands | Runs before the next frame? | The mistake it invites |
|---|---|---|---|
Promise.then / await resumption | Microtask queue | Yes — always, same turn | Believing await yields to the browser |
queueMicrotask | Microtask queue | Yes — always, same turn | Using it to "defer work" when it defers nothing the user can perceive |
MutationObserver | Microtask queue | Yes — same turn as the mutation | Expecting it after layout, so measurements read stale geometry |
setTimeout / setInterval | Timer task source | No — a later turn | Treating a zero delay as zero, and as unclamped |
MessageChannel / postMessage | Posted-message task source | No — a later turn | Assuming it is ordered against timers; it is a different source |
| DOM event listener | User-interaction or DOM task source | No — a later turn | Assuming two listeners on one event share one uninterrupted run (The Microtask Checkpoint) |
fetch response | Networking task source, then microtasks for the reactions | The reactions do; the task does not | Conflating "the response arrived" with "my .then ran" |
requestAnimationFrame | Rendering steps of the next opportunity | It *is* part of the frame | Using it as a general-purpose delay in a hidden tab, where it never fires |
requestIdleCallback | Idle period, browser-scheduled | No, and possibly not for many turns | Assuming it is universally available (Yielding and Scheduling) |
How to build it
Most important first.
- Learn to answer one question about any callback: which queue, and which turn? Every ordering argument in a code review resolves to that, and the answer is usually decidable from the API alone.
- Choose the queue for the meaning you want. A microtask means "before the browser can possibly paint" — good for keeping derived state consistent. A task means "after the browser has had a chance to paint" — good for letting the user see something first.
- Keep the unit of work small enough that the loop actually gets back to the top. Responsiveness is not a property of your function's speed; it is a property of how often the loop turns (Yielding and Scheduling).
- Treat
requestAnimationFrameas the only correct place to write code that must be consistent with the frame about to be produced, and never as a general-purpose "run soon" (The Rendering Opportunity). - When two callbacks must be ordered, order them explicitly — chain them, sequence them, or gate them on a flag. Relying on the relative ordering of two different task sources is relying on something the specification does not promise.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The accessibility tree is derived from the DOM by the same thread that is running your task. While a task runs, that tree is not updated, so a screen reader continues to describe the previous state with no indication that it is stale (The Accessibility Tree).
- A screen-reader user gets no equivalent of a stalled cursor or a half-painted frame. The visual user at least sees that nothing is moving; the non-visual user hears a confident, wrong answer.
- Focus changes queued during a busy task apply late. If focus moves after an announcement has already started, the user hears one thing and lands somewhere else (Focus Management).
- Live-region announcements are queued through the same thread and are subject to the same delay and the same coalescing. An announcement that arrives after the user has moved on is worse than none (Live Regions and Announcement).
What can go wrong
setTimeout(fn, 0)treated as "run this immediately". It is not zero, it is not immediate, and after a few levels of nesting the specification requires the browser to clamp it to a minimum (Tasks: The Unit That Cannot Be Interrupted).awaittreated as yielding to the browser. Awaiting an already-resolved value resumes in the microtask checkpoint of the very same turn — no frame, no input handling, nothing else runs (The Microtask Checkpoint).- A recursive microtask chain. Each microtask queues another, the checkpoint never completes, and the page stops rendering and stops responding while the CPU stays busy — a freeze with no long task to point at.
- Reading layout immediately after a mutation, then mutating again, in a loop. The browser is forced to compute layout synchronously each time, inside your task (Layout Thrashing).
- Assuming your framework's scheduling matches the browser's. Frameworks batch on top of this model — they do not replace it — and their batch boundary is usually a microtask, which means it is still before any frame.
- A timer callback and a network callback registered at the same moment have no specified relative order — they belong to different task sources, and the browser chooses which queue to service.
- Two
setTimeoutcalls with the same delay run in registration order, because they share one task source and each source is FIFO. That guarantee does not extend across sources. - A microtask queued from inside a task always runs before the next task, regardless of which task source that next task came from. This is the one cross-source ordering the model does guarantee.
- Callbacks registered during a rendering opportunity —
requestAnimationFramecallbacks — run in registration order within one frame, but a callback registered *by* a callback runs in the next frame, not this one.
- Ordering is not a security property. A synchronous authorization check followed by an
awaitopens a window in which state can change before the guarded action runs; the check and the action are no longer atomic (What the Frontend Is Responsible For in Auth). - Nothing in the loop isolates code by origin. Every script on the page shares the one stack and the one queue set, so a third-party script can occupy the main thread and starve your handlers as easily as your own code can (Third-Party Scripts and the Supply Chain).
- Timer resolution is deliberately degraded and jittered in browsers as a mitigation against timing side channels, which is one reason timing-based ordering assumptions are fragile.
- The client controls its own scheduling entirely. Any rule enforced by "we disable the button until the request finishes" is a UX affordance, not a control — the server must reject the second submission itself (Submission: Method, Encoding and Doing It Once).
- "Microtasks have higher priority than tasks." They are not prioritised against each other; they are at different points in one turn. The checkpoint runs after the current task, always, and the next task cannot start until it has drained.
- "Single-threaded means one thing happens at a time in my program." One thing happens at a time *on the main thread*. Networking, decoding, rasterisation and compositing are happening on other threads while your task runs (The Multi-Process Browser).
- "Async means it will not block the UI."
asyncis about how a function is written, not where its body runs. Anasyncfunction with a heavy synchronous loop in it blocks exactly as hard as a plain one (Async Is Not Parallelism). - "The event loop is a JavaScript feature." The job queue is the language's; task queues, rendering opportunities and timers are the browser's. The same engine in a different host schedules differently (The Browser Is a Runtime).
Measuring it, and what changes in the field
- The Performance panel main-thread flame chart is the loop drawn out: each top-level block is one task, the gaps are where the loop turned, and the frame markers show which turns produced a frame (A Mental Model of the Devtools).
- Logging with ordering in mind is a legitimate tool here — unlike most concurrency, the browser's ordering is deterministic within a task source, so a reproduction that logs the same sequence every time is telling you the truth.
PerformanceObserverwith the long-task entry type reports when a task occupied the thread beyond the threshold the specification defines, which is the machine-readable version of "the page froze" (Long Tasks).- Field data, not local runs. Ordering is deterministic but *duration* is not, and duration is what users feel (Real User Monitoring).
- On a slow device the ordering is identical and the experience is not: each task simply occupies the thread for longer, so the same code produces fewer loop turns per second and fewer frames.
- In a background or hidden tab, rendering opportunities largely stop and timers are throttled aggressively, so code that assumed a steady stream of turns quietly stalls until the tab is foregrounded (Long-Lived Clients and Version Skew).
- Under memory pressure, garbage collection work interleaves with your tasks on the same thread, which lengthens turns you did not write (Memory Leaks).
- With a large dataset, the shape of the failure changes from "slightly late" to "unresponsive", because one task grows past the point where the loop can turn between two user actions (List Virtualization).
- Reasoning at this level is slower than pattern-matching on
async/await, and most code does not need it. It pays for itself the first time a bug is an ordering bug, because no amount of framework knowledge will resolve one. - Scheduling work into smaller pieces makes total wall-clock time longer — you pay for each hand-off, and you give other work a chance to run in between. You are trading throughput for responsiveness, deliberately (Yielding and Scheduling).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe run-a-task, drain-microtasks, maybe-render sequence is specified in the HTML standard and holds in Blink, Gecko and WebKit alike; the four-line ordering example produces A, D, C, B in all of them, and a browser that did otherwise would be non-conforming.
- BROWSER-SPECIFICWhich runnable task queue is serviced when several have work is explicitly implementation-defined: Chromium runs a priority scheme that favours input and rendering-related sources, while other engines make different choices, so cross-source ordering that appears stable in one browser is not portable.
- SIMPLIFIEDThe real processing model also includes the "in parallel" steps that browser subsystems run off the main thread and several housekeeping steps in the update-the-rendering phase; those are omitted here and covered where they matter, in The Rendering Opportunity and What the Main Thread Owns.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — the job queue is part of the ECMAScript specification and exists in every host; what the browser adds on top is task queues, rendering opportunities and timers.
- — Software Design — "which queue, which turn" is the frontend instance of a general question about where a boundary between units of work belongs.