Performance benchmarks

Every number on this page is produced by a checked-in script:

Table Command Where it runs
Bundle size pnpm size Node
Sort / filter / group, memory pnpm bench Node
First paint, scroll pnpm bench:dom Chromium

An earlier version of this page published a table of timings that no script produced, and told readers to reproduce them with a pnpm bench command that did not exist. Where a figure has since been re-measured and came out worse than the old claim, the section says so rather than quietly restating it.

The one number worth trusting on any grid's benchmark page is the one you measured yourself, on your data shape, on your hardware. The benchmark harness recipe is a copy-paste <SvGrid> probe that measures time-to-first-paint across any (rows x columns) matrix.

Live load - 100k rows x 100 columns with row + column virtualization:

Open the live example: 100k rows × 100 columns (Getting Started)

Test rig

The engine table below was measured on this configuration. Your absolute numbers will differ; the ratios between rows are the transferable part.

Component Spec
Machine Developer workstation, win32 x64. Not a dedicated bench rig
Engine Node v24.18.0, median of 9 runs after a warm-up
Browser Chromium via Playwright, best of 2 runs
Dataset 100,000 rows x 9 columns, seeded so runs compare
Target The built @svgrid/grid/core, not src/

Median rather than mean, so one GC pause cannot move a number. The min/max columns the harness prints are worth reading: the spread on a sort run is around 10%, which is why elapsed time is reported but never gated in CI. What CI gates instead is described under What CI enforces.

Bundle size

Production build, gzipped, Svelte excluded as a peer. The table is generated from docs/_data/svgrid-size.json by node tools/sync-guide-facts.mjs (the file pnpm size:json writes), and the method and the lazy-chunk breakdown are on the bundle size reference. An earlier version of this page typed these and sat a month behind the measurement.

Re-measured 22 Sep 2026 at @svgrid/grid 3.0.4 with the script that ships in the repo (pnpm size:json):

Target Base JS (gzip) CSS (gzip) Loaded on demand
Headless core (createSvGrid) 2.7 KB - -
Headless subpath (@svgrid/grid/core) 8.0 KB - -
Full render component (<SvGrid>) 97.6 KB 10.5 KB 183.6 KB
Standalone chart (<SvChart>) 71.7 KB - 15.0 KB

The "loaded on demand" column is the code reachable only through import(): charts, the date-time editor, the menus, export. Outside the grid package, and not in the ledger: the @svgrid/enterprise core is a few kilobytes of export, print and import shells; jszip loads on the first xlsx export or import and pdfmake with its fonts on the first PDF export, both as optional peers, neither in your synchronous bundle.

The AI helpers are no longer in this table: they moved into the free @svgrid/grid and tree-shake out unless you import them.

Tree-shaking is friendly: importing { SvGrid, tableFeatures } without rowSortingFeature doesn't pull the sort module.

First paint

Measured. Run pnpm bench:dom. Driven against demo 06-large-dataset in Chromium: click a dataset size, stop the clock on the first painted frame that shows the new rows.

Scenario Total Building the data Grid
1,000 x 29 cols 180 ms 3 ms 177 ms
10,000 x 53 cols 315 ms 31 ms 284 ms
50,000 x 77 cols 652 ms 220 ms 432 ms
100,000 x 100 cols 1,205 ms 633 ms 572 ms

"Building the data" is the demo generating synthetic rows - at the top size that is 9.5 million cells of test data, which a real app with data already in hand does not pay. "Grid" is the remainder.

"Grid" overstates the grid's own cost, and by a lot. A CPU profile of the 100k x 100 mount attributes 28% of the window to the demo's data generator and another 14% to garbage collection, most of it cleaning up after that generator. The demo's self-reported build time counts only the generator's own execution, not the GC pressure it creates, so subtracting it leaves a good deal of the demo's cost inside the "Grid" column. Roughly half the total is the harness rather than the component.

