ReoGrid ReoGrid Web

Rendering 10,000 rows in the browser — how ReoGrid Web stays at 60fps

· unvell team
Rendering 10,000 rows in the browser — how ReoGrid Web stays at 60fps

A user clicks a button. 10,000 rows of data — companies, regions, revenue, growth, status — appear in a spreadsheet. They start scrolling. The frame rate stays at 60fps. They sort by revenue. It re-renders in under 100 ms.

That is the Big Data demo on this site, running live in your browser. This post is about how it works, and why it works.


The DOM is the wrong tool for dense grids

A DOM-based grid has a structural problem: every visible cell is a <div> (or <td>), and the browser’s layout/style/paint pipeline is on the critical path of every scroll and every value change. Virtualized grids paper over this by recycling DOM nodes for visible rows only, which works — until you cross-cut the grid with merged cells, frozen panes, conditional formatting, and per-cell borders. Each layer adds DOM nodes the recycler has to keep in sync.

ReoGrid Web is a single <canvas> element. There is no DOM per cell. The renderer walks the visible rectangle of the worksheet and draws it directly into the 2D context. Scrolling is a redraw, not a reflow. Sorting 10,000 rows and re-rendering them is O(visible cells) regardless of the total row count.


What the Big Data demo actually does

Here is the load path, abridged from big-data.example.ts:

import { createReogrid } from '@reogrid/pro';

const { worksheet: ws } = createReogrid({
  workspace: '#grid',
  licenseKey: 'YOUR_LICENSE_KEY',
});

ws.setGridSize(10_002, 9);            // 10,000 rows + header + buffer

const data: Row[] = generateRows(10_000);

function renderData(rows: Row[]) {
  ws.suspendRender();                  // ← coalesce paints
  rows.forEach((d, i) => {
    const r = i + 1;
    ws.cell(r, 1).setValue(d.name);
    ws.cell(r, 4).setValue(d.revenue.toLocaleString());
    ws.cell(r, 6)
      .setValue(`${d.growth >= 0 ? '+' : ''}${d.growth.toFixed(1)}%`)
      .setStyle({ color: d.growth >= 0 ? '#16a34a' : '#dc2626', bold: Math.abs(d.growth) >= 20 });
    // …other columns
  });
  ws.resumeRender();                   // ← single paint
}

const t0 = performance.now();
renderData(data);
ws.setFrozenRows(1);
console.log(`Initial load: ${(performance.now() - t0).toFixed(0)}ms`);

Two things matter here.

1. suspendRender() / resumeRender()

Without bracketing the bulk write, every setValue schedules a repaint. Even at requestAnimationFrame rates, that is 60 wasted paints per second while the loop is filling. With suspend/resume, the renderer paints exactly once after the loop completes.

2. Style payloads, not DOM mutations

setStyle({ color: '#16a34a' }) updates the cell’s style record. It does not toggle a className, add a node, or touch the DOM. The next paint reads the new color from the record and draws it.

For datasets larger than memorywise reasonable in this pattern, bulkSetCells() is the dedicated bulk-load API and fires onBulkCellsChange exactly once instead of per cell.


The per-frame cost: where v1.2 cut 70% of canvas calls

Drawing 10,000 cells without overhead is necessary but not sufficient. The harder problem is what happens during scroll: the renderer walks the new visible window every frame, and every per-cell call into the canvas API has a measurable cost. ctx.font = '…' triggers font shaping. ctx.measureText() runs a text measurement pass. Both add up fast on a dense sheet.

v1.2 introduced per-frame caches for these. Measured per frame on a 1200×100 sheet (text-heavy, mixed styles):

Canvas APIv1.1v1.2Reduction
ctx.font=12934−74%
ctx.fillStyle=17946−74%
ctx.measureText()12635−72%
save() / restore()12285−30%

The cache key is just the string itself — the renderer skips the assignment when the value would not change. There is also a per-frame cache for wrapped/multi-line text layout that removes a per-token measureText loop that previously ran every frame for every visible wrapped cell. Single-line text takes a fast path that skips the layout cache entirely.

The user-visible effect: scroll frames that previously dropped to 45–50fps on dense sheets stay pegged at 60fps, and CPU utilization on a backgrounded tab drops noticeably.


Sorting 10,000 rows and re-rendering

In the demo, sort is done in JS land and the result is re-rendered via the same renderData() path:

const t0 = performance.now();
const sorted = [...data].sort((a, b) => b.revenue - a.revenue);
renderData(sorted);
const elapsed = performance.now() - t0;
// → ~60–120ms total, includes sort + full re-render

Two reasons this is fast despite re-painting every cell:

  1. The sort is in plain memory. No grid roundtrip, no event ping-pong.
  2. renderData() is bracketed by suspendRender()/resumeRender(), so the bulk-write path runs once.

For sheets where the data lives in the grid itself rather than in a JS array, ReoGrid Web also has a built-in sort API on RangeHandle that operates on the canvas-backed cell store directly.


Frozen rows are free

ws.setFrozenRows(1);

The header stays put while you scroll. There is no perf cost: the renderer already maintains separate viewports for the frozen and scrolling panes (ViewportController), and freezing just toggles which viewport a row belongs to. Same applies to frozen columns and the four-pane frozen split.


What this is not

Honest disclosure: ReoGrid Web is a spreadsheet, not a database table viewer.

  • One million rows is out of scope. The use case is editable, Excel-compatible documents — invoices, budgets, dashboards, configuration sheets, internal tools. If you need to scroll through a billion log lines, you want a different abstraction (e.g. a virtualized table with sticky scroll position).
  • Cells store values and styles, not DOM nodes. If you need to embed full React components inside cells (a date picker, a custom form), ReoGrid’s cell-type registry can host interactive widgets (checkbox, dropdown, button, progress, rating, sparkline, link) but is not a substitute for a DOM-tree-per-cell grid.

For the use case it is built for — Excel-fidelity editing in the browser at interactive speed — it holds 60fps on workloads where DOM-virtualized grids start to fight their own rendering pipeline.


Try it

The Big Data demo loads 10,000 rows live, with sort buttons that include the re-render time in their stopwatch. Click around. Scroll. The DevTools performance panel will tell you the rest.

For your own project:

npm install @reogrid/lite     # free, 100 rows × 26 cols
# or
npm install @reogrid/pro      # full features, including 10k+ row sheets

Next up in this series: how to import a real .xlsx file in the browser without sending it to a server — drag-and-drop, no uploads, full Excel fidelity.

Related articles

Try ReoGrid Web in your project

Canvas-based Excel-compatible spreadsheet component for React and Vue. Lite is free — start with one npm install.

Stay Updated

Be first to know — get updates as they ship

Get notified of new releases, features, and announcements.
No spam — just updates that matter.