How browsers render contentThe frame and the event loop

100%

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.

Intermediate40 minUpdated 2 Oct 2026

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.

  1. Click task runs here1Main thread (event loop)
  2. 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.

  1. 0–2 ms · Stock line in the DOM · In stock (Cream)
  2. 0–100 ms · Screen · still showing Cream
  3. 0 ms · Main thread · tap
  4. 1–3 ms · Main thread · mark Teal, write label
  5. 2–88 ms · Stock line in the DOM · Checking stock…
  6. 3–87 ms · Main thread · rebuild "Pairs well with" (84 ms)
  7. 16.7 ms · Screen · vsync (tick)
  8. 33.3 ms · Screen · vsync (tick)
  9. 45 ms · Stock line in the DOM · never painted (error, stale)
  10. 50 ms · Screen · vsync (tick)
  11. 66.7 ms · Screen · vsync (tick)
  12. 83.3 ms · Screen · vsync (tick)
  13. 87–89 ms · Main thread · write stock
  14. 88–140 ms · Stock line in the DOM · In stock, delivered Thursday
  15. 89–94 ms · Main thread · style, layout, paint
  16. 94 ms · Main thread → Screen, arriving 100 ms
  17. 100–140 ms · Screen · Teal + new row + stock
  18. 100 ms · Screen · first frame after the tap (error, delayed)
Illustrative timings on Copperpot's 60 Hz phone. Display refreshes every 16.7 ms. Rendering opportunities that arrive while the click task runs are not lost forever, but nothing can be drawn until the task ends.
Was this section helpful?

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)

The main thread through one turn and one frame. 5 states, 9 transitions. The table below lists them.
The main thread through one turn and one frameThe states of Main thread (event loop). 5 states, 9 transitions. The table below lists them.

task queued / run oldest in chosen queue

task returns / microtask checkpoint

microtask queues another / run it too

queue empty [tasks waiting]

queue empty [nothing waiting]

queue empty [rendering task is next]

rendering opportunity [something to draw]

callbacks done / microtasks drained after each

frame handed off / compositor presents it

Idle

Running a task

Draining microtasks

Animation frame callbacks

Style, layout, paint

5 steps. One task, one checkpoint, one frame, in that order.

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.

Transitions of The main thread through one turn and one frame
From → ToEventGuardActionActor
Idle → Running a tasktask queuedrun oldest in chosen queueevent loop
Running a task → Draining microtaskstask returnsmicrotask checkpointevent loop
Draining microtasks → Draining microtasksmicrotask queues anotherrun it tooscript
Draining microtasks → Running a taskqueue emptytasks waitingevent loop
Draining microtasks → Idlequeue emptynothing waitingevent loop
Draining microtasks → Animation frame callbacksqueue emptyrendering task is nextevent loop
Idle → Animation frame callbacksrendering opportunitysomething to drawcompositor
Animation frame callbacks → Style, layout, paintcallbacks donemicrotasks drained after eachevent loop
Style, layout, paint → Idleframe handed offcompositor presents itcompositor
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

QueueWho puts work thereWhen it runsHow much per turn
Task queuesevents, setTimeout, setInterval, MessageChannel, fetch responses, parserOne task per turn, in an order the browser chooses between queuesOne, then a microtask checkpoint
Microtask queuePromise.then / await, queueMicrotask, MutationObserverRight after the current task or callback returns, before anything elseAll of them, including ones added while draining
Animation frame callbacksrequestAnimationFrameInside the rendering steps, just before style and layout, at the next rendering opportunityAll callbacks registered before the frame started; new ones wait for the next frame
Idle callbacksrequestIdleCallbackWhen no task is runnable, with a deadline of at most 50 ms or less if a frame or timer is dueAs 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

