Main-Thread Scheduling & Long Animation Frames
Part of Performance Budgeting & GPU Architecture.
A compositor-only animation is protected from a busy main thread: once the browser has handed a transform or opacity animation to the compositor, it keeps ticking even while JavaScript runs. Almost every other kind of motion is not protected. Script-driven animation, scroll handlers, layout-affecting transitions, the start of every CSS animation and the response to every interaction all need the main thread to be free at the moment the frame is produced. When a task is still running, the frame waits.
This topic is about the part of the frame budget that JavaScript controls. It covers how the event loop decides when rendering runs, how the Long Animation Frames API tells you exactly which script delayed a frame, and the scheduling tools — requestAnimationFrame, scheduler.yield(), scheduler.postTask() — that let long work coexist with smooth motion. It is the script-side companion to frame budgeting and 16ms targets, which decomposes the budget across style, layout, paint and composite.
Concept definition
A long animation frame is a rendering update that took more than 50 milliseconds from the start of the work that led to it to the moment it was presented. It is a frame-centred view of the same problem that long tasks describe from the task queue’s side: instead of asking “which task ran for a long time”, it asks “which frame was late, and what made it late”.
That shift matters for motion. A frame can be late because of one long task, or because of five medium tasks that ran back to back before rendering had a chance, or because a short task forced a synchronous style and layout that took 40ms. Long tasks miss the second and third cases entirely. Long animation frames catch all three and attribute the time to specific scripts, with their source URL, the function that invoked them and how much of their time was spent in forced layout.
Main-thread scheduling is the set of techniques for arranging work so that rendering opportunities are not starved: doing visual writes at the right point in the frame, splitting long work into chunks with yield points between them, and ordering background work behind anything the user can see.
Execution model
The browser’s event loop picks a task, runs it to completion, drains the microtask queue, and then — if it is time for a new frame — runs the rendering steps: requestAnimationFrame callbacks, style, layout, paint, and handing the result to the compositor. Nothing interrupts a running task. A 120ms JSON parse means at least 120ms with no new frame, no matter how simple the animation that is waiting.
Different kinds of motion depend on that loop to different degrees.
Compositor-driven animations — CSS animations and transitions, and Web Animations, that touch only transform, opacity and a few filter properties — are sampled on the compositor thread after they start. A busy main thread does not freeze them. It does delay their start, because the style change that creates them is processed on the main thread, and it delays any change to them: pausing, reversing or retargeting all wait for the task to end.
Main-thread animations — anything that animates a layout or paint property, anything driven from a requestAnimationFrame loop, and all scroll-linked effects implemented in script — need a rendering step on the main thread for every frame. They stall completely during a long task.
Interaction feedback is the case users notice most. A button press that should start a ripple, open a menu or morph a card cannot produce its first frame until the task handling the event, plus any tasks queued before it, have finished. That delay is what Interaction to Next Paint measures.
The rendering step itself has a cost that scripts can inflate. If a task reads layout — offsetHeight, getBoundingClientRect(), getComputedStyle() — after writing to the DOM, the browser must run style and layout synchronously inside the task. That forced style and layout is attributed to the script in a long animation frame entry, which is why the API is the fastest route to finding layout thrashing in production.
Property / API reference table
| API | Accepted values / shape | Compositing tier | Notes |
|---|---|---|---|
PerformanceObserver type long-animation-frame |
entries with duration, blockingDuration, renderStart, styleAndLayoutStart, scripts |
main-thread | Frames over 50ms; use buffered: true to catch early ones |
PerformanceScriptTiming (in scripts) |
invoker, invokerType, sourceURL, sourceFunctionName, forcedStyleAndLayoutDuration |
main-thread | Attribution for scripts over 5ms in the frame |
requestAnimationFrame(cb) |
callback receiving a frame timestamp | main-thread | Runs just before style; the place for visual writes |
scheduler.yield() |
returns a promise | main-thread | Continuation runs ahead of other queued tasks of the same priority |
scheduler.postTask(cb, { priority }) |
user-blocking, user-visible, background |
main-thread | Orders non-urgent work behind rendering-relevant work |
requestIdleCallback(cb, { timeout }) |
callback with a deadline | main-thread | Only runs when a frame has spare time; not for anything visual |
document.visibilityState |
visible, hidden |
none | rAF is paused while hidden; timers are throttled |
animation-play-state |
running, paused |
composite | Pausing a CSS animation stops its sampling entirely |
content-visibility: auto |
keyword | skips rendering | Stops off-screen subtrees costing style, layout and paint |
Annotated code examples
1 — Attributing late frames in the field
// Intent: log which scripts made animation frames late, with forced layout broken out.
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
const worst = [...frame.scripts].sort((a, b) => b.duration - a.duration)[0];
report({
duration: Math.round(frame.duration),
blocking: Math.round(frame.blockingDuration), // time beyond 50ms that blocked input
renderDelay: Math.round(frame.renderStart - frame.startTime),
script: worst?.sourceURL,
fn: worst?.sourceFunctionName,
invoker: worst?.invoker, // e.g. "BUTTON#save.onclick"
forcedLayout: Math.round(worst?.forcedStyleAndLayoutDuration ?? 0),
});
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
/* The animations this protects still carry their reduced-motion branch. */
@media (prefers-reduced-motion: reduce) {
.toast, .menu { animation: none; transition: none; }
}
Rendering Impact: main-thread, negligible. The observer is notified after the frame is presented;
report()should batch and send withnavigator.sendBeacononpagehiderather than on every entry.
The renderDelay field separates two different problems. A large gap between the frame’s start and renderStart means scripts ran too long before rendering could begin. A large gap between renderStart and the end means rendering itself — requestAnimationFrame callbacks, style, layout, paint — was slow, which is a CSS or DOM-size problem rather than a scheduling one. The full field workflow is in debugging jank with the Long Animation Frames API.
2 — Splitting work so an animation can keep running
// Intent: render 400 search results without freezing the loading spinner or the input.
async function renderResults(items, list) {
let chunk = document.createDocumentFragment();
for (let i = 0; i < items.length; i++) {
chunk.append(buildRow(items[i]));
if (i % 40 === 39) {
list.append(chunk); // one DOM write per chunk
chunk = document.createDocumentFragment();
await scheduler.yield(); // let rendering and input run
}
}
list.append(chunk);
}
.results__row {
animation: row-in 200ms ease-out both; /* compositor-only entrance */
}
@keyframes row-in {
from { opacity: 0; transform: translateY(6px); }
}
@media (prefers-reduced-motion: reduce) {
.results__row { animation: none; }
}
Rendering Impact: main-thread in chunks, composite for the rows. Each chunk is a short task; between chunks the browser can render, so the spinner and the row entrances keep their cadence.
scheduler.yield() differs from setTimeout(0) in where the continuation goes. A timeout puts it at the back of the task queue, behind every other pending task. yield() gives the continuation priority over other tasks of the same priority, so the work finishes about as fast as it would have unbroken, but with rendering opportunities in between. Chunk sizing and fallbacks are covered in yielding to the main thread during animations.
3 — Writing visual state once per frame
// Intent: a pointer-following highlight that never writes more than once per frame.
let pending = null;
surface.addEventListener('pointermove', (e) => {
const first = pending === null;
pending = { x: e.offsetX, y: e.offsetY }; // keep only the latest position
if (first) requestAnimationFrame(apply);
});
function apply() {
surface.style.setProperty('--x', `${pending.x}px`);
surface.style.setProperty('--y', `${pending.y}px`);
pending = null;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
surface.classList.add('is-static'); // CSS hides the moving highlight
}
Rendering Impact: main-thread per frame, then composite if
--xand--yfeed atransform. High-frequency input fires many events per frame; collapsing them to one write per frame avoids redundant style recalculations.
DevTools workflow
- Record with CPU throttling. In the Performance panel, set CPU to 4x or 6x slowdown and record the interaction or the scroll that stutters. Main-thread problems that hide on a desktop become obvious under throttling.
- Find the red corners. Long tasks are marked with a red triangle in the Main track. Select one and use the Bottom-Up tab, grouped by URL, to see which script dominates.
- Look for purple inside yellow. A purple Recalculate Style or Layout block nested inside a yellow script block is forced synchronous layout. The warning icon on it links to the line that triggered it.
- Check the Frames track. Dropped and partially presented frames are shaded. Hover one to see its duration, and line it up with the task that preceded it.
- Confirm attribution in the field. Run the observer from example 1 in the Console on a real session, or in the page, and compare
sourceFunctionNamewith what the local trace showed. Lab and field often disagree about which script is worst. - Verify the compositor is unaffected. In the Animations drawer, confirm that CSS animations you expect to be compositor-driven keep playing while you pause script execution in the Sources panel. If they freeze, they are touching a main-thread property.
Failure modes & fixes
- A loading spinner freezes while data renders. Root cause: the spinner rotates via a
requestAnimationFrameloop or animates a non-composited property, and rendering the data is one long task. Fix: animate the spinner with a CSStransformanimation, and chunk the render withscheduler.yield(). - Menus open 200ms after the click on low-end phones. Root cause: the click handler does analytics, state updates and DOM work synchronously before the class that starts the animation is applied. Fix: apply the visual state first, then yield, then do the rest — or move analytics to
scheduler.postTaskatbackgroundpriority. - A scroll-linked effect judders even though the handler is tiny. Root cause: the handler reads layout after other code wrote to the DOM, forcing synchronous layout on every scroll event. Fix: move the effect to a native scroll-driven animation, or read once per frame inside
requestAnimationFramebefore any writes. - Long animation frame entries show a third-party script as the invoker. Root cause: a tag or widget running on a timer during your animation. Fix: load it after the page’s critical motion has settled, or gate it behind
requestIdleCallback. - Work keeps running in a background tab and the page stutters when you return. Root cause: timers and fetch callbacks continue while rAF is paused, so DOM work queues up. Fix: stop non-essential loops on
visibilitychangeand resume on return, as covered in pausing off-screen and background-tab animations.
Accessibility and reduced-motion notes
A frozen animation is an accessibility problem as well as a performance one. A progress indicator that stops moving reads as “the app has hung”, and users of assistive technology who rely on focus moving into a newly opened dialog wait for the same blocked task. Keep the state change that matters — focus, aria-expanded, aria-busy — at the start of the handler, before any heavy work, so the accessible experience does not depend on the animation.
Reduced motion does not remove the scheduling problem; it removes the motion that exposes it. A user with prefers-reduced-motion: reduce still waits for the long task before the menu appears — it just appears without animating. Measure interaction latency under both settings, following the approach in motion testing and automation, because a regression can hide behind a disabled animation.
Script-driven animation loops should check the preference once and subscribe to changes, not query it every frame:
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let animate = !reduce.matches;
reduce.addEventListener('change', (e) => { animate = !e.matches; });
Frequently asked questions
Do CSS animations keep running when JavaScript is busy?
Animations and transitions that only change transform, opacity or certain filters are sampled on the compositor and keep running during a long task. They cannot start, stop or change until the task ends, and animations of any other property freeze with the main thread.
What is the difference between long tasks and long animation frames?
Long tasks report individual tasks over 50ms. Long animation frames report rendering updates that were delayed past 50ms, including frames delayed by several shorter tasks or by forced layout, and they attribute the time to specific scripts. For motion, long animation frames are the more useful signal.
Is scheduler.yield() better than setTimeout for breaking up work?
Yes, for work that should finish promptly. setTimeout sends the continuation to the back of the queue, so other tasks can run first and the work takes longer overall. scheduler.yield() lets rendering and input run but resumes the continuation ahead of other same-priority tasks.
Should I use requestIdleCallback for animation work?
No. Idle callbacks only run when a frame has spare time and may not run for a long while on a busy page. Use requestAnimationFrame for visual writes and requestIdleCallback only for work with no visible effect, such as analytics or prefetching.
How do I collect long animation frame data from real users?
Register a PerformanceObserver for the long-animation-frame entry type with buffered: true, summarise the worst entries per page view, and send them with navigator.sendBeacon when the page is hidden. Field data catches devices and third-party scripts that lab traces miss.
Related
- Frame Budgeting & 16ms Targets — the budget this work is competing for
- Debugging Jank with the Long Animation Frames API — field attribution step by step
- Yielding to the Main Thread During Animations — chunking work with scheduler.yield()
- Pausing Off-Screen and Background-Tab Animations — stopping work nobody can see
- Compositor-Only Property Optimization — the animations a busy main thread cannot freeze
- Idle-Until-Urgent Animation Setup — prepare animation work during idle time but never let it delay an interaction
- OffscreenCanvas and Workers for Animation — move canvas rendering off the main thread with OffscreenCanvas and a worker
- Third-Party Scripts and Animation Jank — tags, chat widgets and A/B tools run on your main thread and stutter your