Front-end Engineering Lab

Scroll Performance

Achieve buttery smooth scrolling at 60fps

Scroll Performance

Smooth scrolling is critical for user experience. Janky scrolling makes your app feel slow and unresponsive. This guide covers every technique to achieve 60fps scrolling.

Why Scroll Jank Happens

Causes of janky scrolling:
1. Heavy scroll event handlers
2. Forced synchronous layouts
3. Expensive paint operations
4. Too many DOM elements
5. Large images loading
6. JavaScript blocking main thread

Measuring Scroll Performance

FPS During Scroll

let lastTime = performance.now();
let frameCount = 0;

function measureScrollFPS() {
  frameCount++;
  const currentTime = performance.now();
  
  if (currentTime >= lastTime + 1000) {
    const fps = Math.round((frameCount * 1000) / (currentTime - lastTime));
    console.log(`Scroll FPS: ${fps}`);
    
    frameCount = 0;
    lastTime = currentTime;
  }
  
  requestAnimationFrame(measureScrollFPS);
}

window.addEventListener('scroll', () => {
  requestAnimationFrame(measureScrollFPS);
}, { passive: true });

Chrome DevTools

1. Open DevTools
2. Performance tab
3. Enable "Screenshots" and "Web Vitals"
4. Record while scrolling
5. Look for:
   - Long frames (> 16.67ms red bars)
   - Layout thrashing
   - Paint operations

Passive Event Listeners

Mark scroll listeners as passive to prevent blocking.

// ❌ BAD: Blocks scrolling
window.addEventListener('scroll', handleScroll);

// ✅ GOOD: Non-blocking
window.addEventListener('scroll', handleScroll, { passive: true });

// If you need preventDefault (rare):
window.addEventListener('scroll', handleScroll, { passive: false });

Note: Most modern browsers default to passive for scroll/touch events.

Debounce & Throttle

Limit how often scroll handlers run.

Throttle (Regular Intervals)

function throttle<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null;
  let previous = 0;

  return function executedFunction(...args: Parameters<T>) {
    const now = Date.now();
    const remaining = wait - (now - previous);

    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func(...args);
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now();
        timeout = null;
        func(...args);
      }, remaining);
    }
  };
}

// Usage: Run every 100ms
const handleScroll = throttle(() => {
  const scrollY = window.scrollY;
  updateScrollPosition(scrollY);
}, 100);

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

Debounce (After Done Scrolling)

function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout;

  return function executedFunction(...args: Parameters<T>) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
}

// Usage: Run 200ms after scroll stops
const handleScrollEnd = debounce(() => {
  console.log('Scroll ended');
  loadMoreItems();
}, 200);

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

requestAnimationFrame

Best for visual updates.

let ticking = false;

function handleScroll() {
  if (!ticking) {
    requestAnimationFrame(() => {
      // Visual updates here
      updateParallax(window.scrollY);
      ticking = false;
    });
    ticking = true;
  }
}

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

Avoid Layout Thrashing

Don't mix reads and writes to the DOM.

❌ Bad (Layout Thrashing)

// Causes forced reflow on every iteration
function updateElements() {
  elements.forEach(el => {
    const top = el.offsetTop;  // READ (forces layout)
    el.style.top = top + 10 + 'px';  // WRITE
    
    const width = el.offsetWidth;  // READ (forces layout again!)
    el.style.width = width + 5 + 'px';  // WRITE
  });
}

✅ Good (Batch Reads & Writes)

function updateElements() {
  // Batch all reads
  const positions = elements.map(el => ({
    top: el.offsetTop,
    width: el.offsetWidth,
  }));

  // Batch all writes
  elements.forEach((el, i) => {
    el.style.top = positions[i].top + 10 + 'px';
    el.style.width = positions[i].width + 5 + 'px';
  });
}

Virtual Scrolling

Only render visible items.

npm install react-window
import { FixedSizeList } from 'react-window';

function VirtualList({ items }: Props) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
      overscanCount={5}  // Render 5 extra items for smooth scrolling
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].name}
        </div>
      )}
    </FixedSizeList>
  );
}

Variable Size Items

import { VariableSizeList } from 'react-window';

function VirtualVariableList({ items }: Props) {
  // Cache item heights
  const itemHeights = useRef<number[]>([]);

  const getItemSize = (index: number) => {
    return itemHeights.current[index] || 100; // Default height
  };

  return (
    <VariableSizeList
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].content}
        </div>
      )}
    </VariableSizeList>
  );
}

Intersection Observer

Lazy load content as it enters viewport.

function LazyLoadOnScroll({ items }: Props) {
  const [visibleItems, setVisibleItems] = useState<Set<string>>(new Set());

  const observerCallback = useCallback((entries: IntersectionObserverEntry[]) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        setVisibleItems(prev => new Set(prev).add(entry.target.id));
      }
    });
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver(observerCallback, {
      rootMargin: '100px', // Load 100px before visible
      threshold: 0.01,
    });

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

    return () => observer.disconnect();
  }, [observerCallback]);

  return (
    <div>
      {items.map(item => (
        <div key={item.id} id={item.id} data-lazy>
          {visibleItems.has(item.id) ? (
            <ItemContent data={item} />
          ) : (
            <ItemPlaceholder height={item.height} />
          )}
        </div>
      ))}
    </div>
  );
}

