A detailed guide for benchmarking the performance of well-known React data grids. This README covers configurations, testing methods, and the rationale for evaluating rendering performance across scrolling, sorting, filtering, and dataset replacement.
Note
The README explains how to set up the testing environment and run the benchmarks locally. Local benchmark results can vary depending on your machine, browser, and system load. For performance results from our testing environment, see this published article.
The approach used for this benchmark was inspired by the JS Framework Benchmark.
We want to measure how quickly various React Data Grids can go from an action to pixels on the screen. For the purposes of these benchmarks an action can be scrolling the grid, replacing the grid's dataset, filtering, or sorting.
Data Grids offer a wide variety of features and capabilities, many of which are unrelated to performance. This benchmark focuses on rendering performance, so features such as cell editing and row selection are not included in the measured actions.
While this benchmark does not cover every available library or test every performance scenario, it focuses, in our judgment, on the performance characteristics that matter when assessing React data grid behavior.
Apart from render performance, we assess the bundle sizes of the data grids in our benchmarks.
To measure this, we use esbuild to bundle the code for each grid, and then gzip it using
Node's zlib library. The size of the resulting file is then reported in kilobytes,
giving us a good sense of the overall efficiency.
We are the creators of LyteNyte Grid, which is a data grid included in these benchmarks. Whilst we have made every effort to ensure the fairness of these benchmarks, we encourage developers to perform their own due diligence, and measure performance for their own specific use cases.
Follow these steps to get started:
- Clone the repository
- Change to the benchmarks directory:
cd benchmarks-grid - Install all the dependencies:
pnpm install
- Generate the benchmark data:
pnpm run generate:data
- Build the benchmark app for production:
pnpm run build
- Start a local production preview (the preview runs on port 4173):
pnpm run preview
- Run the benchmark with
tsx. For example:pnpm tsx ./bench/01-scroll-10k.ts
To run the bundle size checks, only the install and build steps are required; the preview server does not need to be running.
pnpm tsx ./check-bundle-size.ts`Before you run the benchmarks, ensure your testing environment includes:
- Node.js (v22 or later)
- pnpm (v10 or later)
- Google Chrome (v149+ or later)
The benchmarks have been tested on Windows 10 or later and on macOS Sequoia. Other operating systems may work if they support Node.js and Google Chrome, but we can’t guarantee compatibility.
By nature, these benchmarks are rendering-heavy, so having GPU-accelerated hardware is recommended. On macOS, use a Mac with an Apple M3 chip or newer.
For the most stable results, ensure no nonessential background processes are running before running the benchmarks. The reference benchmark run requires Chrome in headed mode, so a physical or virtual display must be connected.
Note
The benchmarks take approximately 8 hours to run, but may take significantly longer on less powerful hardware.
The repository contains a Vite-powered React application. We use Vite to build the production version of the application. For development, the Vite dev server can also be used by running:
pnpm run devAll the code for the benchmarks is kept in the src folder.
-
components: Contains adivwrapper for rendering each data grid. This is necessary as some data grids require a container with an intrinsic size before they can render correctly. -
routes: Contains the test implementation for each data grid. Each test has a numbered folder, and each numbered folder contains one folder per data grid with the corresponding test code.
In addition, the following folders are also used:
public: Contains test assets, and in particular pre-computed test data that can be used for the various tests. The precomputed data exists to ensure that no test spends time creating data.patches: Fixes anexportissue present in Handsontable. See this issue for more details. The patch has no impact on Handsontable's performance in these benchmarks.pages: Contains an HTML file for each test. Tests are isolated into their own route in the production build to prevent cross-contamination of results.bundle-checks: Contains the entry points used by the check-bundle-sizes script to determine the minimum and maximum bundle sizes for the different grids.bench: Contains the benchmark test scripts that are run for each grid. Each script is a test that is measured as part of the benchmark results. You can run any script usingtsx, for example,pnpm tsx ./bench/02-scroll-200k.ts.
Note
The test scripts run server-side. Before running any test script, ensure the preview instance is running. See the Quick Start steps for more details.
Each benchmark script in the bench folder is run using Measure Right,
a benchmarking library built on top of Playwright.
Measure Right launches Chrome, opens the relevant test page, and performs the interaction defined in the benchmark, such as scrolling.
Each benchmark includes the following steps:
before: Navigates to the page and waits until the required elements are visible.warm: Runs unrecorded warm-up iterations.run: Runs the measured interaction.
Measure Right runs each test in a headed Chrome instance so the full GPU compositing pipeline remains active. This setup helps ensure the benchmarks reflect realistic rendering conditions, including:
- Hardware-accelerated CSS transforms
- Compositor layers
- GPU rasterization
- Layout and paint timing that avoid software-rendering fallback
The benchmark runner always launches Chrome with these flags:
--js-flags=--expose-gc: Enables thewindow.gc()call used to force garbage collection before each pass.--enable-benchmarking: Enables additional Chrome benchmarking APIs.
Measure Right uses the system-installed Google Chrome rather than Playwright’s bundled Chromium. It resolves the Chrome binary from the standard installation path for each platform:
- macOS:
/Applications/Google Chrome.app - Linux:
/usr/bin/google-chrome - Windows:
C:\Program Files\Google\Chrome\Application\chrome.exe
Since the benchmarks use the installed Chrome version, the results are tied to whichever
Chrome version is installed at the time of the run. When sharing or comparing results,
always record the Chrome version (chrome://version) along with the hardware specification.
Scroll benchmarks use a global Scroll helper defined as an inline <script> tag in each test page’s HTML file.
The helper calls the browser’s native scrollBy method, but it does not apply the full scroll
distance in a single step. Instead, it:
- Divides the total scroll distance into fixed 500px steps.
- Wraps each
scrollBycall inrequestAnimationFrame. - Waits for one frame boundary between each step.
For example, a 2,000px scroll produces four 500px increments, with
one requestAnimationFrame pause between each increment.
Each increment is issued only after the previous frame has been committed. The grid must complete its rendering work within the current frame before the next scroll position is applied.
The measured duration reflects the grid’s frame-by-frame rendering throughput,
not how quickly the test script can dispatch events. A grid that falls behind the frame budget will
stall at each requestAnimationFrame boundary, naturally producing a longer measured duration.
For each benchmark iteration, Measure Right performs two separate browser runs.
The memory pass uses the Chrome DevTools Protocol (CDP) Performance.getMetrics API to
capture JSHeapUsedSize in megabytes after the benchmark completes. This isolates
the JavaScript heap memory consumed by the grid during the interaction.
The CPU/timing pass enables Chrome tracing across the following categories:
blink.user_timingdevtools.timelinedisabled-by-default-devtools.timeline
Measure Right brackets the benchmark run function with performance.mark("bench_start") and performance.mark("bench_end")
to define the measurement window. The resulting trace file is parsed to extract
the timing metrics described below.
Before each pass, Measure Right forces a synchronous major garbage collection:
await page.evaluate(
"window.gc({type:'major',execution:'sync',flavor:'last-resort'})",
);This creates a clean heap baseline and prevents previous runs from contaminating the results.
Before any measurements are recorded, each benchmark is run 5 times as a warm-up run.
These warm-up rounds are discarded. They exist solely to:
- Allow the browser’s JIT compiler to reach a steady optimization state.
- Eliminate cold-start effects from the measured results, such as initial script parsing, layout, and paint.
Following the warm-up, each benchmark is measured across 50 iterations.
Running 50 iterations provides a sufficiently large sample to produce stable statistical estimates and to surface outliers caused by background OS activity or transient CPU throttling.
During the CPU/timing pass, Playwright instructs Chrome to record an execution trace. The trace is a JSON file that contains a timestamped stream of browser-internal events emitted by Chrome's rendering engine, compositor, and JavaScript runtime.
Chrome DevTools exposes the same tracing infrastructure when you record a Performance profile. The trace provides Measure Right with low-level visibility into exactly what work Chrome performed, including when:
- Frames were committed.
- Layout occurred.
- Animation frames fired.
The trace covers the entire lifetime of the browser page, not only the benchmark interaction. To isolate the measured interaction, Measure Right brackets the benchmark run function with:
performance.mark("bench_start")performance.mark("bench_end")
These emit blink.user_timing events into the trace that serve as precise anchors for the measurement window.
When Measure Right parses the trace, it first locates the start and end marks. It then discards all events outside
that window. This prevents setup work in before, such as page navigation and waiting for elements,
and post-run idle time from skewing the measured results.
Within the scoped window, Measure Right looks for specific event types
from Chrome’s rendering pipeline:
Commitevents: Indicate when Chrome committed a frame to the compositor.Layoutevents: Indicate browser reflow.AnimationFrameasync-start events: Indicate vsync ticks.
Measure Right derives the metrics below from the timestamps and durations of these events.
The following metrics are extracted from the Chrome trace for each iteration:
| Metric | Description |
|---|---|
| Duration (ms) | Wall-clock time from bench_start to the end of the last Commit event, when Chrome commits the final rendered frame to the compositor. Measure Right subtracts idle vsync wait time from this value when rafLongDelay applies. |
| Average FPS | Estimated frames per second, calculated from the cadence of AnimationFrame async-start events within the benchmark window. |
| Number of Commits | Total count of Commit events across the benchmark run. Benchmarks perform multiple interactions, such as 16 scroll operations, so this value is expected to be greater than 1.A high commit count relative to the number of interactions suggests that the grid triggers multiple rendering passes per interaction rather than efficiently batching updates. |
| Layouts | Total count of Layout events. Unexpected layouts indicate that the grid triggers avoidable browser reflow work. |
| Max Delta Between Commits (ms) | Spread between the first and last Commit timestamps. A large value indicates that rendering was spread across many passes over a long period rather than being batched efficiently. |
| RAF Long Delay (ms) | Idle vsync wait time beyond the standard 16ms frame budget after bench_end. Measure Right subtracts this excess time from Duration to prevent idle browser wait time from inflating the result. |
| Memory (MB) | JavaScript heap size captured via CDP after the interaction completes. |
Full credit for the inspiration of this approach is given to the JS Framework Benchmark repository.
By default, benchmarks are run in round-robin order: each iteration cycles through all grids before starting the next iteration. This prevents any single grid from being consistently measured while the CPU or GPU is in a degraded thermal state, producing fairer comparisons. An alternative sequential mode (all iterations of one benchmark before moving to the next) is also available.
A 1-second pause is inserted between each run to allow the browser and system to settle.
After all iterations are complete, the following statistics are computed for each metric across all iterations:
| Statistic | Description |
|---|---|
| Mean | Average value across all iterations. |
| Mode | Most frequently occurring value after bucketing rounding. |
| Standard deviation | Spread of values across iterations. |
| ±1 standard deviation range | Interval from mean - stdDev to mean + stdDev. This range helps indicate measurement stability. |
Each metric is also normalized relative to the best-performing grid in the run, expressed as a relative distance percentage.
A relative distance of 0% means the grid achieved the best result for that metric.
Lower values are better for:
- Duration
- Memory
- Commits
- Layouts
- Max delta between commits
Higher values are better for:
- Average FPS
For more rigorous analysis of the raw CSV data, it is recommended to trim the top and bottom 5% of iteration values before computing the mean. This removes a small number of iterations affected by OS scheduling spikes, transient GC pauses, or CPU thermal events while preserving the bulk of the dataset.
This produces a 5% trimmed mean using values between the 5th and 95th percentiles. This technique is also used by js-framework-benchmark and other established JavaScript performance suites.
The summary statistics above are intended as a high-level overview. The run function also returns the raw measurements for every individual iteration, giving you the full dataset for each benchmark. This allows you to perform your own statistical analysis, for example, computing percentiles, trimming outliers, or applying alternative aggregation strategies.
The individual results are written to a CSV file (e.g. 01_scroll-10k.csv) alongside the summary,
so they can be loaded into any analysis tool of your choice.
All benchmarked grids use React 19:
react: ^19.2.5react-dom: ^19.2.5
React 19 ships a revised concurrent rendering scheduler and altered reconciliation behavior compared to React 18, so results may differ from results produced with earlier React versions.
The source wraps the app in StrictMode. However, StrictMode double-render checks run only in
development builds, so they do not affect production benchmark measurements.
The following libraries and versions are included in the benchmarks:
| Grid | Package(s) | Version |
|---|---|---|
| LyteNyte Grid | @1771technologies/lytenyte-core |
^2.1.1 |
| AG Grid | ag-grid-community, ag-grid-react |
^35.3.0 |
| MUI X Data Grid | @mui/x-data-grid-premium |
^9.3.0 |
| DevExtreme | devextreme, devextreme-react |
^25.2.7 |
| Handsontable | handsontable, @handsontable/react-wrapper |
^17.1.0 |
| Material React Table | material-react-table |
^3.2.1 |
All datasets are generated with the same seeded linear congruential generator:
- Seed:
12345 - Row value range:
0 -10
This ensures the generated values are identical and deterministic across runs. All data is precomputed before the tests run so that no benchmark iteration spends time generating data.
The row counts and column counts vary by test group to target specific performance characteristics:
| Test | Benchmark Script | Rows | Columns | Handsontable Rows | Handsontable Columns |
|---|---|---|---|---|---|
| Scroll 10K | 01-scroll-10k.ts |
10,000 | 300 | 10,000 | 300 |
| Scroll 200K | 02-scroll-200k.ts |
200,000 | 300 | 150,000 | 300 |
| Scroll 500K | 03-scroll-500k.ts |
500,000 | 300 | 500,000 | 300 |
| Scroll 1M | 04-scroll-1000k.ts |
1,000,000 | 300 | 1,000,000 | 300 |
| Pinned | 05-pinned.ts |
200,000 | 300 | 20,000 | 300 |
| Horizontal | 06-horizontal.ts |
50,000 | 300 | 20,000 | 300 |
| Cell Updates | 07-cell-updates.ts |
1,000 × 50 datasets | 100 | 1,000 × 50 datasets | 300 |
| Sorting 10K | 08-sorting-10k.ts |
10,000 | 300 | 10,000 | 300 |
| Sorting 50K | 09-sorting-50k.ts |
50,000 | 300 | 50,000 | 300 |
| Sorting 100K | 10-sorting-100k.ts |
100,000 | 300 | 100,000 | 300 |
| Filtering 10K | 11-filtering-10k.ts |
10,000 | 300 | 10,000 | 300 |
| Filtering 50K | 12-filtering-50k.ts |
50,000 | 300 | 50,000 | 300 |
| Filtering 100K | 13-filtering-100k.ts |
100,000 | 300 | 100,000 | 300 |
For all scroll, sort, and filter tests, every column renders the same custom cell.
The custom cell is a div that fills the cell area and applies one of 10 background colors
based on the row’s integer value. All grids use the same cell renderer so that per-cell
rendering work is consistent across implementations.
For the cell updates test, the benchmark measures how quickly each grid replaces the entire dataset and renders the new rows. The test cycles through 50 prebuilt datasets in sequence.
Handsontable has two structural differences from the other grids worth noting.
First, Handsontable’s native data model is a 2D array: Array<Array<number>>.
The other grids use an array of objects with named keys: Array<Record<string, number>>.
All Handsontable tests use this format, which is the correct idiomatic input for the library and avoids any overhead from an incompatible data shape.
Second, several Handsontable tests use reduced row counts:
| Test | Other Grids | Handsontable |
|---|---|---|
| Scroll 200K | 200,000 rows | 150,000 rows |
| Pinned | 200,000 rows | 20,000 rows |
| Horizontal | 50,000 rows | 20,000 rows |
These reductions were necessary because Handsontable crashed with out-of-memory errors when loaded with the full row counts used by the other grids.
Where Handsontable's row count differs from the other grids, this is noted in the benchmark results so readers can account for it when interpreting comparisons.
All grids are rendered inside a shared GridContainer component that
fixes the container to exactly 1920×1080 pixels. The Playwright browser
viewport is set to 2000×1200 for all benchmark runs, so the grid always
fills a consistent visible area regardless of which grid is under test.
All grids are configured to use a row height of 20px and a header height of 20px. Default cell padding is stripped to zero across all grids, either through each grid's API or via targeted CSS overrides, so that cell content sizing is as consistent as possible between implementations.
To ensure like-for-like rendering comparisons, the following normalizations are applied across all grids:
| Normalization | Applied Setting | Rationale |
|---|---|---|
| Virtualization | Enabled row and column virtualization on every grid that supports it. | Virtualization is the standard production configuration for large datasets. |
| Row and column overscan | Sets overscan or buffer values to 0, or the closest equivalent. |
This prevents grids with larger default buffers from rendering extra rows or columns beyond the visible area. |
| Animations | Disabled row and cell animations where the grid provides an option, such as animateRows={false} in AG Grid. |
Animations add rendering work that is unrelated to the benchmarked interaction. |
| Pagination and toolbars | Disabled pagination and toolbars on all grids. | Only the grid body and header are rendered. |
| Column resizing and reordering | Disabled resizing and reordering where applicable. | This avoids extra event listeners or overhead unrelated to rendering. |
Where a grid's defaults would cause it to render materially more or less work than others, we override them to bring behavior in line with the rest of the field.
CSS overrides are used sparingly and only to normalize padding and row height. They are not used to hide or remove any part of a grid's rendering output. No internal APIs, private flags, or undocumented configuration options are used for any grid.
The benchmark scripts connect to the production build of the app running locally.
Do not use the Vite dev server for benchmark runs. React’s development mode adds overhead that can inflate timings and make cross-grid comparisons unreliable, such as:
StrictModedouble-render checks.- Runtime prop-type checks.
- Extra reconciliation paths.
The production build produces minified and optimized output that more closely reflects real-world deployment conditions.
Before running any script, make sure the preview server is up:
pnpm run build
pnpm run previewThe server listens on port 4173. Each benchmark script navigates to
http://localhost:4173/pages/... so the server must remain running for
the entire duration of the test.
Each script in the bench folder is a self-contained test that can be
run independently with tsx:
pnpm tsx ./bench/01-scroll-10k.ts
pnpm tsx ./bench/08-sorting-10k.tsThe script launches a Chrome browser via Playwright, runs the benchmark,
and prints a summary table to the console when it finishes. It also writes
a CSV file to the project root (e.g. 01_scroll-10k.csv) containing the
raw measurements for every individual iteration.
There is no single command to run all benchmarks in sequence. Run each script individually:
pnpm tsx ./bench/01-scroll-10k.ts
pnpm tsx ./bench/02-scroll-200k.ts
pnpm tsx ./bench/03-scroll-500k.ts
pnpm tsx ./bench/04-scroll-1000k.ts
pnpm tsx ./bench/05-pinned.ts
pnpm tsx ./bench/06-horizontal.ts
pnpm tsx ./bench/07-cell-updates.ts
pnpm tsx ./bench/08-sorting-10k.ts
pnpm tsx ./bench/09-sorting-50k.ts
pnpm tsx ./bench/10-sorting-100k.ts
pnpm tsx ./bench/11-filtering-10k.ts
pnpm tsx ./bench/12-filtering-50k.ts
pnpm tsx ./bench/13-filtering-100k.tsRunning the full suite takes approximately 8 hours. For the most stable results, close all non-essential applications before starting and allow the machine to run uninterrupted.
When a script completes, it prints a console.table summary to the terminal.
The table shows the mean value for each metric across all iterations for
every grid included in that script.
The benchmark writes the full per-iteration dataset to a CSV file in the project root. The CSV includes every raw measurement, with one row per iteration per grid.
You can load the CSV into any spreadsheet or analysis tool for further processing. For details about the raw data fields, see the Individual Run Results section of the Benchmark Methodology.
Chrome trace files for the most recent CPU/timing pass of each benchmark are saved to
a traces/ directory in the project root. Trace files are named by benchmark, for example:
traces/LyteNyte Scroll 10K.json`
These are overwritten on each run and can be loaded into chrome://tracing or
the Chrome DevTools Performance panel for manual inspection.
Each benchmark script defines which grids to include in the benchmarks array passed
to run(). To run only specific grids, open the script and remove the entries
you do not need from that array.
For example, to run only LyteNyte and AG Grid in the scroll 10K test,
edit bench/01-scroll-10k.ts so the run() call reads:
const [result, items] = await run({
benchmarks: [lng, ag],
...
});Note
Some scripts already exclude certain grids. For example, Handsontable is omitted from the Scroll 200K script because it consistently crashes at that row count.
The iteration count is set via the iterations parameter in the run()
call at the bottom of each script. To run fewer iterations for a quick
spot-check, change the value directly in the script:
const [result, items] = await run({
benchmarks: [...],
iterations: 5, // default is 50 for full runs
...
});Keep in mind that the summary statistics (mean, standard deviation, range) become less reliable with fewer iterations, and outliers caused by background OS activity will have a greater impact on the results.
Bundle sizes are measured independently of the runtime benchmarks and do not require the preview server to be running.
Run the check with:
pnpm tsx ./check-bundle-size.tsThis prints a summary table to the console and writes the produced bundle
files to the dist/ directory.
The bundle size script uses esbuild to bundle each grid's entry point.
For each grid, the script:
- Enables full minification with
minify: true. - Targets ESM output.
- Compresses the generated bundle with Node.js
zlibusingZ_BEST_COMPRESSION. - Reports the final gzipped file size.
The script treats react and react-dom as external dependencies and excludes
them from all bundles, since every application already provides them.
Each grid is measured twice using two different entry points from the
bundle-checks folder:
-
Minimum bundle (
*-min.ts): Imports only the primary component needed to render the grid (e.g.import { Grid } from "@1771technologies/lytenyte-core"). This represents the smallest realistic footprint for an application that uses the grid with a single named import and benefits from tree shaking. -
Maximum bundle (
*-max.ts): Re-exports the entire package withexport * from "...". This represents the upper bound, the full size of the library if nothing is tree-shaken away, as would occur in environments where tree shaking is unavailable or the application imports broadly.
Together these two values give a meaningful size range rather than a single potentially misleading figure.
Some grids declare peer dependencies that are expected to be provided by the host application. For these grids the bundle is measured in two ways: once with those peer dependencies bundled in (the default), and once with them treated as external so only the grid's own code is counted. The grids with excluded peer dependencies are:
- MUI X DataGrid:
@mui/material,@emotion/react,@emotion/styled - Material React Table:
@mui/material,@mui/x-date-pickers,@mui/icons-material,@emotion/react,@emotion/styled
LyteNyte Grid, AG Grid, DevExtreme, and Handsontable have no peer dependencies excluded, their reported sizes include all of their dependencies.
Bundle size measurement covers JavaScript only. CSS is not included in
these measurements. Compression is gzip only (Z_BEST_COMPRESSION);
Brotli is not measured. Results therefore represent what a gzip-capable
CDN or web server would serve for the JS portion of each grid.
Running a benchmark script produces files in two locations:
- CSV files: written to the project root, named after the benchmark
script (e.g.
01_scroll-10k.csv,08_sorting-10k.csv). One file per script run, overwritten on each run. - Chrome trace files: written to
traces/in the project root, named after each benchmark (e.g.traces/LyteNyte Scroll 10K.json). Overwritten on each run. These are standard Chrome trace files and can be loaded intochrome://tracingor the Chrome DevTools Performance panel.
Running the bundle size check writes minified JS bundles and their
gzipped counterparts to dist/.
Each CSV file has one header row followed by one data row per iteration per grid. If a script runs 2 grids for 50 iterations, the file will have 101 rows (1 header + 100 data rows).
The columns are:
| Column | Type | Unit | Description |
|---|---|---|---|
name |
string | — | Benchmark name, e.g. "LyteNyte Scroll 10K". |
duration |
number | ms | Wall-clock time from bench_start to end of last Commit, minus any rafLongDelay correction. |
avgFps |
number | fps | Average frames per second estimated from AnimationFrame event cadence. |
memory |
number | MB | JS heap used size (JSHeapUsedSize) captured via CDP after the run. |
numberCommits |
number | count | Total Commit events within the benchmark window. |
layouts |
number | count | Total Layout events within the benchmark window. |
maxDeltaBetweenCommits |
number | ms | Spread between first and last Commit timestamps. |
rafLongDelay |
number | ms | Excess vsync wait beyond 16ms subtracted from duration; 0 when no correction was applied. |
A single benchmark iteration is not reliable on its own. Background OS activity, garbage collection timing, thermal throttling, and vsync jitter can make a single run unusually fast or slow.
Always draw conclusions from the aggregated statistics across all 50 iterations, in particular the mean and the standard deviation range.
If the standard deviation range is wide, the results are noisy. Re-run the benchmark with other processes closed. A single outlier in the CSV is not a concern; however, a consistently widespread deviation is.
When comparing two grids on a single metric, check whether their standard deviation ranges overlap. If the ranges overlap, the difference may not be statistically meaningful, even when the means differ.
Absolute values, such as duration in milliseconds, memory in megabytes, and FPS, are not portable across machines.
For example, 120ms on an M3 MacBook Pro and 120ms on a Windows desktop do not indicate identical performance. GPU acceleration, CPU thermal headroom, and browser scheduling can vary across machines.
Only compare results produced on the same hardware in the same session. When you share results publicly, include the hardware specifications.
Memory and timing metrics are captured in separate browser runs and measure different dimensions of grid performance:
- Memory: JavaScript heap used size.
- Timing: Duration, FPS, commits, and layouts.
A grid can be fast but memory-heavy. A grid can also use less memory but take longer to render.
Do not combine memory and timing metrics into a single score. Evaluate each dimension independently and decide which trade-off matters more for your use case.
Published benchmark results are produced on specific hardware under controlled conditions (no background processes, GPU acceleration enabled, macOS on M-series silicon). Your local results may differ for several reasons:
- Different Hardware: CPU and GPU performance, memory bandwidth, and thermal characteristics all affect rendering speed.
- Background Processes: Any application consuming CPU or GPU during the run will inflate timing results.
- Browser Version: Chrome's rendering engine changes between versions. Results are tied to the Chrome version installed at the time of measurement.
- Thermal State: Sustained benchmark runs cause CPUs to throttle. Results recorded late in a long session may be slower than those recorded at the start. The round-robin iteration order mitigates this within a single script, but across a full 8-hour suite run, earlier tests will generally have more thermal headroom.
- OS Scheduling: Windows and macOS schedule processes differently, and results are not expected to be numerically equivalent across operating systems.
The metrics captured here, duration, FPS, commit count, layout count, are objective measurements of browser rendering work, but they are not a perfect proxy for how smooth a grid feels to a user.
FPS as computed from AnimationFrame cadence measures how consistently
the browser is scheduling animation callbacks, but does not capture frame
drops that occur within a single vsync interval. A grid that schedules
frames at a perfectly even cadence but does heavy work on each frame may
feel jankier than its FPS figure suggests.
Duration measures how long the browser takes to process the benchmark interaction end-to-end, but a long duration is not always perceptible if most of the time is spent in off-screen work. Conversely, a grid that produces visual artifacts or repaints visible cells unnecessarily may feel worse than its duration figure suggests.
These metrics are best understood as engineering diagnostics, useful for identifying which grids do more or less work, rather than as direct predictions of user experience. For a complete picture, combine these results with manual inspection of the rendered output and your own user testing on representative hardware for your own use cases.
The benchmarks measure a specific set of rendering-intensive interactions using pregenerated numeric data and a custom color cell renderer.
A grid that performs well in these benchmarks may not be the best choice for every application. Grids also differ in areas that these benchmarks do not measure, including:
- Feature set
- API ergonomics
- Accessibility support
- Ecosystem maturity
- Licensing
Use the benchmark results as one input alongside your own evaluation of the features and workloads your application requires.
Some normalizations require workarounds for differences in how grids expose configuration. Row buffer sizes, overscan counts, and animation flags are named and scoped differently across libraries, and in some cases the closest available option is not an exact equivalent.
Where a perfect match is not possible, we select the option that yields the most comparable rendering behavior and document the choice in the Fairness and Grid Normalization section.
All benchmark scripts include Playwright assertions inside the run function, so the
assertions fall within the bench_start / bench_end measurement window.
The assertions serve two purposes:
- Verify that the operation produced the expected result.
- Synchronize the benchmark steps by waiting until the grid has finished rendering.
Each test type uses an assertion that matches the interaction being measured:
| Test Type | Assertion |
|---|---|
| Scroll test | Use toBeInViewport() to confirm that the expected row has rendered into view. |
| Cell update tests | Use toBeVisible() to wait until all dataset replacements have rendered. |
| Sort and filter tests | Assert specific cell values to confirm that the operation completed. |
The same assertions run for every grid, so the assertion overhead is consistent and does not favor any implementation.
These benchmarks measure rendering performance for scrolling, sorting, filtering, and dataset replacement only. The following capabilities are deliberately out of scope and performance on them is not reflected in these results:
- Cell editing and inline form controls.
- Row grouping, tree data, and aggregation.
- Column resizing and reordering.
- Keyboard navigation and focus management.
- Accessibility (ARIA, screen reader compatibility).
- Server-side data fetching and infinite scroll.
- Server-side rendering (SSR) and hydration.
- Frozen / sticky rows beyond the pinned test.
- Theming and custom CSS rendering costs.
- Plugin or extension overhead.
Results are specific to the Chrome version, operating system, and hardware used during the run. Different browsers (Firefox, Safari) use different rendering pipelines and are not measured here.
Results on different machines, including cloud or CI environments, will not be numerically comparable to locally produced results and should not be treated as equivalent, even if the hardware specifications appear similar.
If you believe a grid is configured in a way that produces misleading results, either because it is disadvantaged relative to its typical production usage or because it has been given an unrealistic advantage, please open a GitHub issue with:
- The benchmark script and grid in question.
- A description of what you believe is unfair.
- A suggested alternative configuration and the reasoning behind it.
We will review all reports and update the benchmark if the concern is valid. Our goal is accurate measurement, not favorable results for any particular grid.
If you would like to see a grid added to the benchmarks, please open a GitHub issue with the grid's name, package, and a link to its documentation. We will consider adding it if we can configure it fairly and consistently with the existing grids. Note that grids require a permissive enough license to be used in a public benchmark without restriction.
If you would like to see a new benchmark scenario added, such as a different interaction type, a different row or column count, or a specific rendering pattern, please open a GitHub issue describing the use case and why it would be a meaningful addition. Scenarios that reflect real-world production workloads and are reproducible across all included grids are most likely to be accepted.
This README and benchmark project are provided for informational purposes only. The benchmark methodology, scripts, and configuration are not an endorsement, recommendation, or guarantee of performance for any grid library. Results may vary based on hardware, browser version, operating system, system load, grid configuration, dataset shape, and application requirements.
All third-party names, packages, trademarks, and products referenced in this project belong to their respective owners. Their inclusion is for comparative benchmarking only and does not imply sponsorship, endorsement, affiliation, or approval.
We aim to configure each grid fairly and consistently. If you maintain one of the included grids and believe a benchmark configuration is incorrect or unrepresentative, please open an issue or pull request with details.
This project is provided “as is,” without warranties of any kind. Users should review the methodology, inspect the source code, and validate results in their own environment before making technical or business decisions.
