The Rendering Opportunity
A frame can only be produced between tasks, after the microtask checkpoint — which is the mechanical reason a promise chain never lets the browser paint.
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 exactly can the browser produce a frame, and why does my loading state never appear before the work that was supposed to follow it?
A person does something that takes a moment and expects to see that the page noticed: a spinner, a disabled button, a skeleton row. They want proof the click landed before they want the result.
Set the loading state, then start the work. The DOM was updated first, so the spinner is on screen by the time the work begins — the lines are in the right order.
The DOM was updated; the screen was not. A DOM mutation only marks things dirty; the pixels are produced later, in a phase your task has not returned to yet (The Rendering Pipeline).
- The DOM was updated; the screen was not. A DOM mutation only marks things dirty; the pixels are produced later, in a phase your task has not returned to yet (The Rendering Pipeline).
- Wrapping the work in
Promise.resolve().then(...)does not help, because the microtask checkpoint runs before the rendering opportunity, not after it (The Microtask Checkpoint). awaitbefore the work does not help either, for the same reason — unless what you await settles from a task source.- The user sees nothing, then sees the finished result. The spinner they were shown in the design review never appears in production, and it is not a CSS bug.
- A hidden tab gets no rendering opportunities at all, so an animation driven by
requestAnimationFramestops entirely rather than running slowly — which is correct behaviour and still surprises people who used it as a timer.
What is actually happening
In the browser, not in the framework.
- Rendering is a phase of the loop, not a task. It happens at the end of a turn: run a task, drain microtasks, then — if this turn is a rendering opportunity — update the rendering.
- Whether a turn is a rendering opportunity is the browser's decision. It weighs the display refresh rate, whether the page is visible, whether the document is in a state worth painting, and whether anything was actually invalidated. Turns can pass with no frame at all, and frames are never produced mid-task.
requestAnimationFramecallbacks run at the start of the update-the-rendering steps, before style and layout are computed. That is what makes them the right place to read and write geometry for the frame about to appear (Layout Thrashing).- A microtask checkpoint runs after each animation-frame callback returns, because the stack becomes empty. So promise work scheduled from inside
requestAnimationFramestill lands before that frame is painted. - Observer callbacks are ordered inside the phase: animation-frame callbacks first, then
ResizeObserver— which can itself dirty layout and cause a further pass — then intersection observations, then style, layout, paint and composite (Compositing Layers). - The one and only way to give the browser a rendering opportunity is to end the task. Not to await, not to queue a microtask, not to ask nicely — return, and let the loop reach the top (Yielding and Scheduling).
What this makes the browser do
And which of it is avoidable.
- Deciding whether to render this turn, which is cheap, and then doing it, which is not: animation callbacks, observer delivery, style recalculation, layout, paint recording, and compositing (The Rendering Pipeline).
- Recomputing only what was invalidated. A change that touches only compositing is much cheaper than one that forces layout for the whole document (The Cost of a Change).
- Skipping the phase entirely when the page is hidden, occluded or throttled — which saves an enormous amount of work and is the reason background tabs stay cheap.
- Delivering
ResizeObserverrecords, which may dirty layout again and require a second pass within the same frame — bounded by the browser, and a real cost when observers write to the DOM.
Why the spinner never appeared
This is the most common way the model is discovered. The code is correct in every respect except the one that matters: both the state change and the work that follows it are in the same task, so the browser never reaches the phase where it could have drawn the first.
The timeline below is schematic. What matters is not the widths of the bars but where the phase boundaries fall: there is no rendering phase inside a task, and the microtask checkpoint sits between the task and the phase.
- Click dispatched (task begins) — The handler starts. From here until it returns, no frame is possible.
- setLoading(true) → DOM marked dirty — The document is invalidated. Nothing is painted; nothing can be.
- Heavy synchronous work — The spinner exists in the DOM for this entire span and is never on screen.
- Microtask checkpoint — Still no frame. This phase is before rendering, not after it.
- Paint + composite — The first frame the user sees — and it already shows the finished result.
The two DOM writes are net-zero by the time a frame exists. Splitting the task in two — write, return, then start the work — is the whole fix.
The phase, in order
The update-the-rendering phase is a fixed sequence, and knowing the order tells you where a given callback can and cannot see. Anything that runs before style is looking at the previous frame's computed values unless it forces a recalculation; anything that writes after layout has been computed dirties it again.
The steps below are the ones a frontend engineer can actually observe or hook. The specification includes several more housekeeping steps around them; they are omitted here because nothing you write can interact with them.
- 1Resize and scroll steps
Fire the resize and scroll events that the browser has been coalescing since the last frame.
fails by A non-passive scroll handler doing work here delays the frame it is part of (Passive Listeners).
- 2Animation-frame callbacks
Run every
requestAnimationFramecallback registered before this frame, in registration order, each followed by a microtask checkpoint.fails by Long callbacks push the frame late; a callback registering another runs next frame, not this one.
- 3ResizeObserver deliveries
Deliver size-change records, potentially triggering another layout pass within the same frame.
fails by An observer that resizes what it observes; the browser bounds the loop and reports undelivered notifications.
- 4IntersectionObserver deliveries
Deliver visibility-change records for observed elements.
fails by Heavy work in a delivery — a common lazy-loading mistake — spends frame time on things that are not yet visible (Lazy Loading).
- 5Style, layout, paint, composite
Recompute what was invalidated and produce the frame.
fails by A change that invalidates layout for the whole document instead of one subtree (CSS Containment).
Everything above happens after the microtask checkpoint and before the next task. There is no author-facing hook after paint in this sequence.
The fix is a task boundary
Once you see the phase, the fix writes itself: end the task after the visual change, and do the work from a subsequent one. The mechanism is not subtle, but the two versions are close enough in appearance that reviews rarely catch the difference.
Note that the second version has a real cost beyond the extra line. Between the two tasks, other things can happen — another click, a route change, a state update — so the work must tolerate a world that moved. That is the trade: the user gets feedback, and you take on a race (Out-of-Order Responses).
button.addEventListener("click", async () => {
setPending(true); // DOM dirty, nothing painted
await computeReport(rows); // resolves from memory -> microtask
setPending(false); // still the same task
});
// The user sees: nothing, then the finished report.button.addEventListener("click", () => {
setPending(true);
// end this task; let the browser reach its rendering phase
requestAnimationFrame(() => setTimeout(() => {
const report = computeReport(rows);
setPending(false);
show(report);
}));
});
// The user sees: pending state, then the finished report.The rendering opportunity only exists between tasks, so the only way to guarantee the pending state is painted is to return from the handler before starting the work. Nesting a timer inside an animation-frame callback is the blunt, portable way to say "after the next frame has actually been produced" — a scheduler API expresses the same intent more directly where it is available (Yielding and Scheduling).
How to build it
Most important first.
- To show a pending state before starting work, put a task boundary between them. Update the DOM, return, and start the work from a task the browser services after it has had its rendering opportunity (Yielding and Scheduling).
- Use
requestAnimationFramefor work that belongs to a frame — measuring geometry, driving a transition, batching visual writes — and not as a general delay. In a hidden tab it does not run, which is a feature for animation and a bug for anything else. - Batch DOM reads and DOM writes into separate phases within the frame. Interleaving them forces the browser to compute layout synchronously inside your task, before the phase where it intended to do so (Layout Thrashing).
- Keep animation-frame callbacks short by construction. Work here is directly in front of the frame it delays, so the cost is visible as jank rather than as a delayed callback (The Frame Budget).
- Prefer letting the compositor animate. Properties that need only compositing can be updated without the main thread being involved at all, which is why they keep moving even when the thread is busy (Cheap and Expensive Animation).
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 updated by the main thread alongside rendering. No rendering opportunity means no updated tree, so the interval in which the screen is stale is exactly the interval in which the announcement is stale (The Accessibility Tree).
- A pending state that never paints is also never announced. If a user is told nothing happened, the natural response is to activate the control again — which is how a missing spinner becomes a duplicate submission (Submission: Method, Encoding and Doing It Once).
- Users who set a reduced-motion preference should get a state change without a transition, not a state change that is invisible. The correct response to reduced motion is less movement, not less feedback (Contrast, Colour and Motion).
- Focus moves are applied in the same phase family. Moving focus and then immediately doing long synchronous work leaves the focus ring unpainted while the user is already, invisibly, somewhere else (Focus Management).
What can go wrong
- The invisible pending state: set, and unset, within one task. The user gets no feedback that their click landed, which reads as an unresponsive product even when the total time is short.
- Two rendering opportunities missed because a task straddled them. Frames are not queued — a skipped frame is simply gone, and the visual result is a jump rather than a delay (Scroll and Input Latency).
- A
requestAnimationFrameloop used as a timer, which stops in a background tab and resumes at an unpredictable point, leaving state that assumed continuous ticking inconsistent. - A
ResizeObservercallback that resizes the observed element. The browser detects and bounds the loop, but the symptom — a console error about undelivered notifications, and an element that settles at the wrong size — is opaque. - Reading
offsetHeightimmediately after a write in a loop, forcing synchronous layout many times in one task and turning a rendering phase into a task-time cost (What a Mutation Costs).
- A DOM change and the frame that reflects it are in different phases of the loop, so code that assumes the two are simultaneous will read stale geometry.
ResizeObserverdeliveries can trigger further layout in the same frame, so an observer that writes to the DOM can observe its own effect in the next delivery rather than the current one.- An animation driven by the compositor and one driven by the main thread can drift apart when the thread is busy, because only one of them is affected (Compositing Layers).
- A visibility change can arrive between two frames of a frame-driven animation, so the gap between two consecutive callbacks is unbounded and must not be assumed to be one refresh interval.
- Frame timing is an observable signal. Browsers deliberately blunt high-resolution timing and cross-origin frame information because rendering timing has been used as a side channel; do not build on precise frame timing.
- A cross-origin iframe shares the rendering opportunity of the page only in the sense that it shares the loop's wall clock; it cannot be measured or driven directly from the parent, by design (The Same-Origin Policy).
- A script that occupies the thread across many rendering opportunities denies the page to its user without needing any privilege at all (Third-Party Scripts and the Supply Chain).
- "Updating the DOM updates the screen." It marks the document dirty. Pixels arrive in a later phase, and only if the browser decides a frame is due.
- "
awaitlets the browser paint." It resumes in the microtask checkpoint, which is before the rendering opportunity, so the browser has had no turn at all. - "
requestAnimationFrameruns after paint." It runs at the start of the rendering phase, before style and layout — which is precisely why it is the right place to write and the dangerous place to read carelessly. - "A dropped frame is a delayed frame." It is a lost one. The browser does not catch up by drawing the frames it missed; it draws the next one from current state, which is why jank looks like teleporting rather than lag.
Measuring it, and what changes in the field
- The Frames track in the Performance panel is the direct read-out: each frame is a bar, missing frames are gaps, and the flame chart above them shows the task that ate the gap (A Mental Model of the Devtools).
- A rendering-statistics overlay showing the frame rate live is the fastest way to tell "the work is slow" from "the frames are not being produced at all" (Debugging Rendering and Jank).
- Interaction latency in the field decomposes into waiting for the thread, running the handler, and waiting for the next frame — the last of which is exactly this phase (Interaction Responsiveness).
- Instrument the honest way: mark before the DOM write and measure inside a
requestAnimationFramecallback, which is the first moment the browser is committed to painting it.
- On a high-refresh-rate display the browser produces rendering opportunities more often, so the same work per frame leaves less room and jank appears sooner (The Frame Budget).
- On a slow device style and layout themselves take longer, so a rendering opportunity can be missed by rendering work alone, with no script involved.
- In a hidden or fully occluded tab the phase is skipped, so anything driven by frames pauses; on some platforms a backgrounded page may be suspended entirely.
- With a large or deeply nested DOM, style recalculation and layout dominate the phase, and the fix is in the document rather than in the scheduling (CSS Containment).
- Yielding to let a frame happen makes the total operation slower — the hand-off costs, and the browser spends time rendering that it could have spent on your work. You are buying perceived responsiveness with real throughput.
- Doing visual work in
requestAnimationFramegives you the correct phase and puts you directly in front of the frame. There is no slack there; work that overruns is jank, immediately and visibly. - Reading geometry in the frame phase is correct but expensive, and the discipline of separating reads from writes is one more invariant a team has to maintain in code that otherwise looks harmless.
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.
- GENERALThat rendering happens between tasks after the microtask checkpoint, and that animation-frame callbacks run at the start of that phase before style and layout, is specified in the HTML standard and holds in every engine.
- BROWSER-SPECIFICWhen a turn counts as a rendering opportunity is deliberately left to the implementation: engines differ in how aggressively they skip frames for occluded or backgrounded documents and in how they align to the display refresh, so frame cadence for an offscreen or partially covered page is not portable behaviour.
- DEVICE-SPECIFICRefresh rate sets how often opportunities occur, and variable-refresh displays change it while the page is running; the same per-frame work that is comfortable at a low refresh rate overruns at a high one, so frame budgets cannot be reasoned about as a constant.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — the engine's garbage collector also takes main-thread time inside this loop, and a collection that lands in a rendering phase is a dropped frame nobody wrote code for.