Diagnosing Main-Thread Jank in the Performance Panel

Scroll jank almost always has a main-thread cause, and the Performance panel is where you prove which one. This page is the hands-on diagnosis workflow that follows the recording basics in the Chrome DevTools profiling guide for scroll animations: record a trace while scrolling, isolate the long tasks, attribute them to their source — most often a scroll handler forcing synchronous layout — and confirm that your animation-timeline work is genuinely staying off the main thread. It assumes the vocabulary of the wider Animation Performance Profiling & Optimization section: main thread versus compositor thread, frame budget, long task.

When to use this approach

Use this workflow when the symptom is stutter during scroll — frames visibly freezing or jumping — rather than incorrect animation behavior:

  • Scroll stutters but the scroll-driven animation itself is simple. The animation is probably fine; something else on the page is blocking the main thread while you scroll. This workflow finds it.
  • The page mixes animation-timeline with legacy scroll listeners. Migrated codebases often keep old scroll handlers alive alongside new CSS timelines. The trace shows exactly what each costs.
  • Jank appears only on mid-range hardware. Long tasks that fit inside the frame budget on a desktop CPU blow through it at 4x throttling. Diagnose under throttling, not on raw desktop speed.
  • You need evidence before refactoring. “The scroll handler is slow” is a hypothesis; a flame chart with a 140 ms task attributed to a named function is a finding.

If the animation moves smoothly but looks wrong — easing, direction, or range problems — use the guide to debugging scroll animation timing functions instead; that is a correctness problem, not a jank problem.


Long-task anatomy: forced reflow inside a scroll handler A main-thread lane shows a short 18 millisecond task followed by a 130 millisecond long task that crosses the dashed 50 millisecond threshold line. Inside the long task, a scroll event handler contains a style read that forces reflow, a layout block, and a style write. Below, a compositor lane shows uninterrupted scroll-driven animation ticks, indicating the CSS timeline itself is not the cause of the jank. 50 ms long-task threshold Main Task 18 ms Long task — 130 ms Event: scroll — onScroll() read offsetTop Layout (forced reflow) write style.top read-after-write forces synchronous layout Compositor animation-timeline ticks continue uninterrupted the CSS timeline is not the jank source

Implementation

Step 1: Set up a clean recording environment

Diagnosis is only as good as the trace. Before recording:

  1. Open the page in an incognito window — extensions inject scripts that pollute the Main track with tasks you did not write.
  2. Undock DevTools into its own window so panel rendering does not compete with the page.
  3. Performance panel gear menu → CPU: 4x slowdown. Main-thread jank that only exists on slower hardware is the common case, and throttling reproduces it deterministically.
  4. Disable “Record and reload”-style options; you want steady-state scrolling, not load-time noise.

Step 2: Record a trace while scrolling

Click Record, then scroll the janky region the way a user would — one slow drag through the animated range, one fast fling — and stop after three to five seconds. Immediately note the overall shape:

  • The Frames track: where do the dropped or partially presented frames concentrate?
  • The Main track: are tasks flagged with the red long-task triangle in the same time window?

Drag-select the window around one group of dropped frames to zoom the flame chart. All remaining steps happen inside this window.

Step 3: Find the long tasks

Any main-thread task over 50 ms is a long task and gets a red corner triangle in the Main track. For each one in your window:

  1. Click the task and open the Bottom-Up tab to see aggregated self-time by activity — this answers what kind of work dominated (scripting, style recalculation, layout).
  2. Switch to Call Tree to see whose code initiated it, with function names and source URLs.
  3. Check the Summary pie for the task: a scripting-dominated task points to a handler or rAF loop; a rendering-dominated task points to style/layout churn.

Attribute every long task to one of three buckets: your scroll-linked JavaScript, third-party per-frame code (analytics, embeds), or rendering work forced by DOM/style changes. The fixes differ per bucket, so do not skip attribution.

Step 4: Identify forced reflow from scroll handlers

The classic scroll-jank signature is a scroll event handler that reads layout, writes style, then reads again. Inside a long task, look for a purple Layout block nested within a scripting block, annotated with a warning that forced reflow is a likely bottleneck. Clicking the Layout entry shows the JS stack that forced it — file, function, and line.

The underlying pattern in code looks like this:

// Anti-pattern: layout read after style write, per scroll event
window.addEventListener('scroll', () => {
  const y = window.scrollY;
  cards.forEach((card) => {
    card.style.transform = `translateY(${y * 0.1}px)`; // write
    const top = card.offsetTop;                        // read → forced reflow
    if (top < threshold) card.classList.add('pinned'); // write again
  });
});

Each offsetTop read after a style write invalidates the layout tree and forces the engine to run layout synchronously, once per card, per scroll event — potentially dozens of forced layouts inside a single task. The Performance panel shows this as a comb of purple Layout slivers inside one scripting block. That comb is the definitive forced-reflow signature; see the rendering pipeline guide for why reads and writes interleaved this way defeat the engine’s batching.

Step 5: Confirm animation-timeline work stays off the main thread