#StepWhat runsOn Copperpot's page
1Skip checkHidden, render-blocked or unchanged documents with no rAF callbacks drop outA background tab with the casserole page skips the whole list
2Resize stepsresize event, if the viewport changed sizeThe phone rotates; the gallery listener re-reads its width once, here
3Scroll stepsscroll events pending since the last frameHowever far the page moved since the last frame, a scroll listener gets one event
4Media queriesMediaQueryList change eventsThe page crosses its 600 px breakpoint
5AnimationsCSS and Web Animations advance; animation and transition events fireThe swatch ring finishes its 150 ms transition; transitionend fires
6Fullscreenfullscreenchange and fullscreenerrorThe gallery goes full screen
7Animation frame callbacksEvery requestAnimationFrame callback queued before this step began, each with the same timestampThe free-delivery meter moves one step
8Style and layoutRecalculate style and lay out; deliver ResizeObserver entries and repeat while they cause changes"Pairs well with" learns its new width and lays out again
9Focus fixupMove focus away from an element that stopped being focusableThe focused size tile was removed by the rebuild
10Intersection observationsIntersectionObserver callbacks are queuedThe reviews section scrolled into view; its loader is told
11PaintUpdate the rendering to reflect the current state; the result goes to the compositorTeal, 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 rAFMarked dirtyNext frame redoesOn Copperpot's page
width, height, top, margin, font-size, text content, adding nodesStyle and layout of the element and whatever its size pushes aroundStyle, layout, paint, compositeThe rebuilt "Pairs well with" row moves the reviews below it
color, background-color, box-shadow, outlineStyle and the paint of that element onlyStyle, paint, composite (layout skipped)The swatch ring changes colour on press
transform, opacity (element on its own layer)Only the layer's position or alphaComposite 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

Time between frames at common refresh ratesDoubling the refresh rate from 60 Hz to 120 Hz halves the time between frames from 16.7 ms to 8.3 ms, so the same JavaScript gets half the room per frame.05 ms10 ms15 ms60 Hz90 Hz120 Hz144 Hz16.7 ms11.1 ms8.3 ms6.9 msDisplay refresh rateMilliseconds between frames (ms)Time between frames at common refresh ratesDoubling the refresh rate from 60 Hz to 120 Hz halves the time between frames from 16.7 ms to 8.3 ms, so the same JavaScript gets half the room per frame.05 ms10 ms15 ms60 Hz90 Hz120 Hz144 Hz16.7 ms11.1 ms8.3 ms6.9 msDisplay refresh rateMilliseconds between frames (ms)
1000 ms divided by the refresh rate. The browser's own per-frame work comes out of the same interval.
Data
Display refresh ratems per frame
60 Hz16.7
90 Hz11.1
120 Hz8.3
144 Hz6.9

One 60 Hz frame on Copperpot's page while the delivery meter animates

16.7 ms frame1Main thread (event loop)
  1. input, width 2.5, Input and other tasks, value 2.5 ms
  2. meter, width 0.8, rAF callbacks, value 0.8 ms
  3. style + layout, width 2.2, Style and layout, value 2.2 ms
  4. paint, width 1.5, Paint, value 1.5 ms
  5. browser, width 4.5, Browser's own frame work, value 4.5 ms
  6. 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
Start

As it starts. 2 steps follow.

Widths in milliseconds; illustrative figures for a mid-range phone. The step shows the same work on a 120 Hz screen, where it no longer fits.

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.

Notes
  • 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. 1.2 ms · Meter updates · update 1
  2. 16.7 ms · Frames · shows 1 (tick, ok)
  3. 17.4 ms · Meter updates · update 2
  4. 33.3 ms · Frames · shows 2 (tick, ok)
  5. 33.5 ms · Meter updates · update 3 (error, stale) — Overwritten by update 4 before any frame showed it
  6. 49.6 ms · Meter updates · update 4
  7. 50 ms · Frames · shows 4 (3 lost) (tick, stale)
  8. 66.7 ms · Frames · nothing new: repeat (tick, delayed)
  9. 66.9 ms · Meter updates · update 5
  10. 83.3 ms · Frames · shows 5 (tick, ok)
  11. 83.5 ms · Meter updates · update 6
  12. 100 ms · Frames · shows 6 (tick, ok)
  13. 100.2 ms · Meter updates · update 7
  14. 116.7 ms · Frames · shows 7 (tick, ok)