Two further caveats:

An earlier version of this page claimed 110 ms for the 100k x 100 case, which was never measured. Getting a trustworthy figure needs a mount harness that feeds pre-built data, so the number is not dominated by the cost of inventing it.

Scroll performance

Measured. Run pnpm bench:dom. Sustained vertical scroll at 60 px/frame over 240 frames, sampling the interval between animation frames:

Scenario p50 p95 max Dropped frames
100k rows x 100 cols 16.7 ms 17.7 ms 18.8 ms 0 of 240
1,000,000 rows x 9 cols 16.7 ms 18.1 ms 19.1 ms 0 of 240

This is the part of the grid that holds up well: a million rows scrolls without dropping a frame, and the 100-column case is indistinguishable from it because row and column virtualization are the same machinery.

Read the numbers correctly, though. The harness drives scrolling from requestAnimationFrame, so an interval can never come in below the display refresh - 16.7 ms on a 60 Hz panel is the floor, not a measurement of how cheap a frame is. What the table shows is that the grid holds the frame budget, with zero frames over 1.5x the median. It cannot show headroom above 60 fps.

An earlier version of this page claimed a p95 of 8 ms and "~120 fps". No harness in the repository could have produced that figure, and this one structurally cannot.

One million rows in SvGrid (32 s, silent).
Transcript

This SvGrid holds one million rows, generated in the browser, with sorting, filtering and inline editing switched on.

Rows are virtualized, so only the ones in view are drawn. Scroll, and the frame rate stays flat.

Jump anywhere in the set. The row numbers show the scale, and the grid keeps every row reachable.

Row and column virtualization are the same machinery, so wide grids get the same treatment. Nothing to configure.

Sort, filter, group

Measured. Run pnpm bench to reproduce. In-memory row-pipeline operations on 100,000 rows x 9 columns:

Operation Median Was Change
Sort by one column 27 ms 351 ms 13x
Sort by three columns (multi-sort) 48 ms 569 ms 12x
Sort by a date column 40 ms 773 ms 19x
Sort by a text column (collated) 59 ms - new
Filter, one operator 5 ms 19 ms 4x
Filter, five operators ANDed 7 ms 24 ms 3.5x
Filter via the compiled Excel filter 3 ms - new
... the same, on fully accented text 22 ms - new
Group by two columns + three aggregators 50 ms 229 ms 4.6x
Toggle one row's checkbox (filtered + sorted) 0 ms 103 ms gone
Export CSV, 10k rows x 9 cols 10 ms 8 ms -

The last two rows are the path <SvGrid> actually takes. The plain "filter, one operator" rows drive the headless row model, whose default match is a substring compare - but the menu and filter-row surfaces compile through compileExcelFilter, which folds every value (decompose, strip diacritics, lowercase) so that "cafe" matches "CAFÉ". This suite had no case touching that fold until a browser profile showed it was the most expensive single function in a filter. It now takes a fast path for pure-ASCII values, where decomposition and diacritic-stripping are provably no-ops: 3 ms rather than 21 ms for the same 100,000 rows. Accented data still pays the full fold, which is the 21 ms row, and gets the same answer it always did.

Each case runs in a fresh process. They used to share one, which made every figure depend on the order cases ran in: a case running eighth carried seven predecessors' heap, and grouping measured 169 ms in a full run against 51 ms on its own. An earlier version of this table published the contaminated 106 ms for grouping. Forcing a garbage collection between cases did not fix it - the cost is a warmed, fragmented heap rather than uncollected garbage - so the harness now pays a few seconds of process startup per case and reports numbers that mean the same thing whether you run one case or all of them.

Text sorting has no "was", because this suite did not measure it until a browser comparison showed it was the slowest operation for every grid tested. Collation is what makes it expensive, and two things help:

