s. The buffer size is a trade-off between memory and perceived smoothness.
3. RequestAnimationFrame sync: Direct scroll event listeners fire at inconsistent intervals. Wrapping calculations in requestAnimationFrame throttles updates to the display refresh rate (typically 60Hz), eliminating redundant computations.
4. Measurement cache for dynamic sizes: Fixed-height lists are trivial. Real-world content varies. A ResizeObserver-driven cache stores measured heights, enabling accurate offset calculation without layout thrashing.
Step-by-Step Implementation (TypeScript)
1. Core Interfaces & Configuration
export interface VirtualScrollerOptions {
itemCount: number;
itemSize: number | ((index: number) => number);
containerHeight: number;
overscanCount?: number;
onVisibleRangeChange?: (start: number, end: number) => void;
}
export interface VisibleRange {
start: number;
end: number;
offsetTop: number;
}
2. Core Calculation Engine
export class VirtualScrollerEngine {
private options: Required<VirtualScrollerOptions>;
private measuredSizes: Map<number, number> = new Map();
private totalHeight: number = 0;
constructor(options: VirtualScrollerOptions) {
this.options = {
overscanCount: 3,
onVisibleRangeChange: () => {},
...options,
};
this.calculateTotalHeight();
}
private getItemSize(index: number): number {
if (typeof this.options.itemSize === 'function') {
return this.measuredSizes.get(index) ?? this.options.itemSize(index);
}
return this.options.itemSize;
}
private calculateTotalHeight(): void {
let height = 0;
for (let i = 0; i < this.options.itemCount; i++) {
height += this.getItemSize(i);
}
this.totalHeight = height;
}
public getVisibleRange(scrollTop: number): VisibleRange {
const { itemCount, overscanCount, containerHeight } = this.options;
// Binary search or iterative offset calculation
// For fixed sizes, O(1) math applies. For dynamic, we use accumulated offsets.
const fixedSize = typeof this.options.itemSize === 'number';
let start = 0;
let accumulated = 0;
if (fixedSize) {
start = Math.floor(scrollTop / (this.options.itemSize as number));
} else {
// Dynamic: iterate until accumulated >= scrollTop
for (let i = 0; i < itemCount; i++) {
const size = this.getItemSize(i);
if (accumulated + size > scrollTop) {
start = i;
break;
}
accumulated += size;
}
}
const end = Math.min(
itemCount,
start + Math.ceil(containerHeight / (fixedSize ? (this.options.itemSize as number) : 50)) + overscanCount * 2
);
const clampedStart = Math.max(0, start - overscanCount);
const clampedEnd = Math.min(itemCount, end + overscanCount);
return {
start: clampedStart,
end: clampedEnd,
offsetTop: this.getOffsetTop(clampedStart),
};
}
private getOffsetTop(index: number): number {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += this.getItemSize(i);
}
return offset;
}
public updateSize(index: number, size: number): void {
if (this.measuredSizes.get(index) === size) return;
this.measuredSizes.set(index, size);
this.calculateTotalHeight();
}
public getTotalHeight(): number {
return this.totalHeight;
}
}
3. React Integration Pattern
import { useRef, useEffect, useState, useCallback } from 'react';
interface VirtualListProps<T> {
items: T[];
itemSize: number | ((index: number) => number;
containerHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
export function VirtualList<T>({ items, itemSize, containerHeight, renderItem }: VirtualListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const engineRef = useRef(new VirtualScrollerEngine({
itemCount: items.length,
itemSize,
containerHeight,
}));
const [visibleRange, setVisibleRange] = useState<VisibleRange>({ start: 0, end: 0, offsetTop: 0 });
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
const range = engineRef.current.getVisibleRange(scrollTop);
setVisibleRange(range);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let rafId: number;
const onScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(handleScroll);
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
cancelAnimationFrame(rafId);
};
}, [handleScroll]);
const { start, end, offsetTop } = visibleRange;
const totalHeight = engineRef.current.getTotalHeight();
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetTop}px)`, willChange: 'transform' }}>
{items.slice(start, end).map((item, i) => (
<div key={start + i} style={{ contain: 'strict' }}>
{renderItem(item, start + i)}
</div>
))}
</div>
</div>
</div>
);
}
Architecture Notes:
contain: strict on rendered items isolates layout/paint/composite calculations, preventing cross-item reflow.
willChange: transform hints the browser to promote the layer early, avoiding first-scroll jank.
- The engine separates calculation from rendering, enabling framework-agnostic reuse and server-side precomputation.
Pitfall Guide
-
Omitting the overscan buffer: Fast scroll gestures outpace DOM updates. Without 2-3 extra items above/below the viewport, users see blank spaces during momentum scrolling. Fix: Always calculate start - overscan and end + overscan, but clamp to valid index bounds.
-
Hardcoding dynamic item sizes: Assuming uniform heights in content-rich lists (avatars, wrapped text, async images) causes vertical misalignment and scrollbar jitter. Fix: Implement a measurement cache. Render items initially with estimated height, use ResizeObserver to capture actual dimensions, and update the engine cache. Debounce cache updates to prevent render loops.
-
Binding scroll events without RAF throttling: Scroll events fire at hardware-dependent rates (often 120Hz+). Running layout calculations on every tick blocks the main thread. Fix: Wrap scroll handlers in requestAnimationFrame. Use passive listeners to avoid blocking the compositor. Cancel pending frames on rapid scroll to prevent calculation backlog.
-
Ignoring container resize: Window resizing, sidebar toggles, or responsive breakpoints change containerHeight, breaking viewport calculations. Fix: Attach a ResizeObserver to the container. Recalculate visible range and update internal dimensions immediately. Debounce if resize triggers frequently.
-
Neglecting keyboard navigation and focus management: Virtual scrolling destroys DOM nodes outside the viewport, breaking Tab order, Enter selection, and screen reader traversal. Fix: Maintain a logical focus index. Use tabIndex="-1" on non-focused items and tabIndex="0" on the focused item. Implement arrow key handlers that update scroll position and focus programmatically. Add role="list" and role="listitem" with aria-setsize and aria-posinset.
-
Confusing virtual scrolling with infinite scroll: Infinite scroll fetches data incrementally; virtual scrolling manages DOM rendering. They solve different problems. Using infinite scroll alone still renders accumulated DOM nodes, eventually causing the same performance collapse. Fix: Combine them if needed. Use virtual scrolling for DOM management and infinite scroll for data pagination. Keep the virtual window size constant regardless of total fetched items.
-
Applying CSS transforms incorrectly: Using transform on individual items instead of the viewport wrapper multiplies GPU layers and causes compositing overhead. Fix: Apply transform: translateY() only to the single viewport container. Keep items statically positioned within it. Avoid inline styles for positioning; use CSS variables or computed classes when possible.
Production Best Practices:
- Pre-warm the size cache with API metadata if available (e.g.,
estimatedHeight from backend).
- Use
IntersectionObserver for lazy image loading inside virtual items to prevent memory spikes.
- Batch DOM updates: never update the engine cache synchronously during render; schedule via microtask or RAF.
- Monitor
Long Animation Frames in Chrome DevTools to detect scroll calculation bottlenecks.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Fixed-height list (logs, settings) | O(1) math offset calculation | Predictable layout, minimal JS overhead | Low dev time, minimal memory |
| Dynamic-height list (social feed, chat) | ResizeObserver + measurement cache | Handles variable content without reflow | Medium dev time, cache memory overhead |
| Data grid/table with columns | Column-based virtualization + row recycling | Maintains alignment while scrolling horizontally | High complexity, requires custom layout engine |
| Mobile/low-end devices | Reduced overscan (1 item) + simplified cache | Limits GPU layer count and JS execution | Slightly more flicker, but preserves 60fps on constrained hardware |
Configuration Template
// virtual-scroller.config.ts
export interface VirtualScrollerConfig {
itemCount: number;
containerHeight: number;
itemSize: number | ((index: number) => number);
overscanCount: number;
useResizeObserver: boolean;
debounceCacheMs: number;
enableAccessibility: boolean;
onVisibleRangeChange?: (start: number, end: number) => void;
}
export const defaultConfig: Partial<VirtualScrollerConfig> = {
overscanCount: 3,
useResizeObserver: true,
debounceCacheMs: 50,
enableAccessibility: true,
};
export function createConfig(overrides: Partial<VirtualScrollerConfig>): VirtualScrollerConfig {
return { ...defaultConfig, ...overrides } as VirtualScrollerConfig;
}
Quick Start Guide
- Install dependencies: No external packages required. The core engine uses native browser APIs. If using React/Vue, ensure your bundler supports TypeScript and modern ES modules.
- Copy the engine and integration code: Paste
VirtualScrollerEngine and the framework wrapper into your components directory. Export interfaces for type safety.
- Configure sizing strategy: Pass
itemSize as a number for fixed lists, or a function returning estimated heights for dynamic content. Set containerHeight to match your UI layout.
- Mount and verify: Render the component inside a parent with explicit height. Open Chrome DevTools Performance tab, scroll rapidly, and confirm DOM node count stays constant and FPS remains ≥55.
- Enable dynamic measurement: If content varies, attach
ResizeObserver to rendered items, call engine.updateSize(index, measuredHeight), and verify scrollbar behavior stabilizes after initial load.