Illustrative timings. Ticks on the Frames lane are vsyncs, where the browser draws whatever the meter's last update left in the DOM. Timer callbacks fire at least 16 ms apart and slip by a fraction of a millisecond each time.

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

The Teal tap with a server stock check, as an ordered list of steps:
The Teal tap with a server stock check10 steps between Shopper's thumb, Main thread (event loop), Network service, Compositor and display. The steps are listed as text after the diagram.Compositor and displayNetwork serviceMain thread (event loop)Shopper's thumbhandler: Teal pressed, "Checking stock…", fetch() called; returns at oncetask ends; microtask checkpoint is empty; thread is freemicrotask: res.json() starts reading the bodymicrotask: write "3 left in Teal", enable Add to baskettap on Teal (a click task)1GET /api/stock?sku=CAS24-TEAL2next frame: Teal pressed, "Checking stock…"3200, headers (a task settles the fetch promise)4body complete (a task settles the json promise)5next frame: the stock line6
  1. Shopper's thumb → Main thread (event loop): tap on Teal (a click task)
  2. Note over Main thread (event loop): handler: Teal pressed, "Checking stock…", fetch() called; returns at once
  3. Main thread (event loop) → Network service: GET /api/stock?sku=CAS24-TEAL
  4. Note over Main thread (event loop): task ends; microtask checkpoint is empty; thread is free
  5. Main thread (event loop) → Compositor and display: next frame: Teal pressed, "Checking stock…"
  6. Network service → Main thread (event loop) (reply): 200, headers (a task settles the fetch promise)
  7. Note over Main thread (event loop): microtask: res.json() starts reading the body
  8. Network service → Main thread (event loop) (reply): body complete (a task settles the json promise)
  9. Note over Main thread (event loop): microtask: write "3 left in Teal", enable Add to basket
  10. 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 callbackWhen it runsWhat it means on Copperpot
click, keydown, pointerdown, pointerup, touchstart, touchendDiscrete input: dispatched as its own task as soon as the loop gets to itThe Teal tap; Enter on the quantity field
pointermove, touchmove, mousemove, wheelContinuous input: Chrome (since 60) delivers it at most once per frame, just before rAF callbacks; getCoalescedEvents() returns the points in betweenDragging the gallery zoom lens gets one event per frame, not one per sensor reading
scroll, resizeFired from the rendering steps, at most once per frame per targetA scroll listener that updates the sticky price runs at the frame rate, never faster
ResizeObserverIn 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
IntersectionObserverQueued from the rendering steps after layoutThe reviews section reports that it is near the viewport
MutationObserverA microtask, after the DOM changes that triggered itAn analytics script hears about the new stock line before the frame is drawn
Was this section helpful?

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

JobUseWhy
Fill the delivery meter from scriptrequestAnimationFrameOnce 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 fetchawait fetch() / thenThe callback runs as a microtask after the response task; the next frame paints it
Run the rebuild after the feedback has paintedrAF, 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 requesta debounce timerEach 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 happeningrequestIdleCallback with a timeout, setTimeout fallbackLow priority work that should never compete with input; Safari needs the fallback
Choose 2 or 3 "Pairs well with" cards per rowResizeObserverRuns after layout and before paint, so the new layout shows in the same frame
Start loading reviews as they near the screenIntersectionObserverNo scroll listener; reported from the rendering steps