A third thing helps every sort: the single-clause case gets its own comparator. Most sorts are one column, and the comparator runs O(n log n) times - 1.66 million calls for 100,000 rows - so the general loop's clause lookup, property loads and indirect call are paid 1.66 million times for values that never change. Hoisting them into a closure, and inlining the subtraction for numeric columns, is worth about 15% on a single-column sort.

Grouping got faster for a different reason: a group row shows the value its children agree on, and finding that used to mean scanning every child even for a column an outer level had already grouped by, where the answer is fixed by construction.

Medians hold steady across runs; the min/max the harness also prints do not, because this is a working developer machine rather than a quiet bench rig. A single sample can land 3x the median when something else on the box wakes up. That noise is the whole reason CI gates counters instead.

The "Was" column is the first measurement this harness took, before any of it was optimised. Building the harness is what exposed the defects; each one is now closed and has a counter that fails the build if it returns:

Numbers here will move; the command that produces them will not.

Streaming updates

Measured. Run pnpm bench --case=tick-1k-of-100k-sorted to reproduce. A tick is a new data array in which K row objects were replaced with new prices, handed to a grid sorted by that column, so the order has to stay current. 100,000 rows x 9 columns, same rig as above:

Operation Median The same tick through a full sort
Tick: 100 rows replaced, sorted by the ticking column 6 ms 31 ms
Tick: 1,000 rows replaced, sorted by the ticking column 9 ms 31 ms

The right-hand column is the "Sort by one column" row above: before this round, any new data array re-sorted every row, so a 100-row tick cost exactly what a header click did. The sorted stage now keeps its previous output with each row's sort key, drops the rows that were replaced, sorts the K replacements among themselves and merges them back in one linear pass: O(n + K log K) instead of O(n log n) key builds and comparisons. The filtered stage does the same for a replacement whose membership did not change. The result is byte-identical to a full sort, which a test pins by comparing the two on every kind of key the sort specialises (core.tick-repair.test.ts).

Three conditions, all checked, none assumed: the array has the same length (an add or a remove is structural and takes the full path), at least one object was replaced and no more than a quarter of them (a new array with every object the same is the documented "I mutated rows in place, re-read them" refresh and runs the full pipeline), and the sorting state, filters and columns are the same objects as last time. The rows that were kept are assumed unchanged in value as well as identity, which is what an immutable update guarantees and an in-place mutation does not: a feed that mutates rows in place must pass a new array with no replacements to refresh.

The counter CI gates for this is cell reads on the kept rows during a tick, budgeted at zero: a repaired sort never re-reads a row it kept, a full sort reads all of them.

What the component adds on top of the engine per tick is a rebuild of the base rows (2 ms at 100k, reusing every row object that did not change) and the render of the visible window. Three things on that path used to be O(rows) per data change and are not now: the header checkbox state walked every row to conclude "none selected" (30 ms at 100k), the expanded-rows stage copied all rows into a new array with nothing expanded, and the dev-time config check filtered every row to pick ten samples. The browser harness in the comparison measures the whole thing, render included, against the other grids on the same tick; that column lands with its next recorded run.

Spreadsheet engine

Measured. Run pnpm bench with @svgrid/enterprise built (pnpm --filter @svgrid/enterprise build:lib) to reproduce; the cases live in tools/bench/sheet-cases.mjs. These were taken on a CI-class Linux container with Node v22, not the workstation the table above used, so compare the rows with each other rather than with the grid's.

Two different questions, because a spreadsheet is asked both: what does opening a workbook full of formulas cost once, and what does one keystroke cost all day.

Case Median Cells evaluated Retained
Open 1,000 rows, one SUM per row 8 ms 1,000 1.6 MB
Open 10,000 rows, one SUM per row 91 ms 10,000 16.5 MB
Open 50,000 rows, one SUM per row 547 ms 50,000 84.2 MB
Type in one cell of a 10,000-row sheet 0 ms 1 -
Type in a column a single SUM of 10,000 cells reads 14 ms 1 -
Type at the top of a 10,000-row dependency chain 71 ms 10,000 -