Now verify the CSS scroll-driven animation itself is innocent (or guilty). In the same trace window:

  1. Find the compositor thread track. During scroll it should show a steady tick per frame while the animation is in range.
  2. Scan the Main track for per-frame Recalculate Style, Layout, or Paint entries attributable to the animated elements. A compositor-driven animation produces none — no recurring main-thread work at all.
  3. Run the stall test: in the console, execute a deliberate busy-loop and scroll during it.
// Stall the main thread for 500 ms while you scroll
const end = performance.now() + 500;
while (performance.now() < end) { /* block */ }

If the scroll-driven animation keeps advancing during the stall, it runs on the compositor — the jank you recorded comes from elsewhere (Steps 3–4 found it). If the animation freezes with the page, it has fallen back to the main thread, usually because the keyframes animate a non-composited property. That moves the fix into compositor-safe property selection and will-change budgeting.

Step 6: Apply the fix pattern that matches the finding

  • Forced reflow in a scroll handler → replace the handler with a declarative timeline where possible; animation-timeline: scroll() moves the entire effect off the main thread and deletes the handler outright.
  • Handler must stay (business logic, not visuals) → make the listener { passive: true }, batch all reads before all writes, and move writes into a single requestAnimationFrame callback per frame.
  • Main-thread animation fallback → restrict keyframes to transform and opacity so the compositor can own them.
  • Third-party per-frame cost → defer or gate the script; no CSS change will fix code you do not control running every frame.
// Fixed handler: passive, batched, one rAF write per frame
let scheduled = false;
window.addEventListener('scroll', () => {
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;                    // one read
    const tops = cards.map((c) => c.offsetTop);  // all reads first
    cards.forEach((card, i) => {                 // then all writes
      card.classList.toggle('pinned', tops[i] < threshold);
    });
    scheduled = false;
  });
}, { passive: true });

Verification

Re-record the identical scenario after each fix and compare traces:

  1. Frames track: the runs of dropped frames from the original trace are gone at the same scroll positions, still under 4x CPU throttling.
  2. Main track: no long-task triangles during the scroll window; total main-thread time in the Summary pie visibly reduced.
  3. Forced reflow warnings: zero Layout entries nested inside scripting blocks.
  4. Compositor track: uninterrupted per-frame ticks across the whole gesture, including any remaining heavy main-thread moments.
  5. Console check: document.getAnimations() on an animated element’s timeline shows the expected ScrollTimeline, confirming the declarative path actually attached after the refactor.

Keep the before/after trace files (Performance panel → Save profile) in the repository or ticket. Regressions are far easier to argue about with two saved traces than with adjectives, and interaction-latency effects can be tracked against the Core Web Vitals budgets for animated interfaces.

Edge cases and gotchas

Long tasks that belong to the framework, not your handler. React, Vue, and Svelte schedule reconciliation work that can land mid-scroll if scroll position feeds component state. The Call Tree shows framework internals at the top; your state update is buried lower. Fix the state-update frequency (throttle, or move the value to a CSS custom property) rather than blaming the framework function at the stack top.

Passive listeners remove input blocking, not task cost. { passive: true } only tells the engine it need not wait for preventDefault() before scrolling. Your handler still runs on the main thread and still shows up as tasks. Passive is necessary, not sufficient.

Forced reflow with no obvious read. getComputedStyle() followed by reading any layout-dependent property, getBoundingClientRect(), scrollHeight, and even focus() can all force layout. If the Layout block’s stack points at innocuous-looking code, check for these hidden readers.

The 50 ms threshold is about responsiveness, not smoothness. A page can drop frames with no long tasks — twenty 14 ms tasks back-to-back overflow the frame budget without any single task crossing 50 ms. When the Frames track shows drops but no red triangles, sum task time per frame interval instead of hunting long tasks.

Trace looks clean but users still report jank. DevTools throttling models CPU, not thermal state, memory pressure, or low-power cores. Confirm on real hardware via remote debugging (chrome://inspect) before closing the issue.

Browser-specific notes

Chrome / Edge (Chromium). Everything above applies directly; animation-timeline has been supported since Chrome 115 and Edge 115, so the compositor-verification step tests the real native path. The forced-reflow warning and the long-task red triangle are Chromium-specific affordances.

Firefox. The Performance panel front-ends the Firefox Profiler. Long tasks appear in the marker chart as wide Reflow and Styles markers; there is no 50 ms triangle, so filter markers by duration instead. Because animation-timeline sits behind a pref (available in Nightly), release-channel Firefox exercises your fallback code path — profile it deliberately, since an IntersectionObserver or scroll-listener fallback is precisely the kind of main-thread code this page diagnoses.

Safari. Web Inspector → Timelines with the Frames mode shows per-frame script/style/layout/paint segments; forced synchronous layout appears in the Layout & Rendering timeline with a call stack. Scroll-driven animation support is in active development (Technology Preview builds), so on current release Safari, as with Firefox, you are usually profiling the fallback path rather than a native ScrollTimeline.


Up: Profiling Scroll Animations in Chrome DevTools