# Scroll performance analysis

> Source: <https://webperf-snippets.net/Interaction/Scroll-Performance>

Measures scroll jank, frame drops, and event listener configuration to identify what makes scrolling feel laggy or unresponsive. Scroll jank, visible stuttering during scroll, is one of the most common UX problems on the web, especially on mobile.

### Why this matters

Scroll jank is caused by work blocking the compositor thread: non-passive event listeners, heavy JavaScript during scroll, or large layout-triggering DOM reads. Even a few dropped frames per second are noticeable to users and contribute to poor experience scores.

### What this snippet detects

| Problem                          | Detection Method                                                |
| -------------------------------- | --------------------------------------------------------------- |
| **Non-passive scroll listeners** | Monkey-patches `addEventListener` to catch future registrations |
| **Frame drops during scroll**    | Measures `requestAnimationFrame` timing during active scroll    |
| **CSS scroll configuration**     | Audits `will-change`, `content-visibility`, `scroll-behavior`   |

### FPS thresholds

| Rating               | FPS       | Frame time | Experience      |
| -------------------- | --------- | ---------- | --------------- |
| 🟢 Good              | ≥ 55 fps  | ≤ 18ms     | Smooth          |
| 🟡 Needs improvement | 40–54 fps | 18–25ms    | Minor jank      |
| 🔴 Poor              | < 40 fps  | > 25ms     | Visible stutter |

> **Note:** The passive listener detection only catches listeners registered **after** the snippet runs. Run it before interacting with the page for complete coverage.

### Snippet

