The frame and the event loop.
A web page has one main thread and it does one thing at a time. Clicks, timers, network replies and promise callbacks take turns on it, and between some of those turns the browser stops to draw a frame. Nothing you write to the DOM reaches the screen until that stop comes round, and it cannot come round while your code is still running. Once you can see where the frame sits in the loop, a spinner that never appears, an animation that stutters on a fast phone and a page that freezes on a promise all have the same explanation.
Builds on From bytes to pixels.
The idea.
The screen does not follow your code line by line. It is redrawn at set moments, between turns of the event loop, and shows whatever the DOM holds at that moment.
On Copperpot's product page for the 24 cm enamel casserole, a shopper taps the Teal swatch. The click handler was written to be helpful. First it marks Teal as chosen and sets the stock line to "Checking stock…". Then it rebuilds the "Pairs well with" row of matching lids and trivets for the new colour, which takes 84 ms of JavaScript on this phone. Finally it looks Teal up in the variant data the page already holds and writes "In stock, delivered Thursday".
The shopper never sees "Checking stock…". For about a tenth of a second the old Cream page stays on screen with no sign that the tap landed, and then Teal, the new row and the final stock line all appear together. The label was in the DOM for 86 ms, yet no frame was ever drawn while it was there.
That is the event loop at work. The page's main thread runs one piece of work, a task, from start to finish without interruption. The tap's handler is one task. Drawing a frame (running style, layout and paint and handing the result to the screen) is something the loop does between tasks, when the display is ready for a new picture, roughly every 16.7 ms on a 60 Hz screen. A task that is still running cannot be paused for a frame, so the frame waits, and when it is finally drawn it shows the DOM as the task left it. Intermediate values are simply overwritten.
Everything in this topic follows from that one rule: when the frame comes, what runs just before it (requestAnimationFrame), what squeezes in ahead of it (microtasks), what has no idea it exists (timers), and how to arrange work so the shopper sees the right thing at the right moment.
What the handler wrote, and what the shopper saw
Cream is chosen and its stock line is showing. The shopper's thumb comes down on Teal.
Before the tap. Store “Copperpot”, showing the product view. Cart button “Basket” with badge 1 (its accessible name says “Cart, 1 item”, not the number alone). Product (showing) “Enamel casserole, 24 cm”: rating 4.8 (“312 reviews”); price £64.00. Gallery: image 1 of 5 (“Casserole photographed from the side, cream glaze”); thumbnails beside it. Colour (swatches, a radio group): Cream (selected), Teal, Rust. Stock: “In stock”. Quantity 1 (stepper “Quantity”). Buttons: “Add to basket”. Delivery: “Delivered Thursday”. Folded sections: Details, Pairs well with, Reviews.
- Click task runs here1Main thread (event loop)
- Painted only at a frame2Compositor and display
The Teal tap, as the code wrote it and as the screen showed it
Scenario 1 of 2: As described.
Timeline as a list
The Teal tap, as the code wrote it and as the screen showed it: 3 lanes, from 0 ms to 140 ms.
- 0–2 ms · Stock line in the DOM · In stock (Cream)
- 0–100 ms · Screen · still showing Cream
- 0 ms · Main thread · tap
- 1–3 ms · Main thread · mark Teal, write label
- 2–88 ms · Stock line in the DOM · Checking stock…
- 3–87 ms · Main thread · rebuild "Pairs well with" (84 ms)
- 16.7 ms · Screen · vsync (tick)
- 33.3 ms · Screen · vsync (tick)
- 45 ms · Stock line in the DOM · never painted (error, stale)
- 50 ms · Screen · vsync (tick)
- 66.7 ms · Screen · vsync (tick)
- 83.3 ms · Screen · vsync (tick)
- 87–89 ms · Main thread · write stock
- 88–140 ms · Stock line in the DOM · In stock, delivered Thursday
- 89–94 ms · Main thread · style, layout, paint
- 94 ms · Main thread → Screen, arriving 100 ms
- 100–140 ms · Screen · Teal + new row + stock
- 100 ms · Screen · first frame after the tap (error, delayed)
How it works.
The HTML Standard describes the loop precisely, and browsers follow it closely enough that the order is something you can rely on. Follow one turn of it, then the frame's own steps, then the clocks that decide when a frame is due.
One turn of the loop
A window's event loop owns several task queues. A task is a self-contained piece of work queued by some source: dispatching a click, running a timer callback, delivering a network response, parsing a chunk of HTML, posting a message. Each turn, the loop picks one queue, takes the oldest runnable task from it and runs it to completion. Which queue it picks is left to the browser, which is how browsers can favour input: the standard's own example gives keyboard and mouse tasks preference three times out of four, while tasks from any single source always keep their order.
When the task returns, the loop performs a microtask checkpoint. The microtask queue is separate and is not a task queue: promise reactions, queueMicrotask callbacks and MutationObserver deliveries go there. The checkpoint runs microtasks until the queue is empty, including any that the running microtasks add. Only then can anything else happen.
Rendering is woven in on its own clock. In parallel with the loop, the browser waits for a rendering opportunity, the moment its display can take a new picture, and queues a task to update the rendering. That task waits its turn like any other, so a long task in front of it delays the frame, and it is skipped for documents that are hidden or have nothing new to draw and no animation frame callbacks waiting.
The main thread through one turn and one frame
States of1Main thread (event loop)
A simplified view of the HTML Standard's processing model for one page. Nothing leaves Running a task until the task returns, and nothing leaves Draining microtasks until the queue is empty: those two facts explain most surprises.
| From → To | Event | Guard | Action | Actor |
|---|---|---|---|---|
| Idle → Running a task | task queued | run oldest in chosen queue | event loop | |
| Running a task → Draining microtasks | task returns | microtask checkpoint | event loop | |
| Draining microtasks → Draining microtasks | microtask queues another | run it too | script | |
| Draining microtasks → Running a task | queue empty | tasks waiting | event loop | |
| Draining microtasks → Idle | queue empty | nothing waiting | event loop | |
| Draining microtasks → Animation frame callbacks | queue empty | rendering task is next | event loop | |
| Idle → Animation frame callbacks | rendering opportunity | something to draw | compositor | |
| Animation frame callbacks → Style, layout, paint | callbacks done | microtasks drained after each | event loop | |
| Style, layout, paint → Idle | frame handed off | compositor presents it | compositor |
- Idlestart
- No runnable task; idle callbacks may run here
- Running a task
- One click, timer, message or response, start to finish
- Draining microtasks
- Until the microtask queue is empty, new arrivals included
- Animation frame callbacks
- Every callback registered before this frame, one timestamp
- Style, layout, paint
- Once for everything written since the last frame
The four places work can wait
| Queue | Who puts work there | When it runs | How much per turn |
|---|---|---|---|
| Task queues | events, setTimeout, setInterval, MessageChannel, fetch responses, parser | One task per turn, in an order the browser chooses between queues | One, then a microtask checkpoint |
| Microtask queue | Promise.then / await, queueMicrotask, MutationObserver | Right after the current task or callback returns, before anything else | All of them, including ones added while draining |
| Animation frame callbacks | requestAnimationFrame | Inside the rendering steps, just before style and layout, at the next rendering opportunity | All callbacks registered before the frame started; new ones wait for the next frame |
| Idle callbacks | requestIdleCallback | When no task is runnable, with a deadline of at most 50 ms or less if a frame or timer is due | As many as fit in the deadline (not in Safari outside a preview flag) |
Inside a frame, step by step
The task that updates the rendering is a fixed list, run for every visible document that has something to show. The order matters, because each step can change what the later ones see. Events tied to the screen, such as resize and scroll, are not fired the instant the window changes size or the page moves; they are fired here, at most once per frame, which is why a scroll listener never runs more often than frames are drawn.
The update-the-rendering steps, in the HTML Standard's order
| # | Step | What runs | On Copperpot's page |
|---|---|---|---|
| 1 | Skip check | Hidden, render-blocked or unchanged documents with no rAF callbacks drop out | A background tab with the casserole page skips the whole list |
| 2 | Resize steps | resize event, if the viewport changed size | The phone rotates; the gallery listener re-reads its width once, here |
| 3 | Scroll steps | scroll events pending since the last frame | However far the page moved since the last frame, a scroll listener gets one event |
| 4 | Media queries | MediaQueryList change events | The page crosses its 600 px breakpoint |
| 5 | Animations | CSS and Web Animations advance; animation and transition events fire | The swatch ring finishes its 150 ms transition; transitionend fires |
| 6 | Fullscreen | fullscreenchange and fullscreenerror | The gallery goes full screen |
| 7 | Animation frame callbacks | Every requestAnimationFrame callback queued before this step began, each with the same timestamp | The free-delivery meter moves one step |
| 8 | Style and layout | Recalculate style and lay out; deliver ResizeObserver entries and repeat while they cause changes | "Pairs well with" learns its new width and lays out again |
| 9 | Focus fixup | Move focus away from an element that stopped being focusable | The focused size tile was removed by the rebuild |
| 10 | Intersection observations | IntersectionObserver callbacks are queued | The reviews section scrolled into view; its loader is told |
| 11 | Paint | Update the rendering to reflect the current state; the result goes to the compositor | Teal, the new row and the stock line, together |
Two consequences follow. First, requestAnimationFrame is the last place where script can change the page and have the change appear in this very frame without paying for a second layout: style and layout run right after it. Second, a write is cheap at the moment it happens. Setting a class or a style does not recompute anything; the engine only marks the affected elements dirty (style needs recalculating, layout needs redoing, a region needs repainting) and step 8 and paint later redo just the dirty parts, once, for every write since the last frame. That is why ten writes in one task cost about the same as one at frame time.
The marks can be cashed early. If script reads geometry such as offsetHeight or getBoundingClientRect() while layout is dirty, the browser must run style and layout on the spot to answer: a forced synchronous layout. Doing that read-after-write inside a loop repeats layout many times in one frame, the pattern the layout-thrashing topic owns along with its fixes. How much the next frame must redo depends on what was written, as the table shows; the layers and compositing topic explains when a transform or opacity change skips the main thread altogether.
What a write marks dirty, and what the next frame must redo
| Written in a task or rAF | Marked dirty | Next frame redoes | On Copperpot's page |
|---|---|---|---|
| width, height, top, margin, font-size, text content, adding nodes | Style and layout of the element and whatever its size pushes around | Style, layout, paint, composite | The rebuilt "Pairs well with" row moves the reviews below it |
| color, background-color, box-shadow, outline | Style and the paint of that element only | Style, paint, composite (layout skipped) | The swatch ring changes colour on press |
| transform, opacity (element on its own layer) | Only the layer's position or alpha | Composite only (style may recalc; no layout or paint) | The delivery meter's scaleX fill |
Rendering opportunities and the frame budget
The display decides the beat. A 60 Hz screen takes a new picture every 16.7 ms; a 120 Hz screen every 8.3 ms. The browser lines its rendering opportunities up with that signal (usually called vsync) and may lower the rate for a page that cannot keep up, for a cross-origin frame that is out of view, or for a hidden tab, where the standard allows a few frames a second or none at all.
Inside one interval, everything the page does has to fit: the tasks that happen to run, the rAF callbacks, style, layout and paint, plus the browser's own work to get the frame to the screen. web.dev's rule of thumb at 60 Hz is about 10 ms for the page out of the 16.7 ms. On a 120 Hz screen the same proportion leaves about 5 ms, so code that was smooth on a 60 Hz test phone can miss every other frame on a newer one.
Time between frames at common refresh rates
Data
| Display refresh rate | ms per frame |
|---|---|
| 60 Hz | 16.7 |
| 90 Hz | 11.1 |
| 120 Hz | 8.3 |
| 144 Hz | 6.9 |
One 60 Hz frame on Copperpot's page while the delivery meter animates
- input, width 2.5, Input and other tasks, value 2.5 ms
- meter, width 0.8, rAF callbacks, value 0.8 ms
- style + layout, width 2.2, Style and layout, value 2.2 ms
- paint, width 1.5, Paint, value 1.5 ms
- browser, width 4.5, Browser's own frame work, value 4.5 ms
- spare, width 5.2, Spare, value 5.2 ms
- page work: 7 ms, from 0 to 7
- Input and other tasks
- rAF callbacks
- Style and layout
- Paint
- Browser's own frame work
- Spare
- Over budget
As it starts. 2 steps follow.
requestAnimationFrame against timers
Copperpot's basket drawer has a free-delivery meter: "£11 to go" fills towards a £75 threshold when something is added. The fill is driven by script, a scaleX on the bar recomputed each step. The first version used setTimeout with a 16 ms delay, because 16 ms is about a frame. That reasoning has two holes. A timer is a task on its own clock: it fires at least 16 ms after it was set, later if the thread is busy, and it knows nothing about when the next frame is drawn. And not every screen is 60 Hz.
requestAnimationFrame asks for the one thing a visual update wants: run this just before the next frame is drawn. Its callbacks run inside the rendering steps, once per frame, at whatever rate the screen refreshes, and they all receive the same timestamp, the frame's time. Browsers pause them in background tabs and hidden iframes, which saves battery but means a rAF loop must never be the clock for anything that has to keep time.
The delivery meter's updates against the frames that show them
Scenario 1 of 4: As described.
- 5 Overwritten by update 4 before any frame showed it
Timeline as a list
The delivery meter's updates against the frames that show them: 2 lanes, from 0 ms to 120 ms.
- 1.2 ms · Meter updates · update 1
- 16.7 ms · Frames · shows 1 (tick, ok)
- 17.4 ms · Meter updates · update 2
- 33.3 ms · Frames · shows 2 (tick, ok)
- 33.5 ms · Meter updates · update 3 (error, stale) — Overwritten by update 4 before any frame showed it
- 49.6 ms · Meter updates · update 4
- 50 ms · Frames · shows 4 (3 lost) (tick, stale)
- 66.7 ms · Frames · nothing new: repeat (tick, delayed)
- 66.9 ms · Meter updates · update 5
- 83.3 ms · Frames · shows 5 (tick, ok)
- 83.5 ms · Meter updates · update 6
- 100 ms · Frames · shows 6 (tick, ok)
- 100.2 ms · Meter updates · update 7
- 116.7 ms · Frames · shows 7 (tick, ok)
The delivery meter, driven by the frame and by time
// Moves a fixed amount per tick, so its speed depends on how
// often the timer manages to fire; it never lines up with frames.
function fillMeter(fill, from, to) {
let value = from;
const tick = () => {
value = Math.min(value + 0.03, to);
fill.style.transform = `scaleX(${value})`;
if (value < to) setTimeout(tick, 16);
};
tick();
}
function fillMeter(fill, from, to, duration = 600) {
let start;
let frameId;
const step = (now) => { // now: this frame's shared timestamp
start ??= now;
const t = Math.min((now - start) / duration, 1);
const eased = 1 - (1 - t) ** 3;
fill.style.transform = `scaleX(${from + (to - from) * eased})`;
if (t < 1) frameId = requestAnimationFrame(step); // one-shot: ask again
};
frameId = requestAnimationFrame(step);
return () => cancelAnimationFrame(frameId); // stop if the drawer closes
}
Microtasks cut in line
A promise callback does not run when the promise settles; it is queued as a microtask, and microtasks run at the next checkpoint, which comes as soon as the current task or callback returns. That makes them the fastest way to defer something, faster than any timer and always ahead of the next frame. It also means a chain of awaits that never touches the network or a timer completes inside a single turn, with no frame in between, however many steps it has.
Copperpot's real stock check goes to the server. The request runs in parallel, off the main thread; when the response arrives, a task is queued to settle the fetch's promise, and the then callbacks run as microtasks right after that task. Whatever they write is painted at the next frame together with anything else written before it.
The Teal tap with a server stock check
- Shopper's thumb → Main thread (event loop): tap on Teal (a click task)
- Note over Main thread (event loop): handler: Teal pressed, "Checking stock…", fetch() called; returns at once
- Main thread (event loop) → Network service: GET /api/stock?sku=CAS24-TEAL
- Note over Main thread (event loop): task ends; microtask checkpoint is empty; thread is free
- Main thread (event loop) → Compositor and display: next frame: Teal pressed, "Checking stock…"
- Network service → Main thread (event loop) (reply): 200, headers (a task settles the fetch promise)
- Note over Main thread (event loop): microtask: res.json() starts reading the body
- Network service → Main thread (event loop) (reply): body complete (a task settles the json promise)
- Note over Main thread (event loop): microtask: write "3 left in Teal", enable Add to basket
- Main thread (event loop) → Compositor and display: next frame: the stock line
Who runs first: one handler, four kinds of deferral
tealSwatch.addEventListener('click', () => {
setTimeout(() => log('timer'), 0);
requestAnimationFrame(() => log('frame'));
Promise.resolve().then(() => log('promise'));
queueMicrotask(() => log('microtask'));
log('handler');
});
handler // the task itself always finishes first
promise // microtasks, in the order they were queued,
microtask // before anything else can run
frame // these two can come in either order: it depends on
timer // whether a rendering opportunity is due before the
// timer's task is picked
Where input events and observers land
When each kind of event reaches your code
| Event or callback | When it runs | What it means on Copperpot |
|---|---|---|
| click, keydown, pointerdown, pointerup, touchstart, touchend | Discrete input: dispatched as its own task as soon as the loop gets to it | The Teal tap; Enter on the quantity field |
| pointermove, touchmove, mousemove, wheel | Continuous input: Chrome (since 60) delivers it at most once per frame, just before rAF callbacks; getCoalescedEvents() returns the points in between | Dragging the gallery zoom lens gets one event per frame, not one per sensor reading |
| scroll, resize | Fired from the rendering steps, at most once per frame per target | A scroll listener that updates the sticky price runs at the frame rate, never faster |
| ResizeObserver | In the rendering steps, after layout and before paint; may loop while observers change sizes | "Pairs well with" picks 2 or 3 cards per row from its new width, in the same frame |
| IntersectionObserver | Queued from the rendering steps after layout | The reviews section reports that it is near the viewport |
| MutationObserver | A microtask, after the DOM changes that triggered it | An analytics script hears about the new stock line before the frame is drawn |
In practice.
Back on Copperpot's page: the Teal tap fixed, the right queue for each job on the page, a countdown that survives a hidden tab, how to see all of this in DevTools, and how to put it into an interview answer in a few sentences.
Paint the feedback, then do the work
The fix for the Teal tap is not to make the label appear sooner by force; there is no API that paints in the middle of a task. It is to end the task once the feedback is written and continue the heavy part in a task that is guaranteed to start after the next frame. A requestAnimationFrame callback runs just before that frame is painted; a zero-delay timer set from inside it becomes a task that cannot run until the rendering update has finished. Together they mean "after the next paint".
This changes what the shopper feels, not how much work is done. The press ring and "Checking stock…" appear at the first frame, about 33 ms after the tap instead of 100 ms. The rebuild is still one 84 ms task, long enough to delay the next tap; breaking it into smaller pieces and yielding between them is the subject of the long-tasks topic, which also covers scheduler.yield() and how Interaction to Next Paint measures exactly this gap between an input and the frame that answers it.
Copperpot's swatch handler, feedback first
// Resolves in a task that starts after the next frame has been rendered.
function afterNextFrame() {
return new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0));
});
}
swatchGroup.addEventListener('click', async (event) => {
const swatch = event.target.closest('[data-colour]');
if (!swatch) return;
const colour = swatch.dataset.colour;
pressSwatch(swatch); // cheap: aria-checked + ring
stockLine.textContent = 'Checking stock…';
await afterNextFrame(); // the frame shows the feedback
rebuildPairsWellWith(colour); // 84 ms, now its own task
stockLine.textContent = stockText(variants[colour]);
});
// Looks like it defers the work, but a resolved promise is a
// microtask: it runs before the frame, so the label is still never seen.
stockLine.textContent = 'Checking stock…';
await Promise.resolve();
rebuildPairsWellWith(colour);
The right place for each job on Copperpot's page
| Job | Use | Why |
|---|---|---|
| Fill the delivery meter from script | requestAnimationFrame | Once per frame at the screen's rate, with a shared timestamp; a pure CSS transition on transform is better still, as the compositing topic shows |
| Show the stock answer after fetch | await fetch() / then | The callback runs as a microtask after the response task; the next frame paints it |
| Run the rebuild after the feedback has painted | rAF, then setTimeout(…, 0) | A new task queued from inside the rendering update, so a frame is guaranteed in between |
| Coalesce ten quantity-stepper clicks into one price request | a debounce timer | Each click is its own task, so only a timer can wait for the taps to stop; a microtask batches only the writes made inside one task |
| Prefetch the size guide when nothing else is happening | requestIdleCallback with a timeout, setTimeout fallback | Low priority work that should never compete with input; Safari needs the fallback |
| Choose 2 or 3 "Pairs well with" cards per row | ResizeObserver | Runs after layout and before paint, so the new layout shows in the same frame |
| Start loading reviews as they near the screen | IntersectionObserver | No scroll listener; reported from the rendering steps |
Copperpot runs a weekend offer with an "Offer ends in 04:59" banner. A first version subtracted one sixtieth of a second on every animation frame. A shopper switches to a messaging app for 40 seconds and comes back to a banner that has lost only the 10 seconds it was visible for. Nothing was broken: hidden documents get no rendering updates, so animation frame callbacks stop, and timers in background tabs are throttled too (Chrome checks heavily throttled timers only once a minute after five minutes hidden). Anything that keeps time has to read a clock and use frames only to show the result.
"Offer ends in" across 40 seconds in a background tab
Timeline as a list
"Offer ends in" across 40 seconds in a background tab: 4 lanes, from 0 s to 60 s.
- 0–10 s · Tab · visible
- 0–10 s · Frames drawn · 60 a second
- 10–50 s · Tab · hidden
- 10 s · Frame-counted banner · 04:49
- 10 s · Clock-based banner · 04:49
- 10–50 s · Frames drawn, Frame-counted banner · window: no rendering updates
- 50–60 s · Tab · visible
- 50–60 s · Frames drawn · 60 a second
- 50 s · Clock-based banner · 04:09 (ok, ok)
- 60 s · Frame-counted banner · 04:39 (error, violation)
- 60 s · Clock-based banner · 03:59 (ok, ok)
Keep time with a clock, draw with frames
const endsAt = Date.parse(banner.dataset.endsAt); // from the server, absolute
function draw() {
const left = Math.max(0, endsAt - Date.now());
const secs = Math.floor(left / 1000);
const text = `${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;
if (banner.textContent !== text) banner.textContent = text; // write only on change
if (left > 0) requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Seeing the loop in DevTools
Where the loop shows up in a performance recording (Chrome DevTools)
| What to look at | What it shows | On the Teal tap |
|---|---|---|
| Main track | Every task as a grey bar with its call stack below it; tasks over 50 ms are flagged with a red corner | One 88 ms click task with rebuildPairsWellWith filling most of it |
| Frames track | Each frame that was presented, and frames that were dropped or only partly presented | No new frame between the tap and 100 ms; after the fix, one at about 33 ms |
| Animation Frame Fired entries | requestAnimationFrame callbacks, inside the rendering work | The delivery meter's callback, once per frame, each well under 1 ms |
| Recalculate style, Layout, Paint | The rendering steps after the callbacks, normally once per frame | One of each after the task; several Layout entries inside a task point to forced layout |
| Interactions track | Each tap or key press, from input to the frame that answered it | The Teal tap spans about 100 ms before the fix and about 33 ms after |
Where the system flows lean on it
Saying it in an interview
Trade-offs.
Every queue is right for something and wrong for something else. Three choices come up again and again in front-end designs, and a handful of failures come from getting them wrong.
- Pro:Runs once per frame at the screen's real rate
- Pro:Changes land in the very next frame with no extra layout
- Pro:Stops in hidden tabs and saves battery
- Con:Must ask again each frame and be cancelled when done
- Con:Pauses in hidden tabs so it cannot keep time
- Con:Still main-thread work that competes with input
Not aligned with frames; so updates are doubled or missed; Half rate on 120 Hz screens; Fires late whenever the thread is busy
Only for values known in advance; not per-event values such as a drag position
- Pro:The feedback written before it is guaranteed to be painted
- Pro:Input queued meanwhile can run first
- Con:At least one frame later than doing it now
- Con:A long task is still long; it needs splitting too
Never lets a frame or input in between; A chain that keeps queuing itself freezes the page
May not run for seconds on a busy page without a timeout; Not available in Safari; Should not touch the DOM
- Pro:DOM work is capped at the frame rate
- Pro:Works the same for pointer
- Pro:socket and sensor input
- Con:A frame of latency between arrival and drawing
- Con:A little bookkeeping to schedule one callback at a time
Wasted writes that no frame shows; Can force layout many times per frame if handlers read sizes
Timers are not frame-aligned; so visual updates stutter; Debounce hides every intermediate state
What goes wrong
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| Feedback that never paints1Main thread (event loop) | A loading label, spinner or pressed state is written and replaced inside one task, so the user sees a frozen control and taps again | A performance recording shows no frame between the input and the end of a long task; the Interactions track shows a long input-to-paint time | Write the feedback, end the task, continue after the next frame; split the remaining work | The result appears, late and without warning |
| Microtask starvation1Main thread (event loop) | A promise chain or MutationObserver that keeps queuing work holds the loop; no input, timers or frames until it stops | One huge task in the recording made of Run Microtasks entries; the tab stops responding without high network activity | Bound the loop, or continue in a new task (a timer or message) so the loop can breathe | The page is frozen for as long as the chain runs |
| Timer-driven animation stutters2Compositor and display | Updates are doubled in one frame and missing in the next, and halved on 120 Hz screens | Uneven frame intervals in the Frames track; motion looks jerky on fast phones | requestAnimationFrame with time-based progress, or a CSS animation of transform and opacity | Motion is uneven but completes |
| rAF callbacks pile up1Main thread (event loop) | Each pointermove or socket message registers its own callback, so one frame runs dozens of them, each writing the DOM | Many Animation Frame Fired entries in one frame | Keep one pending frame ID; register only when none is pending; apply the latest value | Frames get long and drop under load |
| Clocks built on frames1Main thread (event loop) | Countdowns, progress bars and game timers stall in hidden tabs and jump or run slow when the tab returns | Wrong values after switching tabs or locking the phone | Compute from an absolute end time and the current time; pause media-driven progress on visibilitychange | Wrong numbers on screen until the next refresh from the server |
| Assuming the response renders "immediately"3Network service | Code reads layout or measures an element right after writing the fetched content and gets values from before the next layout, or forces an early layout | Layout entries inside the response task in a recording | Measure in a ResizeObserver or in the next frame's rAF; see the layout-thrashing topic for read and write ordering | Extra layouts per response, or a one-frame flash of the wrong size |