Skip to content

Performance — 1M Rows

witqq spreadsheet renders a million rows with the same performance as a hundred. Click the button below to load 1,000,000 rows and scroll through them.

1,000,000 Rows Demo
Canvas virtual scrolling renders only visible rows — constant 60 FPS regardless of dataset size.
1,000,000
rows rendered at 60 FPS
Only visible rows are drawn on canvas. Scroll through a million rows with zero jank.

How It Works

witqq spreadsheet uses canvas-based virtual scrolling — only the rows visible in the viewport are rendered on each frame. The engine calculates which rows are visible based on scroll position and row heights, then draws only those cells on a single <canvas> element.

The ProgressiveLoaderPlugin streams data in time-budgeted chunks usingscheduler.yield() (with MessageChannel fallback). Each chunk runs for ~50ms then yields to the browser. The table is interactive immediately — you can scroll, sort, and filter while remaining data loads. A progress overlay shows loading status.

Why it's fast:

  • O(viewport) rendering — drawing cost is proportional to visible rows (~30-50), not total rows (1M)
  • Canvas 2D API — GPU-accelerated text and shape rendering, no DOM node creation per cell
  • Float64Array layout — cumulative row positions in typed arrays for O(1) cell rect lookups and O(log n) scroll-to-row
  • rAF coalescing — multiple changes within a frame produce a single render, via requestAnimationFrame
  • Text measurement cache — LRU cache (10K entries) avoids redundant ctx.measureText() calls
  • Progressive loading — data streams in chunks without blocking the UI thread

The data array itself lives in memory (~200-400MB for 1M rows), but rendering performance is independent of dataset size. Scroll at any speed — the frame budget stays under 16ms.

Traditional DOM-based tables create one <tr> and multiple <td> elements per row. At 1M rows that’s 7M+ DOM nodes — browsers cannot handle this. Even with virtual scrolling (creating/destroying DOM nodes on scroll), the constant DOM manipulation creates jank.

witqq spreadsheet takes a different approach: a single <canvas> element where only visible rows (~30-50) are drawn each frame. The rendering cost is O(viewport size), not O(total rows).

The data array for 1M rows with 7 columns uses approximately 200-400MB of JavaScript heap memory. The rendering engine itself adds negligible overhead — only the visible viewport state is tracked per frame.

import { WitTable } from '@witqq/spreadsheet-react';
import { ProgressiveLoaderPlugin } from '@witqq/spreadsheet-plugins';
const columns = [
{ key: 'id', title: 'ID', width: 80, type: 'number' as const },
{ key: 'name', title: 'Name', width: 200 },
{ key: 'value', title: 'Value', width: 120, type: 'number' as const },
];
function App() {
const ref = useRef(null);
useEffect(() => {
const engine = ref.current?.getInstance();
if (!engine) return;
const loader = new ProgressiveLoaderPlugin({
totalRows: 1_000_000,
chunkBudgetMs: 50,
columnKeys: columns.map(c => c.key),
generateRow: (i) => ({
id: i + 1,
name: `Row ${i + 1}`,
value: Math.random() * 1000,
}),
onComplete: (ms) => console.log(`Loaded in ${ms}ms`),
});
engine.installPlugin(loader);
loader.start(); // Table renders immediately, data streams in
}, []);
return (
<WitTable
ref={ref}
columns={columns}
data={[]}
showRowNumbers
style={{ width: '100%', height: '600px' }}
/>
);
}

The ProgressiveLoaderPlugin loads data in time-budgeted chunks using scheduler.yield() (Chrome 129+) or MessageChannel fallback, showing an animated progress overlay. The table is interactive immediately — scroll and navigate while data loads.

witqq spreadsheet includes two benchmark utilities for measuring performance:

Measure execution time of a factory function:

import { measureInitTime } from '@witqq/spreadsheet';
const { timeMs } = measureInitTime(() => {
const engine = new WitEngine(config);
engine.mount(container);
});
console.log(`Init: ${timeMs.toFixed(1)}ms`);

Measure scroll/render FPS over a window:

import { FPSCounter, type FPSResult } from '@witqq/spreadsheet';
const counter = new FPSCounter();
counter.start();
// Call tick() on each requestAnimationFrame
function loop() {
counter.tick();
if (measuring) requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// After measurement window
const result: FPSResult = counter.stop();
console.log(`Avg FPS: ${result.avgFPS.toFixed(1)}`);
console.log(`Min FPS: ${result.minFPS.toFixed(1)}`);
console.log(`Max FPS: ${result.maxFPS.toFixed(1)}`);
console.log(`Frames: ${result.frameCount}`);

FPSResult fields:

FieldTypeDescription
avgFPSnumberAverage FPS during measurement
minFPSnumberMinimum FPS recorded
maxFPSnumberMaximum FPS recorded
frameCountnumberNumber of frames sampled
durationMsnumberTotal measurement duration in ms