Skip to content

Scroll Performance and Passive Listeners

advanced10 min read

Why Scrolling Should Be the Easiest Thing to Get Right

Scrolling is the most frequent user interaction on any page. Your users scroll thousands of pixels per session. At 60fps, the browser recomposes the page 60 times per second during a scroll. If any frame takes longer than 16.6ms, the user perceives jank — a stutter that feels broken.

And yet, scroll jank is everywhere. Parallax effects, lazy loading, infinite scroll, sticky headers — each is a common source of scroll performance problems. Understanding why requires knowing how the browser handles scroll events internally. Spoiler: it's simpler than you think, and the fix is often one line.

Mental Model

Imagine scrolling a page as flipping through a stack of pre-photographed transparencies. The compositor thread flips them at 60fps — it's fast because no work is needed per flip. Now imagine that before each flip, someone in the back room (the main thread) must check whether to cancel the flip. The flipper has to wait. If the back room is slow (heavy JavaScript), flips stall — that's scroll jank. Passive listeners tell the flipper: "don't wait for the back room — just flip."

The Compositor Thread and Scroll Events

In modern browsers, scrolling is handled by the compositor thread — not the main thread. The compositor can scroll the page by moving layer positions without any JavaScript or layout work. This is why an empty page scrolls perfectly even if the main thread is completely blocked by JavaScript.

But here's the catch — and this is the part most people miss: touchstart, touchmove, wheel, and scroll event listeners run on the main thread. If you attach a listener to any of these events, the compositor must wait for the main thread to process the event before it can update the scroll position — because the listener might call event.preventDefault() to cancel the scroll.

Without scroll listeners:
  Compositor: [scroll][scroll][scroll][scroll] → smooth 60fps

With a non-passive scroll listener:
  Compositor: [wait...][scroll][wait......][scroll] → janky
              ↑ waiting for main thread to NOT call preventDefault()

This single fact — that non-passive scroll listeners force the compositor to wait for the main thread — is the root cause of most scroll jank.

Passive Event Listeners

The fix is beautifully simple. A passive listener tells the browser: "I will never call preventDefault() on this event. Don't wait for me."

// BAD: Compositor must wait for this listener before scrolling
window.addEventListener('scroll', handleScroll);

// BAD: Even if you never call preventDefault(), the browser doesn't know that
window.addEventListener('touchmove', handleTouchMove);

// GOOD: Passive — compositor scrolls immediately without waiting
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('touchmove', handleTouchMove, { passive: true });
Chrome's default behavior

Chrome (and most modern browsers) now treat touchstart and touchmove listeners on document and window as passive by default. But listeners on other elements, and wheel events, may not be passive by default. Always explicitly set { passive: true } when you don't need preventDefault().

Common Trap

If you add { passive: true } and then call event.preventDefault() inside the handler, the browser silently ignores the preventDefault() call and logs a console warning. Your scroll cancelation code simply won't work. If you genuinely need to prevent scroll (e.g., a custom drag handler), you must use { passive: false } and accept the performance cost.

Scroll Event Handlers Are Too Frequent

Even with passive listeners, there's another problem. The scroll event fires at the browser's rendering frequency — potentially 60 or even 120 times per second. Running expensive logic on every single one is a guaranteed jank source.

Throttle: Execute at Most Once Per Interval

function throttle(fn, limit) {
  let lastCall = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

// Execute at most once per 100ms (10fps) instead of 60fps
window.addEventListener(
  'scroll',
  throttle(() => {
    updateScrollProgress();
  }, 100),
  { passive: true }
);

Debounce: Execute After Scrolling Stops

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Only fire after user stops scrolling for 150ms
window.addEventListener(
  'scroll',
  debounce(() => {
    saveScrollPosition();
  }, 150),
  { passive: true }
);

requestAnimationFrame Throttle: Execute Once Per Frame

This is the best option when you need visual updates — it naturally syncs with the browser's paint cycle:

let ticking = false;

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateParallax();
      ticking = false;
    });
    ticking = true;
  }
}

window.addEventListener('scroll', onScroll, { passive: true });

This guarantees at most one execution per frame — exactly when the browser needs the result.

Intersection Observer: The Scroll Event Replacement

Okay, here's the real answer for most cases. For lazy loading images, infinite scroll triggers, "is this element visible?" checks — you don't need scroll events at all. IntersectionObserver does it better, and it's not even close.