A countdown in a hidden tab

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.

  1. 0–10 s · Tab · visible
  2. 0–10 s · Frames drawn · 60 a second
  3. 10–50 s · Tab · hidden
  4. 10 s · Frame-counted banner · 04:49
  5. 10 s · Clock-based banner · 04:49
  6. 10–50 s · Frames drawn, Frame-counted banner · window: no rendering updates
  7. 50–60 s · Tab · visible
  8. 50–60 s · Frames drawn · 60 a second
  9. 50 s · Clock-based banner · 04:09 (ok, ok)
  10. 60 s · Frame-counted banner · 04:39 (error, violation)
  11. 60 s · Clock-based banner · 03:59 (ok, ok)
Illustrative. The banner starts at 04:59 when the page opens. The tab is hidden from 10 s to 50 s, so at 60 s the banner should read 03:59. The frame-counted banner is still at 04:49 when the tab comes back; the clock-based banner shows 04:09 on the first frame back.

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 atWhat it showsOn the Teal tap
Main trackEvery task as a grey bar with its call stack below it; tasks over 50 ms are flagged with a red cornerOne 88 ms click task with rebuildPairsWellWith filling most of it
Frames trackEach frame that was presented, and frames that were dropped or only partly presentedNo new frame between the tap and 100 ms; after the fix, one at about 33 ms
Animation Frame Fired entriesrequestAnimationFrame callbacks, inside the rendering workThe delivery meter's callback, once per frame, each well under 1 ms
Recalculate style, Layout, PaintThe rendering steps after the callbacks, normally once per frameOne of each after the task; several Layout entries inside a task point to forced layout
Interactions trackEach tap or key press, from input to the frame that answered itThe Teal tap spans about 100 ms before the fix and about 33 ms after

Where the system flows lean on it

Live cursors (figma/live-cursors)
Dozens of remote cursor positions arrive per second over a socket. Each message only stores the latest position; one rAF callback per frame moves every cursor, so the work is per frame, not per message.
Select and transform (figma/select-and-transform)
A drag's pointermove events arrive once per frame in Chrome; the handler records the point (and the coalesced ones for precision) and the frame's rAF applies the transform.
Stories viewer (instagram/stories-viewer)
The progress bar's position comes from elapsed media time, not from frames counted, and the story pauses on visibilitychange, because rAF stops in a hidden tab.
Rich-text editing (google-docs/rich-text-editing)
Each keystroke is a discrete task that must update the model and leave the next frame enough time for layout of the edited paragraph; heavy work such as spell-checking waits for idle time.
Product page (amazon/product-detail-page)
Variant taps paint the pressed state and a pending price at the next frame, and fetch or compute the rest after it, as in Copperpot's swatch handler.
Long tasks and INP (javascript-performance/long-tasks-and-inp)
The measurement side of this topic: how long the main thread was blocked between an input and the frame that answered it, and how to split the work.

Saying it in an interview

Q1
PromptYou tap a filter and the results update, but the button's pressed state lags behind. Why, and what would you change?
The press and the results are written in the same task, and the browser can only paint after the task, so the press waits for the filtering work. I would write the pressed state, end the task so a frame can happen (a rAF followed by a timer guarantees one; scheduler.yield(), where available, gives a due frame the chance to run), then filter. If the filtering itself is long, I would split it or move it to a worker so the next input is not blocked.
Q2
Follow-upWhy not just put the heavy part in a .then() or after an await on a resolved promise?
Because that is a microtask. Microtasks run before the loop can do anything else, including render, so the screen still shows nothing until the work is done. Only a new task, started after a rendering update, guarantees a frame in between.
Q3
Follow-upThe design has a live price ticker that updates 30 times a second from a WebSocket. How do you keep it smooth?
Decouple arrival from drawing. Socket messages are tasks that only store the latest value; a single requestAnimationFrame callback reads that value and writes the DOM once per frame. That caps DOM work at the frame rate whatever the message rate, and rAF stops in a hidden tab, which saves work but means anything time-based is computed from timestamps.
Q4
Where it fitsHow much of a design interview should go on the event loop?
Very little on its own. It belongs in the interactions and performance parts, as one or two sentences attached to a specific flow: what the user sees on the first frame after their input, which work is deferred to a later task, and which updates are batched per frame. Naming the frame budget for the target device (16.7 ms at 60 Hz, half of that at 120 Hz) shows you have thought about real phones.
Was this section helpful?

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.

