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 threadMeasuring 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 operationsPassive 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-windowimport { 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 RouterBest Practices
- Passive Listeners: Always use
{ passive: true } - Throttle/Debounce: Limit handler frequency
- requestAnimationFrame: For visual updates
- Virtual Scrolling: For long lists (1000+ items)
- Intersection Observer: Lazy load off-screen content
- content-visibility: Skip rendering off-screen
- CSS Containment: Isolate layout
- Batch DOM Operations: Read then write
- Transform Over Layout: Use
translateYnottop - 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!