```js
// Scroll Performance Analysis
// https://webperf-snippets.net

(() => {
  const formatMs = (ms) => `${ms.toFixed(1)}ms`;
  const formatFps = (fps) => `${fps.toFixed(0)} fps`;

  const TARGET_FRAME_MS = 1000 / 60; // ~16.67ms at 60fps
  const DROP_THRESHOLD_MS = TARGET_FRAME_MS * 1.5; // ~25ms = below 40fps

  const RATING = {
    good: { icon: "🟢", color: "#0CCE6A", label: "Good" },
    "needs-improvement": { icon: "🟡", color: "#FFA400", label: "Needs Improvement" },
    poor: { icon: "🔴", color: "#FF4E42", label: "Poor" },
  };

  const fpsRating = (fps) =>
    fps >= 55 ? "good" : fps >= 40 ? "needs-improvement" : "poor";

  // ─── 1. PASSIVE EVENT LISTENER DETECTION ────────────────────────────────────
  // Detects scroll/touch listeners registered without { passive: true }.
  // Non-passive listeners delay scroll by forcing the browser to wait for JS.

  const nonPassiveListeners = [];
  const SCROLL_EVENT_TYPES = new Set([
    "scroll", "wheel", "touchstart", "touchmove", "touchend",
  ]);

  const _origAddEventListener = EventTarget.prototype.addEventListener;
  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (SCROLL_EVENT_TYPES.has(type)) {
      const isPassive =
        options === true
          ? false // old-style capture boolean, not passive
          : typeof options === "object" && options !== null
          ? options.passive === true
          : false;

      if (!isPassive) {
        const entry = {
          type,
          element: this.tagName || this.constructor?.name || "unknown",
          id: this.id || "",
          passive: false,
        };
        nonPassiveListeners.push(entry);
        console.warn(
          `%c⚠️ Non-passive "${type}" listener on <${entry.element}${
            entry.id ? `#${entry.id}` : ""
          }>`,
          "color: #FFA400; font-weight: bold;",
          this
        );
        console.warn(
          "   Fix: add { passive: true } to improve scroll performance."
        );
      }
    }
    return _origAddEventListener.call(this, type, listener, options);
  };

  // ─── 2. SCROLL FPS MEASUREMENT ──────────────────────────────────────────────
  // Tracks requestAnimationFrame timing during active scroll sessions.

  const sessions = [];
  let currentSession = null;
  let rafId = null;
  let lastFrameTime = null;
  let endTimer = null;

  const trackFrame = (now) => {
    if (!currentSession) return;
    if (lastFrameTime !== null) {
      const frameTime = now - lastFrameTime;
      currentSession.frames.push(frameTime);
      if (frameTime > DROP_THRESHOLD_MS) currentSession.drops++;
    }
    lastFrameTime = now;
    rafId = requestAnimationFrame(trackFrame);
  };

  const endSession = () => {
    if (!currentSession) return;
    cancelAnimationFrame(rafId);

    const { frames, drops } = currentSession;
    if (frames.length < 2) {
      currentSession = null;
      return;
    }

    const avgFrameTime = frames.reduce((a, b) => a + b, 0) / frames.length;
    const avgFps = 1000 / avgFrameTime;
    const minFps = 1000 / Math.max(...frames);
    const dropRate = (drops / frames.length) * 100;
    const rating = fpsRating(avgFps);
    const r = RATING[rating];

    const session = {
      avgFps,
      minFps,
      frames: frames.length,
      drops,
      dropRate,
      rating,
    };
    sessions.push(session);

    console.groupCollapsed(
      `%c${r.icon} Scroll: avg ${formatFps(avgFps)} — ${frames.length} frames, ${drops} drop(s)`,
      `font-weight: bold; color: ${r.color};`
    );
    console.table([
      {
        "Avg FPS": formatFps(avgFps),
        "Min FPS": formatFps(minFps),
        "Frames": frames.length,
        "Drops": drops,
        "Drop Rate": `${dropRate.toFixed(1)}%`,
        "Rating": r.label,
      },
    ]);
    if (drops > 0) {
      const worstMs = Math.max(...frames);
      console.log(
        `   Worst frame: ${formatMs(worstMs)} (target: ${formatMs(TARGET_FRAME_MS)})`
      );
    }
    console.groupEnd();

    currentSession = null;
    lastFrameTime = null;
  };

  window.addEventListener(
    "scroll",
    () => {
      if (!currentSession) {
        currentSession = { frames: [], drops: 0 };
        lastFrameTime = null;
        rafId = requestAnimationFrame(trackFrame);
      }
      clearTimeout(endTimer);
      endTimer = setTimeout(endSession, 200);
    },
    { passive: true }
  );

  // ─── 3. CSS SCROLL AUDIT ────────────────────────────────────────────────────
  // Checks CSS properties that affect scroll compositing and performance.

  const auditScrollCSS = () => {
    const results = {
      smoothScrollElements: [],
      willChangeElements: [],
      contentVisibilityElements: [],
      overscrollElements: [],
    };

    document.querySelectorAll("*").forEach((el) => {
      const cs = getComputedStyle(el);
      const label =
        el.tagName.toLowerCase() +
        (el.id ? `#${el.id}` : el.className
          ? `.${String(el.className).trim().split(/\s+/)[0]}`
          : "");

      if (cs.scrollBehavior === "smooth") {
        results.smoothScrollElements.push(label);
      }
      if (cs.willChange && cs.willChange !== "auto") {
        results.willChangeElements.push({ element: label, value: cs.willChange });
      }
      if (cs.contentVisibility && cs.contentVisibility !== "visible") {
        results.contentVisibilityElements.push({
          element: label,
          value: cs.contentVisibility,
        });
      }
      if (cs.overscrollBehavior && cs.overscrollBehavior !== "auto") {
        results.overscrollElements.push({
          element: label,
          value: cs.overscrollBehavior,
        });
      }
    });

    return results;
  };

  // Findings as homogeneous items and actionable issues (same value at start and in the summary)
  const buildFindings = (css) => {
    const seen = new Set();
    const items = [];
    const add = (kind, target, detail) => {
      const key = `${kind}|${target}|${detail}`;
      if (seen.has(key)) return;
      seen.add(key);
      items.push({ kind, target, detail });
    };
    nonPassiveListeners.forEach((l) =>
      add("non-passive-listener", `${l.element}${l.id ? `#${l.id}` : ""}`, l.type)
    );
    css.willChangeElements.forEach((e) => add("will-change", e.element, e.value));
    css.overscrollElements.forEach((e) => add("overscroll", e.element, e.value));

    const issues = [];
    if (nonPassiveListeners.length > 0) {
      issues.push({
        severity: "warning",
        message: `${nonPassiveListeners.length} non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript.`,
      });
    }
    if (css.willChangeElements.length > 0) {
      issues.push({
        severity: "info",
        message: `will-change is set on ${css.willChangeElements.length} element(s). Remove it from elements that do not animate, since each one keeps a compositor layer.`,
      });
    }
    return { items: items.slice(0, 50), issues };
  };

  // ─── 4. SUMMARY ─────────────────────────────────────────────────────────────

  window.getScrollSummary = () => {
    console.group(
      "%c📜 Scroll Performance Summary",
      "font-weight: bold; font-size: 14px;"
    );

    // — Session stats
    if (sessions.length === 0) {
      console.log("   No scroll sessions recorded yet. Scroll the page first.");
    } else {
      const allFps = sessions.map((s) => s.avgFps);
      const overallAvg = allFps.reduce((a, b) => a + b, 0) / allFps.length;
      const worstAvg = Math.min(...allFps);
      const totalDrops = sessions.reduce((a, s) => a + s.drops, 0);

      console.log("%cScroll Sessions:", "font-weight: bold;");
      console.table(
        sessions.map((s, i) => ({
          "#": i + 1,
          "Avg FPS": formatFps(s.avgFps),
          "Min FPS": formatFps(s.minFps),
          "Drops": s.drops,
          "Drop Rate": `${s.dropRate.toFixed(1)}%`,
          "Rating": RATING[s.rating].icon + " " + RATING[s.rating].label,
        }))
      );
      console.log(`   Overall avg FPS: ${formatFps(overallAvg)}`);
      console.log(`   Worst session avg: ${formatFps(worstAvg)}`);
      console.log(`   Total frame drops: ${totalDrops}`);
    }

    // — Passive listeners
    console.log("");
    console.log("%cPassive Event Listeners:", "font-weight: bold;");
    if (nonPassiveListeners.length === 0) {
      console.log(
        "   ✅ No non-passive scroll listeners detected since snippet loaded."
      );
    } else {
      console.log(
        `   ❌ ${nonPassiveListeners.length} non-passive listener(s) found:`
      );
      console.table(nonPassiveListeners);
      console.log(
        "   Fix: addEventListener('scroll', fn, { passive: true })"
      );
    }

    // — CSS audit
    console.log("");
    console.log("%cCSS Scroll Audit:", "font-weight: bold;");
    const css = auditScrollCSS();

    if (css.contentVisibilityElements.length > 0) {
      console.log(
        `   ✅ content-visibility on ${css.contentVisibilityElements.length} element(s) — reduces off-screen layout cost.`
      );
    } else {
      console.log(
        "   ℹ️ content-visibility not used — consider it for long pages."
      );
    }

    if (css.smoothScrollElements.length > 0) {
      console.log(
        `   ℹ️ scroll-behavior: smooth on ${css.smoothScrollElements.length} element(s).`
      );
      console.log(
        "      Smooth scroll can cause jank if driven by JS instead of CSS."
      );
    }

    if (css.willChangeElements.length > 0) {
      console.log(
        `   ⚠️ will-change set on ${css.willChangeElements.length} element(s):`
      );
      console.table(css.willChangeElements);
      console.log(
        "      will-change creates compositor layers — use sparingly to avoid memory overhead."
      );
    }

    if (css.overscrollElements.length > 0) {
      console.log(
        `   ℹ️ overscroll-behavior set on ${css.overscrollElements.length} element(s):`
      );
      console.table(css.overscrollElements);
    }

    // — Recommendations
    const hasJank = sessions.some((s) => s.rating !== "good");
    const hasNonPassive = nonPassiveListeners.length > 0;

    if (hasJank || hasNonPassive) {
      console.log("");
      console.log(
        "%c💡 Recommendations:",
        "font-weight: bold; color: #3b82f6;"
      );
      if (hasNonPassive) {
        console.log(
          "   • Add { passive: true } to all scroll/touch event listeners"
        );
      }
      if (hasJank) {
        console.log("   • Avoid reading layout properties during scroll (getBoundingClientRect, offsetTop)");
        console.log("   • Debounce or throttle scroll event handlers");
        console.log("   • Move scroll-driven animations to CSS or use Animation Worklet");
        console.log("   • Use content-visibility: auto for long lists or off-screen sections");
        console.log("   • Prefer transform over top/left for animated elements during scroll");
      }
    }

    console.groupEnd();

    const cssAuditResult = auditScrollCSS();
    const findings = buildFindings(cssAuditResult);
    return {
      script: "Scroll-Performance",
      status: "ok",
      details: {
        nonPassiveListeners: nonPassiveListeners.length,
        cssAudit: {
          smoothScrollElements: cssAuditResult.smoothScrollElements.length,
          willChangeElements: cssAuditResult.willChangeElements.length,
          contentVisibilityElements: cssAuditResult.contentVisibilityElements.length,
          overscrollElements: cssAuditResult.overscrollElements.length,
        },
        sessionCount: sessions.length,
        ...(sessions.length > 0 ? {
          avgFps: Math.round(sessions.reduce((a, s) => a + s.avgFps, 0) / sessions.length),
          worstSessionFps: Math.round(Math.min(...sessions.map((s) => s.avgFps))),
          totalDrops: sessions.reduce((a, s) => a + s.drops, 0),
        } : {}),
      },
      items: findings.items,
      issues: findings.issues,
    };
  };

  // ─── INIT ────────────────────────────────────────────────────────────────────

  console.log(
    "%c📜 Scroll Performance Analysis Active",
    "font-weight: bold; font-size: 14px;"
  );
  console.log(
    "   Scroll to measure FPS. Non-passive listener registrations will be warned."
  );
  console.log(
    "   Call %cgetScrollSummary()%c for the full report.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );

  const cssSnapshot = auditScrollCSS();
  const initialFindings = buildFindings(cssSnapshot);
  return {
    script: "Scroll-Performance",
    status: "tracking",
    details: {
      nonPassiveListeners: nonPassiveListeners.length,
      cssAudit: {
        smoothScrollElements: cssSnapshot.smoothScrollElements.length,
        willChangeElements: cssSnapshot.willChangeElements.length,
        contentVisibilityElements: cssSnapshot.contentVisibilityElements.length,
        overscrollElements: cssSnapshot.overscrollElements.length,
      },
    },
    items: initialFindings.items,
    issues: initialFindings.issues,
    message: "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
    getDataFn: "getScrollSummary",
  };
})();
```

### Understanding the results

### Real-time output

Each scroll session logs:

- Average and minimum FPS with rating indicator
- Total frames measured and number of drops
- Worst frame time compared to the 16.67ms target

Warnings appear immediately when a non-passive scroll listener is registered after the snippet loads.

### Summary function

Call `getScrollSummary()` after scrolling to see:

| Section           | Description                                                  |
| ----------------- | ------------------------------------------------------------ |
| Scroll Sessions   | Per-session FPS, drops, and rating                           |
| Passive Listeners | Non-passive handlers with element and event type             |
| CSS Audit         | `will-change`, `content-visibility`, `scroll-behavior` usage |
| Recommendations   | Targeted fixes based on detected issues                      |

### Key concepts

### Non-passive event listeners

By default, `scroll`, `wheel`, `touchstart`, and `touchmove` listeners can call `preventDefault()` to cancel the scroll. Because the browser cannot know in advance whether your code will call it, it must wait for JavaScript to finish before moving the page, blocking the compositor on every frame.

Marking a listener `passive: true` is a contract: you promise never to call `preventDefault()`. The browser can then move the page immediately on the compositor thread, without touching the main thread at all.

Diagram: Non-passive listeners make the compositor wait for JavaScript on the main thread before moving the page, while passive listeners let the compositor move the page immediately and JavaScript run in parallel

```js
// Slow: browser must wait for JS on every scroll frame
element.addEventListener('scroll', handler)

