Skip to Content
InteractionScroll Performance

Scroll performance analysis

Chrome 51+ Edge 16+ Firefox 49+ Safari 10+ Details

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

ProblemDetection Method
Non-passive scroll listenersMonkey-patches addEventListener to catch future registrations
Frame drops during scrollMeasures requestAnimationFrame timing during active scroll
CSS scroll configurationAudits will-change, content-visibility, scroll-behavior

FPS thresholds

RatingFPSFrame timeExperience
Good≥ 55 fps≤ 18msSmooth
Needs improvement40–54 fps18–25msMinor jank
Poor< 40 fps> 25msVisible 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

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Scroll Performance Analysis
// https://webperf-snippets.nucliweb.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:

SectionDescription
Scroll SessionsPer-session FPS, drops, and rating
Passive ListenersNon-passive handlers with element and event type
CSS Auditwill-change, content-visibility, scroll-behavior usage
RecommendationsTargeted 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.

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 parallelConnections: User scrolls to Wait for JS (main thread); Wait for JS (main thread) to Compositor moves page; Compositor moves page to Frame rendered; User scrolls to JS runs in parallel (main thread); User scrolls to Compositor moves page (immediately); Compositor moves page (immediately) to Frame rendered; JS runs in parallel (main thread) to Frame rendered.Non-passive (default)passive: trueUser scrollsWait for JS(main thread)Compositor moves pageFrame renderedUser scrollsCompositor moves page(immediately)JS runs in parallel(main thread)Frame rendered
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 parallelConnections: User scrolls to Wait for JS (main thread); Wait for JS (main thread) to Compositor moves page; Compositor moves page to Frame rendered; User scrolls to JS runs in parallel (main thread); User scrolls to Compositor moves page (immediately); Compositor moves page (immediately) to Frame rendered; JS runs in parallel (main thread) to Frame rendered.Non-passive (default)passive: trueUser scrollsWait for JS(main thread)Compositormoves pageFrame renderedUser scrollsCompositormoves page(immediately)JS runs inparallel(main thread)Frame rendered
// 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:

Pick a scenario and press the scroll button to replay the frames. Open demo in a new tab ↗

Frame drops

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

CauseSymptomFix
Layout reads during scrollHigh frame timeBatch reads with requestAnimationFrame
Heavy scroll event handlersConsistent jankThrottle or move to worker
Large unvirtualized listsJank on fast scrollVirtual scrolling
Excessive compositor layersMemory pressure jankReduce 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 snippet for detailed analysis.

Browser support

The snippet needs these features to run.

FeatureChromeEdgeFirefoxSafari
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.

FeatureChromeEdgeFirefoxSafari
will-change (opens in a new tab) 36 79 36 9.1

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb