The Microtask Checkpoint
Drained to empty after every callback, including microtasks queued during the drain — which is exactly why an unbounded promise chain freezes a page with no long task to blame.
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.
When does a promise callback actually run, and how can a page hang without a single long task in the profile?
Someone expects derived state to be consistent by the time they see it: a total that matches its rows, a validation message that matches the field, a store that has finished settling before anything paints.
Promise callbacks are asynchronous, so they run "later" — roughly like a timer, just with nicer syntax. Wrapping work in Promise.resolve().then(...) therefore defers it and takes pressure off the current turn.
It defers nothing the user can perceive. The callback runs at the end of the very same turn, before the browser has any chance to paint, so the spinner you were trying to show still does not appear (The Rendering Opportunity).
- It defers nothing the user can perceive. The callback runs at the end of the very same turn, before the browser has any chance to paint, so the spinner you were trying to show still does not appear (The Rendering Opportunity).
awaiton an already-resolved promise is the same story. The function resumes in this turn's checkpoint, so a loop ofawaits over in-memory data blocks the page as thoroughly as a plainforloop would (The Sequential Await Trap).- A microtask that queues another microtask is drained in the *same* checkpoint. A chain with no termination condition therefore never ends the checkpoint, and the page stops rendering and stops responding with the CPU pinned.
- That hang does not show up as a long task in the usual sense — the profile shows an enormous run of microtask work between two loop turns, and developers looking for a slow function find a thousand fast ones.
- Two listeners on the same event do not run back to back. The stack empties between them, so a microtask queued by the first runs before the second is called — which reverses the ordering most people expect (How an Event Is Dispatched).
What is actually happening
In the browser, not in the framework.
- A microtask checkpoint runs whenever the JavaScript execution context stack becomes empty, not only at the end of a big task. Every callback the browser invokes gets one on the way out.
- The checkpoint drains the queue to empty. It does not take a snapshot and process it; it keeps taking the next microtask until there is nothing left, so microtasks queued during the drain are handled in the same checkpoint.
- Promise reactions (
then,catch,finally, and the resumption of anawait) are microtasks. So arequeueMicrotaskcallbacks andMutationObserverdeliveries. - The checkpoint is re-entrancy-protected: a checkpoint already in progress is not started again from a nested callback, which is what keeps the drain a single flat loop rather than a stack of partial drains.
- Because the checkpoint sits between the task and any rendering, "microtask" is precisely the phase that means before the browser can paint. That is a useful guarantee, not merely a scheduling detail: it is how a framework can batch several state updates and still be certain the user never sees an intermediate state (Reconciliation and Keys).
- When a task is a real user-triggered event dispatch, each listener invocation returns to an empty stack, so a checkpoint runs between listeners. When the same event is dispatched from script —
el.click()inside a handler — the stack is not empty, and the checkpoint is deferred until the outer code returns.
What this makes the browser do
And which of it is avoidable.
- Running the drain loop after every callback. On a page with many promise-based abstractions this is a substantial number of very small units of work, each with its own overhead.
- Delivering
MutationObserverrecords, which requires tracking every observed mutation during the task and batching them into a record list. - Nothing else. Crucially, the browser cannot render, cannot dispatch input and cannot service any other task source while the checkpoint is draining — the drain is the price of the guarantee.
Drained to empty, including what you add while draining
The drain is a loop, not a pass. That single implementation detail is the source of both the guarantee people rely on and the failure people do not anticipate. Because anything queued during the drain is picked up by the same drain, a chain of promises that regenerates itself is indistinguishable, from the loop's point of view, from a single task that never returns.
The trace below walks a checkpoint that queues more work while draining. Note that the task boundary never arrives: nothing between step 3 and step 8 gives the browser a chance to render, dispatch input or update the accessibility tree.
1// Freezes the page. The checkpoint never drains.2function starve() {3 Promise.resolve().then(starve);4}5 6// Yields. Each iteration ends the task, so the browser7// gets a rendering opportunity between iterations.8function breathe() {9 setTimeout(breathe);10}11 12// The one people write by accident: a "queue drain" whose13// recursion is bounded only by how much data arrived.14async function drain(queue) {15 while (queue.length) {16 await handle(queue.pop()); // resolves immediately -> microtask17 } // -> no frame until the queue is empty18}The third is the realistic one. It contains an await, which reads as "this is asynchronous and therefore polite", and it blocks rendering for the entire length of the queue.
TASK: a click handler that starts a self-feeding chain step running microtask queue can browser render? ---- ------------------------- ----------------------- ------------------- 1 handler() - no (task running) 2 queues cb1 [cb1] no 3 handler returns [cb1] no - checkpoint next ==== MICROTASK CHECKPOINT (drain to EMPTY) ==== 4 cb1 - no 5 cb1 queues cb2 [cb2] no 6 cb2 - no 7 cb2 queues cb3 [cb3] no 8 ... unbounded ... [cbN] no ==== checkpoint never ends ==== Never reached: rendering opportunity, next task, input dispatch, accessibility-tree update. The tab is pinned with the CPU fully busy and the profile shows thousands of fast callbacks, not one slow one.
Between two listeners, the stack is empty
This is the detail that decides most real ordering arguments, and it follows directly from the definition: the checkpoint runs when the execution context stack becomes empty, and each listener invocation returns to an empty stack when the browser is the one dispatching.
So for a genuine user click on an element with two listeners, the sequence is: first listener, checkpoint, second listener, checkpoint. If instead you dispatch the event from inside your own code with el.click(), your frame is still on the stack, so both listeners run and only then does one checkpoint drain everything they queued. The same code, the same listeners, two different output orders, and the difference is who called it.
btn.addEventListener("click", () => {
console.log("L1");
Promise.resolve().then(() => console.log("L1 micro"));
});
btn.addEventListener("click", () => {
console.log("L2");
Promise.resolve().then(() => console.log("L2 micro"));
});
btn.click(); // called from script; our frame is still on the stack
// L1, L2, L1 micro, L2 micro// exactly the same listeners; the user clicks the button // L1, L1 micro, L2, L2 micro
It is not that one is better code — it is that "microtasks run after the event handler" is an incomplete rule that produces the wrong prediction in one of these two cases. The correct rule is "microtasks run when the stack empties", and whether the stack is empty depends on whether your own frame is still below the listener.
Choosing the queue on purpose
Once the mechanism is clear, the choice becomes a design decision with a stated cost rather than a stylistic one. The question is only ever: must this finish before the user can see anything, or must the user be able to see something before this finishes?
Both answers are legitimate and each has a failure mode. Choosing "before paint" for something long is a freeze. Choosing "after paint" for something that guards consistency is a flash of an intermediate state, or a frame rendered against half-updated data (Visual Stability).
Does the user need to see the current state first, or does the state need to be correct before anything is seen?
when Settling derived state, flushing a batch of updates, normalising a store — anything that must be consistent before a frame exists.
cost Blocks rendering, input and accessibility updates for its whole duration, and an unbounded chain blocks them permanently.
when You want the browser to paint what it already has — showing a pending state, starting a long job after the click has visibly registered.
cost Clamping and throttling for timers; a window in which state can change before the work runs (Out-of-Order Responses).
when The work is about the frame that is about to be produced: reading geometry, driving a visual transition (Cheap and Expensive Animation).
cost Does not run at all when the page is hidden, and running long here delays the frame directly (The Frame Budget).
when The work is CPU-bound and does not need the DOM. This is the only option that removes the cost rather than moving it.
cost Serialisation across the boundary, a second copy of the data, and a code structure that has to survive the message boundary (When a Worker Is Actually the Answer).
How to build it
Most important first.
- Use a microtask when you want work done before anything is painted: settling derived state, flushing a batch, normalising a store, resolving a value that the frame about to be produced depends on.
- Use a task when you want the browser to have a chance to paint first. The rule is short enough to remember: microtask for consistency, task for responsiveness (Yielding and Scheduling).
- Bound every recursive promise chain. If a
.thencan queue another.thenbased on data — pagination, retry, a queue drain — put a task boundary in the loop so the checkpoint can end (Retries, and the Duplicate Order). - Do not use
awaitas a yield. If the intent is "let the browser breathe", await something that resolves from a *task* — a timer, a message — not an already-settled value. - Keep
MutationObservercallbacks small. They run in the checkpoint, so a heavy one delays the frame that the mutation was supposed to produce.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A page held by a draining microtask queue is a page whose accessibility tree is frozen. The screen reader keeps reading whatever the tree said when the checkpoint began, so the user is confidently told stale information with no signal that it is stale (The Accessibility Tree).
- This is worse than a visual freeze, because a sighted user sees the absence of motion and infers "busy". There is no non-visual equivalent of a stalled frame, so the failure is silent.
- Live-region updates queued during a checkpoint are not announced until the thread is free. If several land at once the assistive technology may coalesce or drop them, so a burst of announcements is not a substitute for one accurate one (Live Regions and Announcement).
- Because the checkpoint runs before painting, it is the right phase for correcting semantics that must be consistent with the frame about to appear — setting
aria-busyoff, updating an accessible name — and the wrong phase for anything long enough to delay that frame.
What can go wrong
- The unbounded chain:
Promise.resolve().then(function loop() { return Promise.resolve().then(loop) }). The page hangs permanently and nothing in the UI can recover it — not a click, not a frame, not the tab's own spinner. - A recursive drain that is bounded only by data. It works in development with ten items and hangs in production with a hundred thousand, and the failure is a hard freeze rather than a gradual slowdown.
- A
MutationObserverthat mutates the DOM it observes. Each mutation queues another record, and the checkpoint can fail to terminate for the same reason as any other microtask loop (What a Mutation Costs). - An
awaitinside a loop over local data, written to "keep things async". It converts one task into one task with a very long checkpoint, which is strictly worse for the user than the synchronous version because it is harder to spot (The Sequential Await Trap). - Assuming your error handler will run. A rejection with no handler attached during the same drain becomes an unhandled rejection, reported through a completely different channel than a thrown error (Frontend Error Tracking).
- A microtask queued by the first listener for an event runs before the second listener for the same event — an ordering that reverses if the same event is dispatched from script rather than by the user.
- Two independent promise chains started in the same task interleave microtask by microtask, so a chain with fewer links can finish first regardless of which was started first (Interleavings: The Schedule Is Part of the Program).
- State read before an
awaitand used after it may have been changed by another microtask that ran during the checkpoint. Theawaitis a real yield point in your function even though no other task ran. - A
MutationObserversees a batch of records, not each mutation as it happens, so two mutations in one task are indistinguishable from one net change by the time the callback runs.
- A hang caused by a microtask loop is a denial of the page to its own user, and any script on the page can cause it — including a third-party one whose promise chain has a bug (Third-Party Scripts and the Supply Chain).
- The "check, then
await, then act" pattern is a genuine hazard: the checkpoint lets other already-queued microtasks run between the check and the action, so an invariant verified before theawaitmay not hold after it (What the Frontend Is Responsible For in Auth). - Unhandled rejections often carry response bodies and request URLs into error reporting. Because they are reported by a different mechanism than exceptions, they frequently escape the redaction applied to everything else (Session Replay and the Privacy It Costs).
- "Microtasks are just high-priority tasks." They are not in the same ranking at all. A microtask is part of finishing the current task; a task is a separate turn of the loop.
- "
awaityields to the browser." It yields to the microtask queue. Unless the awaited value settles from a task source, the browser gets nothing. - "The queue is snapshotted at the start of the checkpoint." It is not. Anything queued during the drain runs in the same drain, which is the entire mechanism behind starvation.
- "A frozen page always means a long task." A microtask loop freezes just as hard, and long-task instrumentation may attribute it in a way that sends you looking for the wrong thing (Long Tasks).
- "Two handlers for one event run without interruption between them." Between them, the stack is empty, so a full microtask checkpoint has already run.
Measuring it, and what changes in the field
- In the Performance panel, a microtask storm appears as a dense band of small blocks with no frame markers in it — the tell is the absence of frames rather than the presence of one long block (A Mental Model of the Devtools).
- A page that is unresponsive while the CPU is busy, and where breaking in the debugger lands you repeatedly in promise machinery, is a checkpoint that will not drain.
- Ordering questions are best answered by a small reproduction with logs, because browser ordering is deterministic within a source — an experiment here actually settles the argument (A Method for Frontend Bugs).
- Track unhandled rejections explicitly through the
unhandledrejectionevent; they are invisible to a plain error handler (Frontend Error Tracking).
- On a slow device the checkpoint takes proportionally longer, so a chain that was merely inefficient becomes a visible freeze.
- With a large dataset, per-item promise overhead becomes the dominant cost — thousands of tiny microtasks cost more in scheduling than the work inside them.
- In a hidden tab the checkpoint still runs at full speed; only rendering opportunities and timers are throttled, so a runaway chain burns battery in the background just as hard as in the foreground.
- On a page with many independent promise-based libraries, one library's drain delays every other library's callbacks, because there is one queue for all of them.
- The "before paint" guarantee is exactly what makes microtasks dangerous. You cannot have both "nothing can render before this finishes" and "this cannot block rendering"; picking the queue is picking which one you want.
- Inserting a task boundary into a promise chain to protect rendering makes the chain slower overall and introduces a window in which state can change — you have swapped a hang for a race, deliberately (Out-of-Order Responses).
- Microtask-based batching keeps intermediate states invisible at the cost of making them invisible to your debugger too: the DOM never holds the intermediate value long enough to inspect.
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 drain-to-empty semantics come from the ECMAScript job queue plus the HTML checkpoint definition and are identical across Blink, Gecko and WebKit; a browser that processed only a snapshot of the queue would break every promise-based library on the web.
- BROWSER-SPECIFICHow a microtask storm is attributed in tooling differs: Chromium groups the drain under the task that triggered it in the Performance panel, Firefox's profiler shows the promise machinery as its own stack frames, and Safari surfaces less of it — so the same freeze looks like three different bugs depending on where you profile it.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — the microtask queue is the ECMAScript job queue; promise resolution semantics, including how many jobs an
awaitcosts, are defined by the language rather than by the browser.