// Fast: browser scrolls immediately, JS runs independently
element.addEventListener('scroll', handler, { passive: true })
```

### See the frame times

Switch between four ways of handling a scroll and compare the frame times the snippet would record in each one:

Interactive demo: [Interactive bar chart of 60 frame times during one second of scrolling, comparing a passive listener, a non-passive listener, layout reads in the handler and a heavy handler, with the average fps, drops and the snippet log for each](https://webperf-snippets.net/demos/scroll-frames.html). Pick a scenario and press the scroll button to replay the frames.

### Frame drops

A frame drop occurs when the time between two `requestAnimationFrame` callbacks exceeds \~25ms (below 40fps). Common causes during scroll:

| Cause                       | Symptom              | Fix                                      |
| --------------------------- | -------------------- | ---------------------------------------- |
| Layout reads during scroll  | High frame time      | Batch reads with `requestAnimationFrame` |
| Heavy scroll event handlers | Consistent jank      | Throttle or move to worker               |
| Large unvirtualized lists   | Jank on fast scroll  | Virtual scrolling                        |
| Excessive compositor layers | Memory pressure jank | Reduce `will-change` usage               |

### `content-visibility: auto`

Skips rendering work for off-screen content, reducing the amount the browser must recalculate during scroll on long pages. See the [Content Visibility](https://webperf-snippets.net/Loading/Content-Visibility.md) snippet for detailed analysis.

### Browser support

The snippet needs these features to run.

| Feature                                     | Chrome | Edge | Firefox | Safari |
| ------------------------------------------- | :----: | :--: | :-----: | :----: |
| EventTarget.addEventListener.passive option |  ✅ 51  | ✅ 16 |   ✅ 49  |  ✅ 10  |

The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.

| Feature                                                                                    | Chrome | Edge | Firefox | Safari |
| ------------------------------------------------------------------------------------------ | :----: | :--: | :-----: | :----: |
| [will-change](https://developer.mozilla.org/docs/Web/CSS/Reference/Properties/will-change) |  ✅ 36  | ✅ 79 |   ✅ 36  |  ✅ 9.1 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Passive event listeners](https://developer.chrome.com/docs/lighthouse/best-practices/uses-passive-event-listeners) | Chrome Lighthouse
- [content-visibility: the new CSS property that boosts your rendering performance](https://web.dev/articles/content-visibility) | web.dev
- [Avoid large, complex layouts](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing) | web.dev
- [will-change](https://developer.mozilla.org/en-US/docs/Web/CSS/will-change) | MDN
