Can a Browser Really Handle 1 Million Rows?

A million rows is well within Excel's comfort zone, yet most browser grids stall at a few thousand. The reason is architectural: a DOM table allocates one element per cell, so the browser drowns in layout and paint work. To hit that scale in the browser you need one of two strategies — row virtualization or canvas rendering — plus disciplined memory layout and recalculation that never blocks the main thread.
Excel's grid is a million rows by sixteen thousand columns, and nobody thinks twice about scrolling it. Open the same dataset in a typical web grid and the tab freezes. The gap is not the data — it is how the browser is asked to draw it. Understanding exactly where a naive grid falls over is the key to building one that does not.
This post walks through why a DOM-table grid collapses at scale, the two rendering strategies that survive it, how to lay out a million rows in memory, and how to keep formula recalculation from locking the UI. The goal throughout is a grid that scrolls and edits smoothly — targeting 60 FPS — as the dataset grows.
Why does a DOM-table grid collapse at scale?
An HTML table is the obvious starting point, and it is exactly the wrong one at scale. Every cell becomes a real DOM node — a `td`, often wrapping an `input` or a `div`. A million rows by twenty visible columns is twenty million nodes. The browser cannot survive that for three compounding reasons:
- Memory: each DOM node carries layout boxes, style data, and event-listener bookkeeping. Twenty million of them exhausts the tab's heap long before the data itself would.
- Layout: the engine must compute geometry for every node. Table layout is also interdependent — column widths depend on content across all rows — so a single edit can trigger a full reflow.
- Paint and style recalc: scrolling or editing invalidates large regions, and the browser repaints far more than the few rows actually on screen.
The symptom developers notice first is that initial render takes seconds and the tab is unresponsive while it builds. The deeper problem is that even after it loads, every interaction pays the layout-and-paint tax across the entire node tree. You cannot optimize your way out of this with `will-change` hints or debouncing — the node count itself is the bottleneck.
Strategy 1 — Row virtualization
Virtualization (also called windowing) keeps the DOM but renders only the rows that are actually visible. The dataset stays in JavaScript memory; the DOM holds a small, constant window — say the fifty rows in the viewport plus a small overscan buffer. A spacer element fakes the full scroll height so the scrollbar behaves as if all million rows were present.
// Map a scroll position to the visible window of rows.
function visibleWindow(scrollTop: number, viewportH: number, opts: {
rowHeight: number;
total: number;
overscan?: number;
}) {
const { rowHeight, total, overscan = 8 } = opts;
const first = Math.floor(scrollTop / rowHeight);
const count = Math.ceil(viewportH / rowHeight);
const start = Math.max(0, first - overscan);
const end = Math.min(total, first + count + overscan);
// Only rows [start, end) are mounted; the rest are pure data.
return { start, end, offsetY: start * rowHeight };
}Because the mounted node count is constant, render cost no longer scales with the dataset. Virtualization is well understood, it preserves normal DOM semantics (CSS, accessibility, focus), and it is a good fit for list-like or read-mostly grids.
Its limits show up under spreadsheet-style use. Fast scrolling forces constant mount and unmount churn, which can blow the frame budget and flash blank rows. Variable row heights require a measurement cache. And you still pay DOM costs per visible cell, which matters when a viewport shows hundreds of cells with rich formatting. Virtualization moves the ceiling far higher; it does not remove it.
Strategy 2 — Canvas rendering
Canvas rendering abandons per-cell DOM entirely. The grid is a single `canvas` element, and cells are painted directly with drawing calls — fill the background, stroke the gridlines, draw the text. There is no node per cell, so there is no layout tree to maintain and no style recalculation to trigger. On each frame you redraw only the visible region, which is bounded by the viewport rather than the dataset.
// Paint only the cells in view. Cost scales with the viewport,
// not with the number of rows in the sheet.
function paintViewport(ctx: CanvasRenderingContext2D, view: GridView) {
const { startRow, endRow, startCol, endCol } = view.visibleRange();
for (let r = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
const cell = view.getCell(r, c); // O(1) lookup, no DOM
const { x, y, w, h } = view.rect(r, c);
ctx.fillStyle = cell.fill;
ctx.fillRect(x, y, w, h);
if (cell.text) ctx.fillText(cell.text, x + 4, y + h / 2);
}
}
}This is the approach WorksheetJS takes. By painting cells directly to a canvas, it sidesteps DOM overhead entirely and keeps scrolling and editing smooth — targeting 60 FPS on hundreds of thousands of rows. The editor itself is a single overlaid input element positioned over the active cell, so the cost of editing is one node, not a node per cell.
Canvas is not free of work. You re-implement what the browser used to give you: text metrics and clipping, selection highlighting, hit-testing for clicks, and an accessibility layer (since screen readers cannot see canvas pixels, you expose the active region through ARIA on the overlay). The payoff is that none of these scale with row count — they all scale with what is on screen.
Which rendering strategy should you choose?
| Aspect | DOM table | Virtualized DOM | Canvas |
|---|---|---|---|
| Nodes in the document | One per cell | One per visible cell | One canvas total |
| Render cost scales with | Total dataset | Visible rows | Visible viewport |
| Realistic row ceiling | A few thousand | Tens of thousands | Hundreds of thousands+ |
| Styling | Native CSS | Native CSS | Drawn by hand |
| Accessibility | Built in | Built in | Must be reconstructed |
| Fast-scroll behavior | Janky | Mount/unmount churn | Steady redraw |
| Best for | Tiny tables | Lists, read-mostly grids | Spreadsheet-scale editing |
How do you store a million rows in memory?
Rendering is only half the problem; the data has to live somewhere too. A million rows held as an array of plain objects, one property per column, is a heap allocation per cell and a garbage-collection liability. Two principles keep memory in check:
- Store sparsely. Real sheets are mostly empty. Keep populated cells in a map keyed by coordinate rather than a dense two-dimensional array, so an empty million-row sheet costs almost nothing and memory tracks actual content.
- Use typed structures for hot paths. Column-oriented storage with typed arrays for numeric data is compact and cache-friendly, and it gives the recalculation engine tight, predictable loops instead of chasing object pointers.
How do you recalculate without blocking the UI?
A grid that scrolls smoothly can still feel broken if editing one cell freezes the page while thousands of dependent formulas recompute. Recalculation is CPU-bound, and the main thread is also responsible for input, scrolling, and painting. Run a large recalc on the main thread and you drop frames precisely when the user is interacting.
The fix is to move formula calculation off the main thread into a Web Worker. The UI thread stays free to paint and handle input; the worker computes the dependency graph and returns results, which the grid then repaints. WorksheetJS runs its formula calculation in a Web Worker for exactly this reason — so a heavy recalc never blocks scrolling or typing.
// Main thread: hand recalculation to a worker, keep the UI responsive.
const calc = new Worker(new URL("./calc.worker.ts", import.meta.url), {
type: "module",
});
function onCellEdit(ref: string, value: string) {
model.set(ref, value); // update the model immediately
calc.postMessage({ type: "recalc", changed: ref });
}
calc.onmessage = (e) => {
if (e.data.type === "result") {
model.applyResults(e.data.cells); // patch computed values
grid.invalidateViewport(); // repaint only what's on screen
}
};Two refinements make this robust. Recalculate only the dirty subgraph — the cells downstream of the edit — rather than the whole sheet, using the dependency graph to bound the work. And repaint only the visible region when results return, so even a large recalc costs a single viewport redraw on the UI thread.
How do you hold 60 FPS while editing and scrolling?
Sixty frames per second leaves roughly sixteen milliseconds per frame for everything the main thread does. Hitting that consistently at scale comes down to a few habits that follow naturally from the architecture above:
- Bound per-frame work to the viewport. Whether virtualized or canvas, never touch a cell that is not on screen during a frame.
- Drive redraws from `requestAnimationFrame`, not from raw scroll or input events, so paints align with the display refresh and never stack up.
- Keep CPU-heavy work — recalculation, parsing, import — off the main thread in a worker.
- Avoid per-frame allocation in the render loop; reuse buffers and objects so the garbage collector does not pause you mid-scroll.
Performance at a million rows is not a tuning pass you apply at the end — it is decided by the rendering and memory architecture you choose on day one. Pick the wrong foundation and no amount of micro-optimization rescues it.
Conclusion
Handling a million rows in the browser is an architecture problem, not a hardware one. A DOM table cannot get there because its cost scales with the data. Virtualization pushes the ceiling into the tens of thousands; canvas rendering, paired with sparse typed storage and worker-based recalculation, pushes it into the hundreds of thousands while keeping a smooth frame rate. The browser is fully capable of spreadsheet-scale data — you just have to stop asking it to lay out twenty million nodes.