content-visibility

Tell browser to skip rendering off-screen content.

.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 400px; /* Estimated height */
}

Benefits:

  • Browser skips layout/paint for off-screen elements
  • Huge performance boost for long lists
  • Automatic with content-visibility: auto
// React component
function ListItem({ children, estimatedHeight = 400 }: Props) {
  return (
    <div
      style={{
        contentVisibility: 'auto',
        containIntrinsicSize: `0 ${estimatedHeight}px`,
      }}
    >
      {children}
    </div>
  );
}

CSS Containment

Isolate layout calculations.

.card {
  contain: layout style paint;
  /* layout: Element's layout doesn't affect outside
     style: Counter/quote doesn't escape
     paint: Painting doesn't escape bounds */
}

/* Strictest containment */
.isolated-component {
  contain: strict;
  /* Equivalent to: layout style paint size */
}

Smooth Scroll Behavior

Native smooth scrolling.

/* Smooth scroll for entire page */
html {
  scroll-behavior: smooth;
}

/* Disable for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}
// Programmatic smooth scroll
window.scrollTo({
  top: 500,
  behavior: 'smooth',
});

// Scroll element into view
element.scrollIntoView({
  behavior: 'smooth',
  block: 'start',
});

Parallax Scrolling

Efficient parallax effect.

function ParallaxSection() {
  const [offsetY, setOffsetY] = useState(0);
  const ticking = useRef(false);

  useEffect(() => {
    const handleScroll = () => {
      if (!ticking.current) {
        requestAnimationFrame(() => {
          setOffsetY(window.scrollY * 0.5); // Parallax factor
          ticking.current = false;
        });
        ticking.current = true;
      }
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div
      style={{
        transform: `translateY(${offsetY}px)`,
        willChange: 'transform',
      }}
    >
      <img src="/background.jpg" alt="Parallax" />
    </div>
  );
}

Scroll Snap

Native smooth scroll snap points.

.scroll-container {
  scroll-snap-type: y mandatory;
  overflow-y: scroll;
  height: 100vh;
}

.scroll-section {
  scroll-snap-align: start;
  height: 100vh;
}
function FullPageScroll({ sections }: Props) {
  return (
    <div 
      style={{
        scrollSnapType: 'y mandatory',
        overflowY: 'scroll',
        height: '100vh',
      }}
    >
      {sections.map(section => (
        <div
          key={section.id}
          style={{
            scrollSnapAlign: 'start',
            height: '100vh',
          }}
        >
          {section.content}
        </div>
      ))}
    </div>
  );
}

Infinite Scroll

function InfiniteScroll({ loadMore, hasMore, isLoading }: Props) {
  const observerTarget = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !isLoading) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => observer.disconnect();
  }, [loadMore, hasMore, isLoading]);

  return (
    <div>
      {/* List items */}
      <div ref={observerTarget} style={{ height: '1px' }} />
      {isLoading && <Spinner />}
    </div>
  );
}

Scroll Restoration

Restore scroll position after navigation.

// Save scroll position
const saveScrollPosition = () => {
  sessionStorage.setItem('scrollY', window.scrollY.toString());
};

// Restore scroll position
const restoreScrollPosition = () => {
  const scrollY = sessionStorage.getItem('scrollY');
  if (scrollY) {
    window.scrollTo(0, parseInt(scrollY, 10));
  }
};

// Usage
window.addEventListener('beforeunload', saveScrollPosition);
window.addEventListener('load', restoreScrollPosition);

Next.js

// app/layout.tsx
export default function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        {children}
      </body>
    </html>
  );
}

// Scroll restoration is automatic in Next.js App Router

Best Practices

  1. Passive Listeners: Always use { passive: true }
  2. Throttle/Debounce: Limit handler frequency
  3. requestAnimationFrame: For visual updates
  4. Virtual Scrolling: For long lists (1000+ items)
  5. Intersection Observer: Lazy load off-screen content
  6. content-visibility: Skip rendering off-screen
  7. CSS Containment: Isolate layout
  8. Batch DOM Operations: Read then write
  9. Transform Over Layout: Use translateY not top
  10. Monitor Performance: Measure FPS in production

Common Pitfalls

Heavy scroll handlers: Blocking main thread
Throttle + requestAnimationFrame

Layout thrashing: Read/write/read/write
Batch: read all, then write all

Rendering everything: 10,000 items in DOM
Virtual scrolling

Animating top/left: Forces layout
Use transform: translateY()

No passive flag: Blocking scrolling
{ passive: true }

Smooth scrolling is essential for a premium feel—optimize it aggressively!

On this page