Animation Performance Profiling & Optimization
Scroll-driven animations exist for one reason: performance. The scroll-timeline API moves animation interpolation off the main thread and onto the compositor, where the browser’s rendering pipeline can produce frames without recalculating style, layout, or paint. But that performance is conditional, not automatic — animate the wrong property, promote too many layers, or block the main thread during a transition, and the compositor advantage evaporates. This section covers the measurement side of that contract: how to profile scroll-driven animations and view transitions with browser tooling, which properties stay on the compositor’s fast path, how to budget will-change and GPU memory, and how animated interfaces move the Core Web Vitals needles (INP, CLS, LCP) that ship in field data.
In this section
Profiling Scroll Animations in Chrome DevTools
The full Performance panel workflow for scroll-driven effects: recording a scroll trace, reading the thread tracks, using the Animations drawer and Layers view to confirm compositor execution, plus Firefox and Safari equivalents.
Compositor-Safe Properties & will-change Budgeting
Which properties composite (transform, opacity, some filter functions) and which force layout or paint, how layer promotion works, and how to budget will-change against real GPU memory limits on mobile hardware.
Core Web Vitals & Animated Interfaces
How scroll-driven animations and view transitions affect INP, CLS, and LCP, how to measure them with PerformanceObserver, and how to set regression budgets that catch animation-induced slowdowns before release.
Core concept: the frame budget
Every performance conversation about animation reduces to one number: the frame budget. A 60 Hz display refreshes every 16.7 ms; a 120 Hz display every 8.3 ms. Whatever the browser must do to produce the next frame — JavaScript, style recalculation, layout, paint, raster, composite — has to finish inside that window, or the frame is dropped and the user perceives jank.
A workable planning split for the 16.7 ms budget on a 60 Hz display:
- ~4 ms — input handling and JavaScript (event listeners,
requestAnimationFramecallbacks, framework work) - ~3 ms — style recalculation and layout
- ~2 ms — paint (generating display lists, invalidating regions)
- ~7 ms — compositor work: rasterizing tiles, uploading textures, drawing layers, and handing the frame to the display
The strategic insight behind scroll-driven animations is that a correctly authored effect consumes only the final compositor slice — and only a fraction of it. A transform or opacity animation bound to animation-timeline: scroll() costs roughly 0.1–0.3 ms per frame on the compositor thread, because the browser skips the JavaScript, style, layout, and paint phases entirely. The same visual effect driven by a scroll event listener writing inline styles must pass through every phase, costing 4–12 ms per frame and competing with everything else the main thread is doing.
The diagram below shows where that budget is spent and — critically for this section — where each profiling tool attaches:
Three attachment points matter:
PerformanceObserversees main-thread entries only — long animation frames, event timing (INP), layout shifts (CLS), and largest contentful paint. A scroll-driven animation running purely on the compositor is invisible to it, which is exactly what you want: if your animation shows up in long-animation-frame entries, it has fallen off the fast path.- The Performance panel records both threads, making it the only tool that can prove where an animation is actually executing. Profiling Scroll Animations in Chrome DevTools walks through the full recording and interpretation workflow.
- The Layers and Animations panels inspect compositor state directly: which elements own GPU layers, how much memory each layer consumes, and which timelines are attached.
Rendering pipeline implications
The property you animate determines which pipeline stages every frame must re-run. This is the single highest-leverage optimization decision, and it is made at authoring time, not profiling time:
transformandopacity— composite-only. The element’s layer is re-drawn from cached textures; no layout, no paint. These are the only two properties with universally reliable compositor support.filterandbackdrop-filter— composited in Chromium for many functions (blur(),brightness(),opacity()), but cost scales with the filtered area and can force intermediate render surfaces. Treat as conditionally safe and verify in a trace.background-color,color,box-shadow,border-radius— paint properties. Each frame invalidates the layer’s texture and re-paints it. Acceptable for small elements at low frequency; expensive when bound to a scroll timeline that samples every frame.width,height,top,left,margin,padding,flex-basis— layout properties. Each frame re-runs layout for the element and everything affected by it, then paint, then composite. Bound to a scroll timeline, these produce per-frame layout thrash that no amount of GPU power can hide. The measured cost difference is dissected in Animating transform vs top/left in Scroll-Driven Effects.
/* Compositor fast path — stays on the GPU */
.card-lift {
animation: lift linear both;
animation-timeline: view();
animation-range: entry 0% entry 80%;
}
@keyframes lift {
from { transform: translateY(3rem) scale(0.96); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* Slow path — forces layout on every scroll frame. Avoid. */
@keyframes lift-broken {
from { margin-top: 3rem; height: 90%; }
to { margin-top: 0; height: 100%; }
}
There is a second, subtler pipeline implication: a scroll timeline can only run on the compositor if its keyframes are composite-safe and the animated element has a compositable layer. If any keyframe touches a layout or paint property, Chromium demotes the entire animation to the main thread — including the transform parts. One background-color keyframe silently converts a free animation into a per-frame style/paint bill. The demotion rules, layer promotion triggers, and the memory cost of promotion are covered in Compositor-Safe Properties & will-change Budgeting.
View transitions have their own pipeline profile. As explained in how the View Transition API works under the hood, document.startViewTransition() captures the old state as a snapshot, runs your DOM-update callback on the main thread while rendering is paused, then crossfades snapshots on the compositor. The crossfade itself is cheap; the risk concentrates in the callback. Rendering is frozen while it runs, so a 300 ms DOM update is 300 ms of visible freeze and a main-thread block that lands directly on INP.
Browser support matrix
Profiling scroll-driven animations requires two feature families: the animation APIs themselves and the measurement APIs that observe them. Support differs sharply between the two.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
animation-timeline: scroll() / view() |
115 (2023-07) | 115 (2023-07) | Behind pref (Nightly) | In active development (Technology Preview) |
| View Transitions Level 1 (same-document) | 111 (2023-03) | 111 (2023-03) | In development | 18 (2024-09) |
@view-transition (cross-document) |
126 (2024-06) | 126 (2024-06) | In development | 18.2 (2024-12) |
PerformanceObserver (core) |
52 (2016) | 79 (2020) | 57 (2017) | 11 (2017) |
long-animation-frame entries (LoAF) |
123 (2024-03) | 123 (2024-03) | Not supported | Not supported |
event entries (INP measurement) |
76 (2019) | 79 (2020) | Partial (first-input only) |
Not supported |
layout-shift entries (CLS) |
77 (2019) | 79 (2020) | Not supported | Not supported |
largest-contentful-paint entries |
77 (2019) | 79 (2020) | 122 (2024-01) | In development |
Two practical consequences follow. First, field measurement of INP, CLS, and LoAF is effectively Chromium-only, so your real-user metrics describe Chromium users; treat Firefox and Safari behaviour as something to verify locally with their own profilers. Second, because animation-timeline itself is not yet universal, every profiling exercise must be paired with the @supports guard strategy below — you are profiling the enhanced experience, and non-supporting engines never execute it.
Measuring Core Web Vitals impact
Animated interfaces touch all three Core Web Vitals, each through a different mechanism:
- INP (Interaction to Next Paint) is the most exposed. A view transition triggered by a click adds its snapshot capture and DOM-update time directly to that interaction’s INP; demoted scroll animations and heavy
scrolllisteners delay the next paint the same way. Measurement and mitigation patterns live in How View Transitions Affect INP. - CLS (Cumulative Layout Shift) penalizes unexpected layout movement — but movement via
transformis explicitly exempt. A reveal animatingtranslateYcontributes zero CLS; the same reveal animatingmargin-topcounts every shifted frame — a second, independent reason to keep keyframes composite-safe. - LCP (Largest Contentful Paint) degrades when the candidate element starts at
opacity: 0awaiting an entry animation. An LCP hero should never be gated behind a scroll-driven entrance.
The observation harness for all three:
// Vitals harness — safe to ship: feature-detects each entry type.
function observe(type, callback) {
if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return;
new PerformanceObserver((list) => list.getEntries().forEach(callback))
.observe({ type, buffered: true });
}
// Long animation frames: a compositor-safe animation should
// produce NONE of these while scrolling.
observe('long-animation-frame', (entry) => {
if (entry.duration > 50) {
console.warn('LoAF during animation:', entry.duration.toFixed(1), 'ms',
entry.scripts?.map((s) => s.sourceURL));
}
});
// Layout shifts: transform-based effects must not appear here.
observe('layout-shift', (entry) => {
if (!entry.hadRecentInput && entry.value > 0.01) {
console.warn('Animation-adjacent shift:', entry.value.toFixed(4),
entry.sources?.map((s) => s.node));
}
});
// Interaction latency feeding INP.
observe('event', (entry) => {
if (entry.duration > 200) {
console.warn('Slow interaction:', entry.name, entry.duration, 'ms');
}
});
Set explicit regression budgets rather than aspirational targets: zero LoAF entries attributable to animation code during a scripted scroll, CLS contribution from animated components below 0.01, and no interaction whose INP breakdown includes more than 50 ms of transition callback time. Budget-setting methodology and CI wiring are covered in Core Web Vitals & Animated Interfaces.
Progressive enhancement strategy
Performance work inherits the same layered structure as feature support. The baseline experience must be complete without animation; the enhancement must be cheap by construction; and the reduced-motion override must remove the cost along with the motion.
/* 1. Baseline — zero animation cost, fully functional */
.reveal {
opacity: 1;
transform: none;
}
/* 2. Enhancement — only in engines with scroll timelines */
@supports (animation-timeline: view()) {
.reveal {
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal-in {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: none; }
}
}
/* 3. Reduced motion removes the work, not just the movement */
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
animation-timeline: auto; /* the shorthand does not reset this */
}
}
Layer 3 is a performance rule as much as an accessibility one: an animation suppressed only visually (for example, by zeroing its displacement) still samples its timeline and still holds any promoted layer. Resetting animation-timeline: auto releases the compositor resources entirely. The full cascade design — including @layer ordering and JavaScript guards — is documented in Implementing prefers-reduced-motion.
The same layering applies to will-change. Its correct use is scoped and temporary: hint just before an expensive one-shot animation, release afterwards. Its incorrect use is a blanket will-change: transform on every card in a grid, pinning dozens of GPU textures for the lifetime of the page. On a mid-range phone, a full-viewport layer at 3× device-pixel ratio costs roughly 15–20 MB of GPU memory; a few dozen promoted cards exhaust the tile budget and force checkerboarding — the slow path you promoted them to avoid. Scroll-timeline animations of transform/opacity generally need no manual will-change at all: the timeline attachment itself triggers promotion in Chromium.
Finally, treat JavaScript animation libraries as a fallback tier, not a parallel system. The decision framework in when to use CSS animations over JavaScript libraries applies directly: anything expressible as a pure CSS scroll timeline should be, because it is the only variant the compositor can run unattended.
Framework integration notes
React
The dominant anti-pattern is driving animation from state: a scroll listener calling setState re-renders the component tree on every scroll frame, spending the entire JavaScript budget on reconciliation before a single pixel moves. CSS scroll timelines eliminate the listener entirely. Where JavaScript must stay in the loop, write to a CSS custom property via a ref instead of state:
// Bypasses React rendering — no reconciliation per frame
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
el.style.setProperty('--scroll-p', String(window.scrollY / maxScroll));
});
};
addEventListener('scroll', onScroll, { passive: true });
return () => removeEventListener('scroll', onScroll);
}, []);
For route transitions, keep the startViewTransition callback minimal — flush the router update inside it and nothing else. The SPA page swap patterns guide shows the integration shape for React Router.
Vue
Per-frame writes to reactive refs are equally expensive — each write schedules watcher flushes. Use shallowRef for animation state templates never read, and declare animation-timeline in a plain <style> block rather than :style bindings, which Vue re-serializes on unrelated updates.
Svelte
Svelte compiles transition: directives to inline styles driven from requestAnimationFrame — main-thread work by construction. Declare scroll timelines in component CSS so the compiler leaves them alone, reserving Svelte transitions for discrete enter/leave moments.
Debugging workflow
A repeatable five-step workflow separates “feels slow” from a diagnosis. The condensed version follows; the full walkthrough with screenshots of each panel is in Profiling Scroll Animations in Chrome DevTools.
1. Record a scroll trace under throttling. In the Performance panel, set CPU throttling to 4Ă— (6Ă— for a mobile-heavy audience), record while scrolling the animated region at realistic speed. Desktop hardware hides main-thread costs that dominate on phones; never profile unthrottled.
2. Read the thread tracks. A healthy scroll-driven animation shows activity on the compositor track and near-silence on the main track while scrolling. Purple (layout) or green (paint) blocks repeating on every frame of the main track mean a keyframe property has forced the slow path. Long tasks flagged with red triangles are candidates for the deeper teardown in Diagnosing Main-Thread Jank in the Performance Panel.
3. Confirm compositor execution. In the Animations drawer, a compositor-run animation keeps advancing even while the main thread is deliberately blocked (pause script execution, then scroll). Complementary signals: Rendering → Frame Rendering Stats for a live FPS and GPU memory readout, and Rendering → Paint flashing — green flashes on every scroll frame mean per-frame repaints.
4. Audit layers. The Layers panel lists every composited layer with memory estimates and promotion reasons. Look for two failure modes: elements you expected to be promoted but are not (animation demoted to main thread), and promotion sprawl — dozens of layers from over-applied will-change or stray view-transition-name assignments, each named transition element allocating its own snapshot textures.
5. Verify timing correctness. Performance problems masquerade as timing bugs and vice versa. If an effect stutters only at specific scroll positions, check animation-range boundaries and easing before blaming the pipeline — the methodology in debugging scroll animation timing functions covers the timing half of the diagnosis.
In Firefox, the profiler’s compositor track serves the role of steps 2–3 (scroll timelines require the Nightly pref to test at all). Safari’s Web Inspector offers Timelines recording with layer memory in its Layers tab; since scroll-driven animation support there is still in active development, Safari profiling currently exercises your fallback path — worth profiling in its own right.
Key concepts index
frame budget
: The time available to produce one frame: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. All pipeline work for a frame must complete inside it; overruns drop frames and register as jank.
compositor thread
: The browser thread that assembles pre-rasterized layers into frames. Scroll-driven animations of composite-safe properties run here, independent of main-thread congestion.
compositor demotion
: The silent fallback where an animation containing any layout- or paint-affecting keyframe runs entirely on the main thread, forfeiting the fast path even for its transform components.
layer promotion
: Assigning an element its own GPU texture so it can move without repainting neighbours. Triggered by scroll-timeline attachment, will-change, 3D transforms, and view-transition-name; each promotion costs GPU memory.
will-change
: A hint requesting advance layer promotion. Budget it: apply just-in-time, release with will-change: auto, and never apply broadly across lists or grids.
long animation frame (LoAF)
: A PerformanceObserver entry type (Chromium 123+) reporting rendering frames that exceeded 50 ms, with script attribution — the primary field signal that animation work is landing on the main thread.
INP
: Interaction to Next Paint; the responsiveness vital. View transition callbacks and main-thread animation work delay the next paint after input and inflate it directly.
CLS
: Cumulative Layout Shift. Movement via transform is exempt; the same movement via layout properties is penalized — aligning the CLS-safe choice with the compositor-safe choice.
GPU memory budget
: The texture memory available for composited layers, tightest on mobile. A full-viewport layer at 3× DPR costs roughly 15–20 MB; exceeding the budget forces re-rasterization and checkerboarding.
Related
- Browser Support & Progressive Enhancement —
@supportsguard patterns for every enhancement tier - Fallback Strategies for Legacy Browsers — what non-supporting engines should run instead, and what it costs
- Building Scroll Progress Indicators — a compositor-only pattern worth profiling as a reference implementation
- Parallax Effects with Pure CSS — the effect class most sensitive to layer and raster costs
- Motion Scaling & User Preferences — token architecture that scales motion cost down alongside motion intensity