y: idx % 3 }
}));
### 2. Container & Synthetic Spacer
Create a fixed-height scroll container. Inside it, place a single empty element whose height equals `totalRows Γ itemHeight`. This spacer forces the browser to calculate scrollbar dimensions as if every row existed, while remaining completely inert to layout engines.
```css
.grid-viewport {
position: relative;
height: 600px;
overflow-y: auto;
contain: strict; /* Isolates layout/paint from parent */
}
.grid-sizer {
position: relative;
width: 100%;
/* Height set dynamically via JS */
}
3. Viewport Arithmetic & Positioning
With fixed-height items, the relationship between scroll offset and data index is deterministic. No measurement or DOM querying is required.
const ITEM_HEIGHT = 40;
const OVERSCAN_COUNT = 4;
function calculateVisibleRange(scrollTop: number, containerHeight: number) {
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT);
const endIndex = startIndex + visibleCount;
return {
start: Math.max(0, startIndex - OVERSCAN_COUNT),
end: Math.min(dataSource.length - 1, endIndex + OVERSCAN_COUNT)
};
}
Each rendered item receives an absolute top value calculated as index Γ ITEM_HEIGHT. The container uses position: relative, allowing items to stack correctly within the synthetic spacer.
4. Optimized Render Loop
Scroll events fire at hardware refresh rates, often exceeding 60 times per second. Rebuilding the DOM on every tick causes severe jank. Two optimizations are mandatory:
- rAF Gating: Collapse scroll bursts into a single frame update.
- Dirty Checking: Only trigger DOM writes when the visible index range actually changes.
class WindowedRenderer {
private viewportEl: HTMLElement;
private sizerEl: HTMLElement;
private itemPool: Map<number, HTMLElement> = new Map();
private isTicking = false;
private lastRenderedStart = -1;
constructor(viewport: HTMLElement) {
this.viewportEl = viewport;
this.sizerEl = document.createElement('div');
this.sizerEl.className = 'grid-sizer';
this.sizerEl.style.height = `${dataSource.length * ITEM_HEIGHT}px`;
this.viewportEl.appendChild(this.sizerEl);
this.viewportEl.addEventListener('scroll', this.handleScroll, { passive: true });
this.syncViewport();
}
private handleScroll = () => {
if (this.isTicking) return;
this.isTicking = true;
requestAnimationFrame(() => {
this.syncViewport();
this.isTicking = false;
});
};
private syncViewport() {
const { start, end } = calculateVisibleRange(
this.viewportEl.scrollTop,
this.viewportEl.clientHeight
);
if (start === this.lastRenderedStart) return;
this.lastRenderedStart = start;
this.renderWindow(start, end);
}
private renderWindow(start: number, end: number) {
const fragment = document.createDocumentFragment();
const currentIndices = new Set<number>();
for (let i = start; i <= end; i++) {
currentIndices.add(i);
let row = this.itemPool.get(i);
if (!row) {
row = document.createElement('div');
row.className = 'grid-row';
row.style.height = `${ITEM_HEIGHT}px`;
row.style.position = 'absolute';
row.style.left = '0';
row.style.right = '0';
this.itemPool.set(i, row);
}
row.style.top = `${i * ITEM_HEIGHT}px`;
row.textContent = dataSource[i].label;
fragment.appendChild(row);
}
// Detach rows that scrolled out of view
for (const [idx, el] of this.itemPool) {
if (!currentIndices.has(idx)) {
el.remove();
this.itemPool.delete(idx);
}
}
this.sizerEl.replaceChildren(fragment);
}
}
Architecture Rationale
- Object Pooling (
itemPool): Reusing DOM nodes eliminates allocation/deallocation churn. The browser's garbage collector stays idle, preventing micro-stutters during rapid scrolling.
- DocumentFragment: Batching DOM insertions into a single fragment reduces reflow triggers from
N to 1.
contain: strict: CSS containment isolates the viewport from parent layout calculations, preventing expensive cascade recalculations when items move.
- Passive Scroll Listener: Signals to the browser that
preventDefault() won't be called, enabling scroll optimization on mobile and touch devices.
Pitfall Guide
1. Pixel-Level Recalculation
Explanation: Triggering layout updates on every scroll pixel forces the engine to recalculate positions even when the visible index range hasn't changed.
Fix: Implement a dirty check comparing Math.floor(scrollTop / itemHeight) against the previous render. Only update DOM when the integer boundary crosses.
2. Missing Overscan Buffer
Explanation: Rendering exactly the visible rows leaves a blank gap during fast flicks. The browser paints the scroll frame before the JavaScript handler mounts newly exposed rows.
Fix: Add 3β6 extra rows to both ends of the render range. The memory cost is negligible, but the perceived smoothness improves dramatically.
3. Accessibility Blind Spots
Explanation: Screen readers and browser search (Ctrl+F) only interact with existing DOM nodes. A windowed list of 20 items appears to contain only 20 records.
Fix: Apply aria-rowcount="${totalRecords}" to the container and aria-rowindex="${actualIndex}" to each row. For complex grids, maintain a visually hidden but accessible DOM tree that syncs with the virtualized state.
4. Variable Height Assumption
Explanation: Applying fixed-height math to dynamic content breaks index-to-pixel mapping. You cannot calculate scrollTop / height when heights differ.
Fix: Either enforce strict height constraints via CSS, or implement a cumulative offset array that measures rendered items, caches heights, and uses binary search to resolve scroll position to index.
5. Node Allocation Churn
Explanation: Creating and destroying elements on every scroll tick generates massive GC pressure. Memory spikes and drops cause frame drops.
Fix: Maintain a fixed-size pool of reusable elements. Update their top, textContent, and data attributes instead of recreating them.
6. Container Resize Ignorance
Explanation: If the viewport height changes (e.g., sidebar toggle, window resize), the visible row count changes, but the render loop continues using stale dimensions.
Fix: Attach a ResizeObserver to the container. Recalculate visibleCount and trigger a sync when dimensions shift.
7. Framework State Desynchronization
Explanation: In React/Vue/Angular, virtualization logic often conflicts with component lifecycle reconciliation. State updates inside scroll handlers can trigger unnecessary framework re-renders.
Fix: Isolate virtualization logic outside the framework's render cycle. Use refs to manipulate DOM directly, and only sync framework state when selection or focus changes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Fixed-height rows, <100k items | Vanilla windowing (custom) | Minimal overhead, full control, zero dependencies | $0 (dev time only) |
| Variable heights, complex cells | TanStack Virtual / react-window | Handles measurement, caching, and binary search internally | Low (library bundle ~3-5kb gzipped) |
| Real-time streaming data | Infinite scroll + windowing | Prevents unbounded memory growth while keeping UI responsive | Medium (requires pagination API) |
| Enterprise data grids (sorting/filtering) | Ag-Grid / Handsontag | Built-in virtualization, keyboard nav, a11y, and state management | High (licensing or heavy bundle) |
Configuration Template
// virtual-grid.config.ts
export const VirtualGridConfig = {
itemHeight: 48,
overscan: 5,
containerHeight: 640,
enableResizeObserver: true,
accessibility: {
role: 'grid',
ariaLabel: 'Data viewport',
announceRowCount: true
},
performance: {
usePassiveScroll: true,
batchInsertions: true,
enableDirtyCheck: true
}
} as const;
// Usage hook (framework-agnostic)
export function initializeVirtualGrid(container: HTMLElement, data: unknown[]) {
const renderer = new WindowedRenderer(container);
renderer.bindData(data);
if (VirtualGridConfig.enableResizeObserver) {
const observer = new ResizeObserver(() => renderer.syncViewport());
observer.observe(container);
}
return renderer;
}
Quick Start Guide
- Create the viewport: Add a
div with fixed height and overflow-y: auto. Set position: relative and contain: strict.
- Inject the sizer: Append an empty child element. Set its height to
data.length * itemHeight via JavaScript.
- Wire the scroll handler: Attach a passive scroll listener that triggers
requestAnimationFrame. Inside the callback, calculate startIndex and endIndex using viewport math.
- Render the window: Loop from
startIndex - overscan to endIndex + overscan. Position each item absolutely using top: index * itemHeight. Reuse DOM nodes from a pool to avoid allocation churn.
- Validate: Open browser DevTools β Performance tab. Scroll rapidly and verify that DOM node count stays flat, main thread remains idle, and FPS holds at 60. Check accessibility tree to confirm
aria-rowcount reflects total data length.