HomeFeaturesDrawDemoDocumentationPricingContact

Can a Browser Really Handle 1 Million Rows?

A huge spreadsheet with a speedometer at maximum streaming a 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.

The threshold sneaks upA DOM-table grid feels fine in development with sample data and falls over the first time a real customer loads their actual dataset. By then it is in production and the fix is an architecture change, not a tuning pass. Choose a scalable renderer before you ship.

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?

AspectDOM tableVirtualized DOMCanvas
Nodes in the documentOne per cellOne per visible cellOne canvas total
Render cost scales withTotal datasetVisible rowsVisible viewport
Realistic row ceilingA few thousandTens of thousandsHundreds of thousands+
StylingNative CSSNative CSSDrawn by hand
AccessibilityBuilt inBuilt inMust be reconstructed
Fast-scroll behaviorJankyMount/unmount churnSteady redraw
Best forTiny tablesLists, read-mostly gridsSpreadsheet-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:

  1. 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.
  2. 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.
Separate the model from the viewKeep the data model independent of whatever is painted. The renderer should ask the model for the cells in the current viewport and nothing more. That boundary is what lets the view stay constant-cost while the model grows without limit.

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.

Render and edit hundreds of thousands of rows smoothly — canvas rendering and worker-based recalc, free developer tier.Get Started Free

Read More

Frequently Asked Questions

Have questions about WorksheetJs? Find answers to the most common questions about licensing, integration, and features.

Yes, if you don't render them all. A DOM table with one element per cell collapses well before a million rows. Row virtualization (render only what's visible) plus canvas rendering and a model/view split let a browser scroll and edit a million rows smoothly.

Each cell becomes a real DOM node, so memory and layout/paint cost grow with the data, not the viewport. At tens of thousands of rows the browser spends all its time in layout and the tab stutters or crashes. Canvas rendering avoids per-cell DOM entirely.

Render only visible cells (virtualization) and paint them to a canvas rather than the DOM, while keeping the data in a separate in-memory model. This separates the size of the dataset from the cost of rendering it.

It uses canvas rendering with a model/view separation, so only the visible region is painted regardless of dataset size — keeping scrolling and editing smooth past 100k+ rows.

Build your spreadsheet with WorksheetJS

550+ formulas, an AI copilot, charts and pivots — drop a full spreadsheet into your app. Free dev tier, no credit card.

Get Started Free
Illustration of a spreadsheet panel with a donut chart and AI copilot bubble