A deep dive into the architecture decisions, bugs, and breakthroughs behind building a high-performance data grid from scratch.
When I started building EliteGrid — a TypeScript data grid library for React and Vue — I knew virtual scrolling would be the hardest part to get right. Not because the concept is complex, but because the details are brutal.
This is the story of how we built it, what broke along the way, and what the final architecture looks like.
What Is Virtual Scrolling (and Why Does It Matter)?
The naive approach to rendering a data grid is simple: loop over your data and render a row for each item. For 100 rows, this works fine. For 10,000 rows, your browser starts to sweat. For 1,000,000 rows, the page freezes entirely.
The DOM is expensive. Each row is a collection of elements, event listeners, and style computations. Browsers aren't designed to handle hundreds of thousands of DOM nodes simultaneously.
Virtual scrolling solves this by only rendering the rows currently visible on screen — plus a small buffer above and below. As the user scrolls, rows are recycled: off-screen rows are removed and new ones are inserted in their place.
The result: 1,000,000 rows in the dataset, but only ~30 rows in the DOM at any time.
The Basic Architecture
At its core, virtual scrolling needs three things:
- A scrollable container with a fixed height
-
A spacer element whose height equals
totalRows × rowHeight— this creates the illusion of a full list -
A visible window that calculates which rows to render based on
scrollTop
function calculateVisibleRows(
scrollTop: number,
containerHeight: number,
rowHeight: number,
totalRows: number,
bufferSize: number = 5
) {
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - bufferSize);
const visibleCount = Math.ceil(containerHeight / rowHeight);
const endIndex = Math.min(totalRows - 1, startIndex + visibleCount + bufferSize * 2);
return { startIndex, endIndex };
}
Simple enough. But this is where "simple" ends.
Bug #1: The Blank Screen on Fast Scroll
Our first implementation worked perfectly at normal scroll speeds. But when users scrolled fast — flicking the trackpad or dragging the scrollbar — they'd see a blank white screen for a split second before rows appeared.
The cause took us a while to find.
We were using requestAnimationFrame (rAF) to batch DOM updates and avoid layout thrashing. The scroll handler looked like this:
// ❌ BROKEN
containerRef.addEventListener('scroll', (event) => {
const scrollTop = (event.target as HTMLElement).scrollTop; // captured here
requestAnimationFrame(() => {
updateVisibleRows(scrollTop); // used here — but scrollTop is already stale!
});
});
The problem: by the time the rAF callback fired, the user had already scrolled further. We were rendering rows for a scroll position that no longer existed.
The fix was simple but non-obvious:
// ✅ FIXED
containerRef.addEventListener('scroll', () => {
requestAnimationFrame(() => {
const scrollTop = containerRef.current.scrollTop; // read INSIDE the rAF
updateVisibleRows(scrollTop);
});
});
By reading scrollTop inside the rAF callback instead of the scroll event, we always get the current scroll position at paint time. Blank screen gone.
Bug #2: O(n²) Row ID Lookups Killing Performance
EliteGrid's plugin system needs to map between rendered rows and their data source IDs constantly — for selection, editing, focus management, and export.
Our original approach was a linear scan:
// ❌ O(n) lookup, called hundreds of times per scroll
function getRowId(data: RowData): string {
return rows.find(row => row.data === data)?.id ?? '';
}
With 10,000 rows and plugins calling this on every scroll event, we were doing millions of comparisons per second. The grid would visibly lag on mid-range hardware.
The fix was a WeakMap reverse index built alongside the row model:
// ✅ O(1) lookup
class RowModelPlugin {
private dataToId = new WeakMap<RowData, string>();
setRows(rows: Row[]) {
this.dataToId = new WeakMap();
for (const row of rows) {
this.dataToId.set(row.data, row.id);
}
}
getRowId(data: RowData): string {
return this.dataToId.get(data) ?? '';
}
}
WeakMap is perfect here — it doesn't prevent garbage collection of row data objects when rows are recycled, and lookups are O(1). Scroll performance went from janky to buttery smooth.
The Plugin Architecture That Makes It All Work
One thing that makes EliteGrid different from other grids is its microkernel + plugin architecture.
The core grid kernel does almost nothing by itself. It manages a central event bus and a plugin registry. Every feature — virtual scrolling, selection, editing, sorting, export — is a plugin that:
- Registers with the kernel on mount
- Subscribes to events it cares about
- Emits events when state changes
// Simplified plugin interface
interface GridPlugin {
name: string;
init(kernel: GridKernel): void;
destroy(): void;
}
// Example: ScrollPlugin subscribes to scroll events
class ScrollPlugin implements GridPlugin {
name = 'scroll';
init(kernel: GridKernel) {
kernel.on('SCROLL', ({ scrollTop }) => {
const { startIndex, endIndex } = calculateVisibleRows(scrollTop, ...);
kernel.emit('VISIBLE_ROWS_CHANGED', { startIndex, endIndex });
});
}
destroy() {
// cleanup
}
}
This means the virtual scrolling engine doesn't know anything about selection or editing — and vice versa. Plugins are testable in isolation, and adding new features doesn't touch existing code.
Performance Numbers
Running our playground at elitegrid.dev/playground with different dataset sizes:
| Dataset Size | DOM Nodes | Initial Render | Scroll FPS |
|---|---|---|---|
| 1,000 rows | ~30 | < 50ms | 60fps |
| 10,000 rows | ~30 | < 80ms | 60fps |
| 100,000 rows | ~30 | < 100ms | 60fps |
| 1,000,000 rows | ~30 | < 120ms | 60fps |
The DOM node count stays constant regardless of dataset size. That's the whole point.
What's Next
EliteGrid is launching on npm at the end of June with:
- React Vue 3 and JavaScript adapters
- Full TypeScript types
- Virtual scrolling, inline editing, sorting, filtering, CSV export If you want to try it before launch, the playground is live at elitegrid.dev/playground — no install needed.
And if you're building something with large datasets and want to give feedback, I'd genuinely love to hear from you. Drop a comment below or find me on Twitter at @elitegridhq.
For further actions, you may consider blocking this person and/or reporting abuse
