Optimizing Scroll Performance in React

· 9 min read · Performance

Techniques for optimizing scroll-driven features in React including throttling, passive listeners, and direct DOM updates.

Optimizing Scroll Performance in React

Scroll-heavy pages in React applications can feel sluggish. Scroll handlers that update state cause re-renders, which block the main thread and interrupt smooth scrolling. Understanding when and why this happens is key to building performant scroll experiences.

Problem

Scroll jank manifests as:

  • Visible stutter during scroll
  • Delayed scroll response
  • High CPU usage during scroll
  • Frame drops visible in DevTools Performance tab

In React, these issues often trace back to state updates in scroll handlers or poorly managed IntersectionObserver usage.

Why It Happens

State Updates Block the Main Thread

function Component() {
  const [scrollY, setScrollY] = useState(0);
  
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  
  // Every scroll pixel triggers re-render
  return <div style={{ opacity: scrollY / 1000 }}>...</div>;
}

Each setScrollY call triggers a re-render. For complex component trees, this can take 10-100ms—far longer than the 16ms budget for 60fps.

Layout Thrashing

Reading layout properties inside scroll handlers forces the browser to calculate layout:

const handler = () => {
  elements.forEach(el => {
    const rect = el.getBoundingClientRect(); // Forces layout
    // ... use rect
  });
};

Multiple read-then-write cycles cause layout thrashing.

Too Many Observers

Creating IntersectionObservers for many elements:

function Component({ items }) {
  return items.map(item => (
    <ObservedItem key={item.id} /> // 1000 items = 1000 observers
  ));
}

Each observer has overhead. Hundreds or thousands can degrade performance.

Solution

Use passive event listeners, throttle updates, batch reads with IntersectionObserver, and avoid state updates in hot paths.

Implementation

Use Passive Listeners:

useEffect(() => {
  const handler = () => {
    // Handler code
  };
  
  window.addEventListener('scroll', handler, { passive: true });
  return () => window.removeEventListener('scroll', handler);
}, []);

Passive listeners tell the browser the handler will not call preventDefault(), allowing optimized scroll performance.

Throttle with requestAnimationFrame:

function useScrollPosition() {
  const [scrollY, setScrollY] = useState(0);
  const ticking = useRef(false);
  
  useEffect(() => {
    const updatePosition = () => {
      setScrollY(window.scrollY);
      ticking.current = false;
    };
    
    const handler = () => {
      if (!ticking.current) {
        requestAnimationFrame(updatePosition);
        ticking.current = true;
      }
    };
    
    window.addEventListener('scroll', handler, { passive: true });
    return () => window.removeEventListener('scroll', handler);
  }, []);
  
  return scrollY;
}

This limits updates to once per frame (~60 times per second maximum).

Use CSS for Scroll Effects:

Many scroll effects can be CSS-only:

.parallax {
  transform: translateY(calc(var(--scroll) * 0.5));
}
useEffect(() => {
  const handler = () => {
    document.documentElement.style.setProperty(
      '--scroll', 
      window.scrollY.toString()
    );
  };
  window.addEventListener('scroll', handler, { passive: true });
  return () => window.removeEventListener('scroll', handler);
}, []);

CSS transitions and transforms are GPU-accelerated and do not trigger React re-renders.

Example

Performant scroll progress indicator:

function ReadingProgress() {
  const progressRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    const updateProgress = () => {
      if (!progressRef.current) return;
      
      const scrollTop = window.scrollY;
      const docHeight = document.documentElement.scrollHeight - window.innerHeight;
      const progress = (scrollTop / docHeight) * 100;
      
      // Direct DOM manipulation - no React re-render
      progressRef.current.style.width = `${progress}%`;
    };
    
    window.addEventListener('scroll', updateProgress, { passive: true });
    return () => window.removeEventListener('scroll', updateProgress);
  }, []);
  
  // Single render, then updated via DOM
  return (
    <div className="progress-container">
      <div ref={progressRef} className="progress-bar" style={{ width: '0%' }} />
    </div>
  );
}

TIP: For scroll-driven animations that do not affect other components, update the DOM directly instead of updating state. This skips React's reconciliation entirely.

Common Mistakes

Not cleaning up listeners

useEffect(() => {
  window.addEventListener('scroll', handler);
  // Missing cleanup!
}, []);

Always return a cleanup function:

return () => window.removeEventListener('scroll', handler);

Heavy computation in scroll handlers

const handler = () => {
  items.forEach(item => {
    // Complex calculation per item
  });
};

Move heavy computation outside the handler:

const processedItems = useMemo(() => {
  return items.map(item => /* complex calculation */);
}, [items]);

const handler = () => {
  // Use processedItems
};

Observing too many elements

Solution: Observe a few sentinel elements instead:

// Instead of observing every item
items.forEach(item => observer.observe(item));

// Observe navigation points only
const sentinels = document.querySelectorAll('[data-section-start]');
sentinels.forEach(el => observer.observe(el));

WARNING: IntersectionObserver has per-entry overhead. For large lists, observe only a subset of elements and interpolate positions for others.

Forgetting will-change

For elements that animate during scroll:

.animated-on-scroll {
  will-change: transform; /* Hints browser to optimize */
}

Use sparingly—too many will-change declarations consume memory.

Conclusion

Optimize scroll performance by using passive event listeners, throttling state updates with requestAnimationFrame, and manipulating DOM directly for visual-only changes. Replace scroll listeners with IntersectionObserver where possible, and observe only sentinel elements for large collections.