// BAD: Scroll event for lazy loading — runs 60x/sec, reads getBoundingClientRect
window.addEventListener('scroll', () => {
  images.forEach(img => {
    const rect = img.getBoundingClientRect(); // Forced layout!
    if (rect.top < window.innerHeight) {
      img.src = img.dataset.src;
    }
  });
}, { passive: true });

// GOOD: IntersectionObserver — browser-optimized, no forced layout
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  }
}, { rootMargin: '200px' }); // Start loading 200px before visible

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

Why IntersectionObserver is better:

  1. No forced layout — the browser tracks intersection internally without getBoundingClientRect()
  2. Batched callbacks — the observer fires once with all changed entries, not per element
  3. Runs asynchronously — doesn't block the main thread during scroll
  4. rootMargin — preload content before it's visible, eliminating pop-in
IntersectionObserver under the hood

IntersectionObserver is implemented in the browser's rendering pipeline, not in JavaScript. During the compositing step, the browser already knows which layers are visible at which positions. The observer piggybacks on this information — it doesn't need to run JavaScript or force layout to determine visibility. This is why it's dramatically more efficient than scroll-event-based visibility detection. The callback fires between frames, during idle time, so it never blocks rendering.

Production Scenario: The Parallax That Killed Mobile

A landing page had a parallax scrolling effect. On desktop, smooth. On mobile, 12fps jank.

// The problematic code
window.addEventListener('scroll', () => {
  const scrollY = window.scrollY;
  hero.style.transform = `translateY(${scrollY * 0.5}px)`;
  clouds.style.transform = `translateY(${scrollY * 0.3}px)`;
  mountains.style.transform = `translateY(${scrollY * 0.1}px)`;
});

Three problems:

  1. Non-passive listener — compositor waited for main thread on every scroll
  2. window.scrollY forced a layout recalculation on every scroll event
  3. Three style writes per scroll event

The fix:

// Use CSS scroll-driven animations (modern browsers)
@keyframes parallax-hero {
  from { transform: translateY(0); }
  to { transform: translateY(50vh); }
}

.hero {
  animation: parallax-hero linear;
  animation-timeline: scroll();
  animation-range: 0vh 100vh;
}

For older browsers:

let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      const scrollY = window.scrollY;
      hero.style.transform = `translateY(${scrollY * 0.5}px)`;
      clouds.style.transform = `translateY(${scrollY * 0.3}px)`;
      mountains.style.transform = `translateY(${scrollY * 0.1}px)`;
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });

Result: Mobile went from 12fps to 60fps.

What developers doWhat they should do
Attach scroll listeners without { passive: true }
Non-passive scroll listeners force the compositor to wait for the main thread, causing jank
Always use { passive: true } unless you need preventDefault()
Use scroll events for lazy loading or visibility detection
IntersectionObserver runs in the browser's rendering pipeline — no forced layout, no main thread blocking
Use IntersectionObserver for element visibility
Run expensive logic directly in scroll handlers
Scroll events fire 60-120 times/sec. rAF naturally limits to one execution per paint frame
Throttle with requestAnimationFrame — at most one execution per frame
Call getBoundingClientRect() in scroll handlers
getBoundingClientRect() forces synchronous layout — calling it 60x/sec during scroll is layout thrashing
Use IntersectionObserver or cache dimensions and compute from scrollY offset
Quiz
A scroll event listener calls event.preventDefault() to implement custom scroll behavior. Can you make it passive to improve performance?
Quiz
You need to trigger an animation when an element enters the viewport during scroll. Which approach is best?
Key Rules
  1. 1Non-passive scroll/touch/wheel listeners force the compositor to wait for the main thread — causing jank.
  2. 2Always use { passive: true } for scroll, touchstart, touchmove, and wheel listeners unless you need preventDefault().
  3. 3Use IntersectionObserver instead of scroll events for visibility detection, lazy loading, and infinite scroll triggers.
  4. 4Throttle scroll handlers with requestAnimationFrame — at most one execution per paint frame.
  5. 5Never call getBoundingClientRect() or read layout properties inside a scroll handler — it forces synchronous layout.
  6. 6CSS scroll-driven animations (animation-timeline: scroll()) are the ultimate solution — zero JavaScript, compositor-only.