Profiling Scroll-Driven Animations

Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.

The problem

A scroll-driven animation is supposed to run entirely on the compositor thread — that is the whole reason to prefer animation-timeline over a JavaScript scroll listener. But nothing in the CSS guarantees it. One layout-triggering property in the keyframes silently escalates the animation to the main thread, and it will still look correct on a fast desktop while dropping frames on mid-tier mobile. Profiling is how you turn “it looks fine” into “the Main thread is provably idle during scroll”.

Root-cause analysis: what “on the compositor” means in a trace

When a scroll-driven animation is healthy, the browser reads the scroll offset, maps it to progress, and samples transform/opacity keyframes inside the same compositing pass that produces the frame. In a DevTools trace this appears as activity on the Compositor track with the Main thread lane essentially empty during scroll — no scripting, no style, no layout.

When a keyframe touches a non-composited property, the browser cannot resolve it on the compositor. It must run Recalculate Style and Layout on the main thread every frame, then hand the result back for compositing. In the trace this shows as repeating purple Layout and grey/blue Recalculate Style blocks on the Main thread, synchronised with scroll. That is the fingerprint of a fallback, and it is what the workflow below is designed to expose. The fix is always the property-discipline rule from avoiding layout thrashing in CSS animations.

Step-by-step DevTools workflow

  1. Record a scroll session. Open Chrome DevTools (F12) → Performance. Click Record, scroll slowly through the animated section for 3–5 seconds, then Stop. Slow, deliberate scrolling makes per-frame work easier to read than a fast flick.
  2. Read the Compositor track. In the trace, find the Compositor thread row. During scroll you should see steady compositing work here. This is where a healthy scroll-driven animation lives.
  3. Confirm the Main thread is idle. Look at the Main thread lane across the same scroll span. For a compositor-driven animation it should be near-empty — no recurring tasks locked to scroll. Any repeating block synchronised with your scroll motion is a red flag.
  4. Hunt for Recalculate Style and Layout. Zoom into a scrolled span. If you see repeating Layout (purple) or Recalculate Style blocks tied to each scroll increment, a keyframe is animating a non-composited property. A single Update Layer Tree on a resize is acceptable; recurring layout per scroll tick is not.
  5. Check the Frames row. The Frames strip at the top shows delivered frames. Green, evenly-spaced frames during scroll confirm you are hitting the budget; red or widely-spaced frames indicate dropped frames from main-thread work.
  6. Enable Layer borders. Open Rendering (⋮ → More tools → Rendering) and tick Layer borders. The animated element should show a compositor-layer border. If it does not have its own layer and still janks, a targeted will-change: transform forces promotion — used sparingly, per layer promotion strategy.
  7. Cross-check in the Animations panel. Open More tools → Animations, scroll, and confirm the timeline’s progress bar tracks scroll position. A bar that does not move signals a misbound animation-timeline rather than a performance issue.

Root-causing jank when it falls back to the main thread

The primary annotated example below is the anti-pattern you are hunting for, paired directly with its compositor-safe fix so the contrast is unambiguous.

/* ANTI-PATTERN — do NOT ship this. Animating height + top forces Layout
   on every scroll tick, escalating the animation to the main thread. */
@keyframes grow-bad {
  from { height: 40px; top: 20px; }   /* layout properties — main-thread */
  to   { height: 200px; top: 0; }
}
.panel-bad {
  position: relative;
  animation: grow-bad linear both;
  animation-timeline: view();
  animation-duration: auto;
}

/* FIX — same visual effect via transform + opacity, sampled on the compositor. */
@keyframes grow-good {
  from { opacity: 0; transform: translateY(20px) scaleY(0.2); }
  to   { opacity: 1; transform: translateY(0)    scaleY(1); }
}
.panel-good {
  transform-origin: top center;
  animation: grow-good linear both;
  animation-timeline: view();
  animation-duration: auto;
}

/* Accessibility gate: both variants settle to a static, visible state. */
@media (prefers-reduced-motion: reduce) {
  .panel-bad,
  .panel-good {
    animation: none;
    animation-timeline: auto;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: the grow-bad keyframes trigger layout on every scroll tick (main-thread); the grow-good keyframes are transform + opacity — composite only.

After swapping to the grow-good pattern, re-record: the Layout blocks that appeared under grow-bad should vanish from the Main thread, and the Compositor track should carry the animation alone.

Verification checklist

Constraints and trade-offs

  • The Compositor track only proves this animation is off-thread; other main-thread work (expensive scroll listeners elsewhere) can still cause jank — profile the whole scroll, not one element.
  • A one-off Update Layer Tree when the page resizes is normal; only recurring layout per scroll tick indicates a fallback.
  • Forcing will-change: transform to guarantee a layer costs GPU texture memory; audit layer count in the Layers panel so promotions stay within budget.
  • Emulated reduced-motion in DevTools does not always match a real OS setting for scroll timelines; verify on a device with the system preference enabled.
  • Fast flick-scrolling can hide per-frame cost; always profile with slow, controlled scrolling to see the true per-tick work.