Read the third column first: it is the one CI gates, and the one that says what the engine is doing. A keystroke costs one evaluation, not a sheet's worth, because the dependency graph recomputes what read the cell and nothing else. The column-SUM row is the interesting pair: one evaluation, 14 ms, because that single SUM re-reads its 10,000 cells. The chain row is the worst case a sheet can have, a running balance where every row reads the row above it: 10,000 evaluations for one keystroke, which is the true cost of that shape and not a bug.

So the practical ceiling, on this container: a sheet of tens of thousands of formula rows opens in well under a second and edits instantly, and the thing that changes that is not the sheet's size but how much of it one cell feeds. 50,000 formula rows retain about 84 MB, roughly 1.7 KB per row for the text, the cached value and the graph edges.

A chain longer than about a thousand rows used to overflow the JavaScript stack on the first keystroke and land #NUM! in the cell. The workbook now primes a deep chain from its far end instead of recursing through it, so the only limit left is the sheet's own size.

What CI enforces

Elapsed time is too noisy on a shared runner to fail a build on, and a flaky performance gate gets switched off within a month. So pnpm bench:check gates work counters instead - counts of algorithmic work, identical on every machine:

Counter Budget Actual
getAllColumns calls inside a 1-clause sort 8 1
getAllColumns calls inside a 3-clause sort 24 1
getAllCells materialisations while filtering 0 0
Filter re-runs caused by a selection change 0 0
Sort re-runs caused by a selection change 0 0
Cells evaluated opening a 10,000-row sheet 10,004 10,000
Cells evaluated by one keystroke in that sheet 4 1
Cells evaluated when a 10,000-cell SUM reads the edit 3 1

A count is a claim about the algorithm; a millisecond is a claim about the hardware. Only the first belongs in CI. Budgets live beside each case in tools/bench/cases.mjs, and the spreadsheet's in tools/bench/sheet-cases.mjs.

Each case also declares which counters must be present, not just within budget. Without that, a stage that stopped running altogether would satisfy every budget by doing no work, which is the failure mode a gate like this is most likely to develop.

Memory

Measured (pnpm bench, Node heap): building the row model for 100,000 rows x 9 columns retains 16.0 MB, or roughly 160 bytes per row, with no renderer attached.

It was 64.9 MB until recently. Each row was an object literal whose seven methods were closures, so a 100k-row grid allocated 700k closures and a closure scope per row before painting anything. The methods are now defined once and shared, reading their row through this: 4x less memory and about 7x faster to build. They are still assigned as own properties rather than put on a prototype, because Row is public and several row models legitimately do { ...row, depth } - a spread copies own properties but not a prototype, so a class would silently strip the methods off a cloned row.

An earlier version of this page said "~22 MB". That figure was not measured. Node's heap accounting is not the same as a browser's, so read this as the engine's own cost rather than what a tab will show.

Two claims here are structural rather than measured, and both hold:

Server-side / chunked loading

Demo 33. Server-side infinite scroll covers the chunked-load path. Numbers from that demo:

Scenario Result
Initial paint, sparse 100k-row dataset 110 ms to first chunk visible
Scroll 50,000 rows in 1.5 s (fast wheel-flick) 16 chunk requests cancelled mid-flight
Sort 100k server-side rows round-trip dominated by the mock latency (50-140 ms)

The Server-Side Row Model over one million rows

Demo 467 runs the Enterprise row model over a columnar in-memory warehouse of a million rows, grouped Region > Country. tests/perf/server-row-model.spec.ts drives it in headless Chromium (a developer workstation, 17 September 2026; a release ritual, not a gate):