01
What drives a visual update from script
Chosen:requestAnimationFrame, time-based
  • 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
Downside we accept:
  • 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
Ruled out:setTimeout or setInterval at about 16 ms

Not aligned with frames; so updates are doubled or missed; Half rate on 120 Hz screens; Fires late whenever the thread is busy

Ruled out:A CSS transition or Web Animation

Only for values known in advance; not per-event values such as a drag position

02
Where to put work that should happen "a bit later"
Chosen:A new task after the next frame
  • Pro:The feedback written before it is guaranteed to be painted
  • Pro:Input queued meanwhile can run first
Downside we accept:
  • Con:At least one frame later than doing it now
  • Con:A long task is still long; it needs splitting too
Ruled out:A microtask (then, await, queueMicrotask)

Never lets a frame or input in between; A chain that keeps queuing itself freezes the page

Ruled out:An idle callback

May not run for seconds on a busy page without a timeout; Not available in Safari; Should not touch the DOM

03
Handling input that arrives faster than frames
Chosen:Store the latest value, apply it once per frame
  • Pro:DOM work is capped at the frame rate
  • Pro:Works the same for pointer
  • Pro:socket and sensor input
Downside we accept:
  • Con:A frame of latency between arrival and drawing
  • Con:A little bookkeeping to schedule one callback at a time
Ruled out:Write the DOM in every handler

Wasted writes that no frame shows; Can force layout many times per frame if handlers read sizes

Ruled out:Throttle or debounce with timers

Timers are not frame-aligned; so visual updates stutter; Debounce hides every intermediate state

What goes wrong

FailureImpactDetectionMitigationMeanwhile
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 againA performance recording shows no frame between the input and the end of a long task; the Interactions track shows a long input-to-paint timeWrite the feedback, end the task, continue after the next frame; split the remaining workThe 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 stopsOne huge task in the recording made of Run Microtasks entries; the tab stops responding without high network activityBound the loop, or continue in a new task (a timer or message) so the loop can breatheThe page is frozen for as long as the chain runs
Timer-driven animation stutters2Compositor and displayUpdates are doubled in one frame and missing in the next, and halved on 120 Hz screensUneven frame intervals in the Frames track; motion looks jerky on fast phonesrequestAnimationFrame with time-based progress, or a CSS animation of transform and opacityMotion 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 DOMMany Animation Frame Fired entries in one frameKeep one pending frame ID; register only when none is pending; apply the latest valueFrames 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 returnsWrong values after switching tabs or locking the phoneCompute from an absolute end time and the current time; pause media-driven progress on visibilitychangeWrong numbers on screen until the next refresh from the server
Assuming the response renders "immediately"3Network serviceCode reads layout or measures an element right after writing the fetched content and gets values from before the next layout, or forces an early layoutLayout entries inside the response task in a recordingMeasure in a ResizeObserver or in the next frame's rAF; see the layout-thrashing topic for read and write orderingExtra layouts per response, or a one-frame flash of the wrong size
This topic
Tasks, task queues and the microtask checkpoint
Rendering opportunities, refresh rates and the frame budget
The update-the-rendering steps and where rAF and observers sit
requestAnimationFrame against timers, and hidden-tab behaviour
Elsewhere
Long tasks, yielding, scheduler.yield() and INPLong tasks and INP
Reading layout after writing stylesLayout thrashing
What the compositor can animate without the main threadLayers and compositing
Moving work off the main threadWeb workers
Event dispatch, passive listeners and delegationEvents and delegation
Was this section helpful?
Builds on this
Layers and compositing
Read next