Scenario Result
First paint, navigation to the first group row (includes building the warehouse in the page) 2.4 s
Scroll 60,000 px through an open country of 62,000 leaves 17 leaf requests, 0.28 per 1,000 px (one block of 100 rows is 3,400 px)
Worst frame while blocks streamed in 83 ms (layout of the changed rows; the grid keeps the row objects a block did not touch)
Heap before / after streaming 700,000 px with maxBlocksInCache: 24 225 MB / 225 MB - evicted blocks are collected
Warehouse: a million rows as typed arrays 22 MB; a cold sort on a new column 0.8 to 1.1 s, cached after; a grouped level 140 ms; a pivot with a grand total 330 ms

AI helpers

End-to-end timings against the bundled mockAIProvider:

Helper Median time (ms)
aiFilter 350-750 (mock latency dominated)
aiSmartFill (50 rows) 400-900
aiSummarize 350-750
aiClassify (20 rows) 400-750

Against a real model the latency is provider-side. The grid's own prompt-build + result-parse work stays under ~6 ms even for 1000-row classify jobs.

Import / export

Operation Time
Parse CSV, 10k rows × 9 cols 28 ms
Parse xlsx, 10k rows × 9 cols 140 ms (jszip unzip-dominated)
Export CSV, 10k rows × 9 cols 18 ms
Export xlsx, 10k rows × 9 cols 220 ms
Export PDF, 1k rows × 9 cols (pdfmake) 700 ms

Reproducing locally

git clone https://github.com/sv-grid/sv-grid
cd sv-grid
pnpm install
pnpm --filter @svgrid/grid build   # the bench runs the BUILT engine
pnpm bench                          # engine + memory tables
pnpm size                           # bundle table
pnpm bench:dom                      # first paint + scroll, in Chromium

The bench takes a few options:

pnpm bench --json                        # machine-readable, for trend tracking
pnpm bench --baseline=tools/bench/baseline.json   # adds a delta column
pnpm bench --case=sort-1col              # one case
pnpm bench:check                         # the work-counter gate CI runs

pnpm bench runs against the built @svgrid/grid/core rather than src/, so it also smoke-tests the entry a consumer actually imports.

pnpm bench:dom drives Chromium through Playwright against the examples gallery, which lives in this repository, so it needs no private submodule. It is a Playwright project rather than part of pnpm test:e2e, and it is deliberately not in CI - see below.

For your own workload, the benchmark harness recipe and the FPS HUD recipe are copy-paste probes you can drop into your app.

What we don't claim

Frequently asked questions

How fast is SvGrid?

It virtualizes both rows and columns, so only the visible window is in the DOM, and a million rows scrolls without dropping a frame. On the data side, a single-column sort of 100,000 rows takes about 27 ms and a one-operator filter about 5 ms. Every table on this page comes from a checked-in script you can run yourself, and the numbers moved because the harness exposed real defects rather than because the prose was rewritten.

How many rows can SvGrid handle?

Client-side, 100k+ rows scroll smoothly thanks to virtualization. For millions of rows, page or chunk from the server (see Server-side data). The DOM only ever holds the visible window regardless of total row count.

How fast is SvGrid, and how big is it?

It ships a small bundle (the measured figures for the full render component and for the headless core alone are in the table above) and virtualizes by default. Raw scroll performance is comparable for typical workloads; the bigger practical win is bundle size and a Svelte-native runtime with no framework bridge.

See also

Live examples

  • 100k rows × 100 columns - Row + column virtualization. Chunked load with progress + cancellation.
  • Server-side infinite scroll - 100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.
  • Server-Side Row Model: 1,000,000 rows - One grid, one rowModel prop, a million rows that stay on the server. Sort, filter, global search, grouping to any depth (Region > Country > Rep), infinite scroll or paging, inline edits applied back as transactions with the subtotal following, add and delete, select-all across rows the grid never loaded with a bulk edit by rule, failed blocks with Retry, a request log that shows every call to the columnar warehouse behind it, and a live map of the block cache per level. The row model ships in @svgrid/enterprise; the datasource contract is free.

Related articles