Performance figures for five JavaScript data grids — AG Grid, Bryntum Grid, DevExtreme DataGrid, DHTMLX Grid and Kendo UI Grid — measured on the same data, the same row height and the same scroll.
100 000 rows, 20 columns with HTML in cells:
| Grid | Median frame, ms | P95 frame, ms | Worst frame, ms | FPS |
|---|---|---|---|---|
| AG Grid | 16.7 | 26.19 | 69 | 57 |
| Bryntum | 16.6 | 23.1 | 38.5 | 60 |
| DevExtreme | 18.7 | 32.65 | 72.8 | 51 |
| DHTMLX | 16.6 | 21.23 | 41.6 | 60 |
| Kendo | 16.9 | 95.26 | 111.5 | 35 |
Every median frame sits within 16.6–18.7 ms, close to the display's floor. The P95 column is where the grids separate: 21 ms to 95 ms here, and 13× across the full matrix. More below.
Most grid comparisons publish one number per grid — an FPS figure, or a mean frame time, at one dataset size. One number can't tell two very different problems apart.
Here are two of them from this round. The first grid has a typical frame of 16.9 ms, the floor a 60 Hz display allows, and one frame in twenty at 95 ms. It scrolls smoothly, then stalls at a page boundary. The second has a median of 95.5 ms, so every frame is late. Average either one into FPS and they land in roughly the same place.
So this suite publishes the distribution instead: median, 95th percentile and worst frame, pooled over every frame of every run, with the run-to-run spread next to each figure. It covers three behaviors — building the grid, scrolling it, and jumping to a row deep in the dataset. Each one runs across 10 000 to 800 000 rows and 10 to 40 columns, with and without HTML in cells, with and without frozen columns.
Everything needed to re-run it is in this repository.
DHTMLX publishes this suite, and DHTMLX Grid is one of the five grids in it.
Here's what that means in practice:
- The method is written out in METHODOLOGY.md, including every setting applied beyond a vendor default — a closed list, in §2.
- Datasets are deterministic and seeded.
- Every results file records the exact package version of each grid and the machine that ran it.
- The harness that did the measuring is the code in this repository.
AG Grid is measured as ag-grid-community, alongside four commercial trials. Every grid runs on its
vendor's defaults apart from that closed list. These are untuned numbers, which is what an
application gets before anyone tunes it.
Three behaviors, per grid, per scenario, per dataset size:
| What is timed | |
|---|---|
| Initial render | from the call to the grid's constructor until the viewport holds the expected number of rows, stable across two consecutive frames |
| Scroll | a frame-time series collected with requestAnimationFrame while the grid's own scroll container is driven programmatically over 50 000 px |
| Jump to depth | from a programmatic jump to 90 % of the dataset until the viewport catches up |
Library download, library parse, fetch and JSON.parse all finish before the first clock starts.
That holds for all five grids.
Every grid runs 40 px rows, so pixels convert to rows and the scroll is the same amount of work for each. 50 000 px at that height is 1250 rows, at every dataset size, and the jump is what reaches a high index.
Run counts, how percentiles come off the pooled frame series, and the refresh-rate check that decides whether an FPS figure describes the machine are in METHODOLOGY.md §4–6, along with the boundary of every metric.
What produced these numbers:
| Machine | AMD Ryzen 7 4700U, 8 cores, 15 GB, Windows 10 |
| Browser | Chromium 145.0.7632.6, headed, 1920×1080 |
| Display | 60 Hz, measured and validated |
| Runs per point | 5, plus one discarded warmup |
| Matrix | 5 grids × 8 scenarios × 5 sizes = 200 cells, no failures |
| Grid | npm package | Version measured |
|---|---|---|
| AG Grid | ag-grid-community |
36.0.2 |
| Bryntum Grid | @bryntum/grid-trial |
7.3.4 |
| DevExtreme DataGrid | devextreme |
26.1.3 |
| DHTMLX Grid | @dhx/trial-grid |
9.3.8 |
| Kendo UI Grid | @progress/kendo-ui |
2026.2.520 |
That's the table at the top of this file. Every grid built itself, scrolled 50 000 px, then jumped to row 90 000.
The median frame sits within 16.6–18.7 ms for all five, and two grids report 60 FPS. Stop the comparison at those two columns and there's little left to tell them apart.
Kendo shows why the percentile matters. Its typical frame is 16.9 ms, and one frame in twenty takes 95.26 ms. The cost doesn't spread evenly across frames, so its FPS figure of 35 averages the smooth stretches together with the slow ones.
The worst frame is the largest single spike across every run of the cell. It's one frame, so it doesn't describe typical scrolling. Read it beside P95, never instead of it.
Build time in milliseconds, ten template columns, at every size.
| Grid | 10k | 50k | 100k | 200k | 800k |
|---|---|---|---|---|---|
| AG Grid | 148.6 | 199.4 | 255.8 | 386.1 | 1149.4 |
| Bryntum | 268.6 | 532.3 | 838.8 | 1503.1 | 5330.2 |
| DevExtreme | 180.5 | 182.8 | 178.1 | 179.6 | 186.1 |
| DHTMLX | 55 | 65.5 | 73.8 | 93.3 | 242.8 |
| Kendo | 93.1 | 93.5 | 91.1 | 96.5 | 115.2 |
From 10 000 rows to 800 000, build time grows 1.03× for DevExtreme, 1.2× for Kendo, 4.4× for DHTMLX, 7.7× for AG Grid and 19.8× for Bryntum. DevExtreme's render time moves by 6 ms across the whole axis, Kendo's by 22 ms. Their grid-side heap at the largest size is 11.6 MB and 11.3 MB.
AG Grid's P95 is 17.8 ms at 10 000 rows and 2.6× that at 800 000. Its median frame moves too, to 31.9 ms, so at that size the frames are late consistently rather than occasionally. With 40 columns and three of them frozen, the median reaches 95.5 ms and the same 50 000 px take 30.8 s. Frozen columns add nothing measurable up to 200 000 rows and +172.56 ms of P95 at 800 000. It's the only grid here whose frozen-column cost depends on dataset size.
Bryntum sits at the other end of the render column. It spends 5.3 s building itself at 800 000 rows and 12.3 s with 40 columns, the longest in the set, and its grid-side heap at that point is 761.66 MB. Its scroll figures don't follow: P95 is 19.5 ms at 10 000 rows and 19.2 ms at 800 000. DHTMLX holds that same shape at a lower level, 17.3 ms and 17.2 ms, and builds itself in 242.8 ms.
Five grids, five approaches, and five different points where the numbers change character.
| Grid | What the figures describe | Where they change |
|---|---|---|
| DHTMLX | flat across every axis: median 16.6–16.7 ms in all 40 cells, P95 17.1–23.05 ms, fastest render through 200 000 rows | no point in this matrix |
| Bryntum | scroll figures independent of dataset size, P95 18.4–33.32 ms | construction and memory: 12.3 s to build and 761.66 MB at 800k with 40 columns |
| AG Grid | P95 17.4–29.2 ms through 200 000 rows | 800 000 rows, where the median frame moves, most of all with frozen columns |
| DevExtreme | render time and heap flat across the size axis | a per-frame cost throughout: P95 26–40 ms in every cell, and 1.5–2.2× DHTMLX's time over the same distance |
| Kendo | frame distribution scaling with column count | 20 columns and up; P95 173.69 ms at 40 |
Which column to read depends on the application. A grid that has to appear with a large dataset already loaded is reading the render column; one that has to stay smooth under continuous scrolling is reading P95. The full matrix behind both is eight scenarios across five sizes, and the command below produces it.
Node.js >= 20 (see .nvmrc), then:
npm ci
npm run vendor
npx playwright install chromiumUse npm ci, not npm install. It installs exactly what the committed lockfile says, where
npm install re-resolves transitive dependencies on every run and would measure slightly different
libraries with nothing to say so.
The run these figures come from, on one line so it pastes into PowerShell and cmd as well as a POSIX shell:
npm run bench -- --set=article --sizes=10000,50000,100000,200000,800000 --runs=5 --warmup=1 --order=random --cleanup
| Flag | Why |
|---|---|
--set=article |
The eight scenarios the comparison is published on: both cell families at 10, 20 and 40 columns, plus both frozen ones. --set=full also measures the hundred-column pair, which answers a different question. |
--sizes=… |
All five row counts, so the scaling curves have every point. |
--runs=5 |
Five measured runs per cell instead of the default three. |
--warmup=1 |
One unmeasured pass first, to prime caches and the JIT. |
--order=random |
Interleaves the grids. The machine warms and throttles over a run this long, and random order spreads that drift across all five instead of handing it to whichever grid the registry lists last. |
--cleanup |
Frees each dataset once nothing left in the run reads it. Without it, the 800 000-row datasets alone need about 800 MB. |
Plan for a machine given over to it for hours. Keep the window visible and the screen awake — headless frames follow a synthetic cadence, and a screensaver mid-run corrupts the FPS column of everything after it. Anything else competing for the CPU lands in the numbers.
Interrupt a run part-way and it keeps every cell it finished. Re-running the rest adds to the same archive.
| Command | What it does |
|---|---|
npm run bench |
Runs the matrix in a visible Chromium window, writing one file per cell to results/history/ as it finishes. npm run bench -- --help lists every flag. |
npm run report |
Reads that history and writes results/report.html and results/report.md from one aggregate, so the two can't disagree. |
npm run inspect |
Diagnostic views over the same history, as plain text: the full metric set, failure reasons, outliers. The run-to-run spread is in the report and the history files, see METHODOLOGY.md. |
npm run dev |
Dataset server on 127.0.0.1:8081 and the Vite viewer on localhost:5173, for watching a grid run a scenario without measuring it. npm run data-server and npm run viewer start the halves separately. |
npm run lint |
ESLint. npm run lint:fix applies what it can. |
npm test |
node:test over tests/unit/. Needs no browser and no vendor/. |
CI (.github/workflows/ci.yml) runs the last two and never runs the
benchmark. CI machines are shared and virtualized, so requestAnimationFrame isn't locked to a real
display and an FPS figure measured there describes a timer. The job installs only the harness's own
dev dependencies, so it stays runnable without vendor credentials.
A scenario fixes column count, cell complexity and frozen-column count. Dataset size is a runtime flag, not part of the scenario identity.
| ID | Columns | Cells | Frozen |
|---|---|---|---|
tpl10 |
10 | HTML templates | 0 |
tpl20 |
20 | HTML templates | 0 |
tpl40 |
40 | HTML templates | 0 |
tpl100 |
100 | HTML templates | 0 |
pln10 |
10 | plain text | 0 |
pln20 |
20 | plain text | 0 |
pln40 |
40 | plain text | 0 |
pln100 |
100 | plain text | 0 |
frz10 |
10 | HTML templates | 3 |
frz40 |
40 | HTML templates | 3 |
--set=article is the eight that leave out the hundred-column pair.
Every scenario uses the same 10 base fields (id, team, first name, surname, status, score, change,
priority, rating, city) plus numeric col01…col90 filler to reach the target count. The base fields
carry the rich renderers. Both families call the same HTML builders from
src/utils/renderers.js, so every grid gets identical markup.
Dataset sizes are 10 000, 50 000, 100 000, 200 000 and 800 000 rows. Generation is deterministic and seeded, so the same (scenario, size) pair always produces byte-identical rows, and two people measuring the same combination measure the same data. The scroll covers 50 000 px, with position accumulated per tick, so each grid sets its own pace.
What each axis isolates, what every metric means and which figures are read together: METHODOLOGY.md §5 and §7.
Five layers, one responsibility each:
- Adapters (src/adapters/) — one module per grid, implementing
mapColumns,create,getRenderedRowsandgetScrollContainer. The only place you touch to add a grid. - Registries (src/core/registry.js, src/config/scenarios.js) — two lists declaring what the suite tests: which grids exist with their asset paths, and which scenarios exist.
- Core (src/) — how tests run.
src/core/holds paths, ports, flags and process handling, each declared once.src/bench/is the Playwright orchestrator and the in-page measurement.src/data/generates and serves datasets.src/report/reduces the history to numbers and renders them. - Viewer (src/viewer/) —
index.htmlis the manual exploration UI.host.htmlis the minimal page the runner loads in an iframe to measure one grid, one scenario, one dataset. - Results (results/) —
latest.jsonplus thehistory/archive the report reads.
Port 8081 serves datasets. Port 5173 serves the viewer and the benchmark host. Keeping them separate is why there are three commands instead of one: the UI can restart without discarding the dataset cache, and a long benchmark can run against a persistent dataset server.
-
Create
src/adapters/<id>.jsand default-export an object satisfying the contract in src/adapters/contract.js:id,namemapColumns(columns, scenario)— your grid's column format. Build it withbuildColumns()from src/adapters/_shared.js, so renderer and pinning decisions stay identical to the other adapters'.create(container, { columns, data, scenario })— instantiate the grid. May return a Promise.getRenderedRows(container)— the data rows currently in the DOM, one element per row. This defines the render timing, so it has to deduplicate the copies a grid makes for frozen columns. UserowElements()from_shared.js.getScrollContainer(container)— the scrollable DOM node.
src/adapters/index.js checks the contract on load, so a missing or mis-shaped method fails immediately and by name. src/adapters/dhtmlx.js is the smallest reference implementation.
-
Register it in src/core/registry.js with its JS and CSS assets.
-
Open
http://localhost:5173/?grid=<id>&scenario=tpl10and check it renders, then runnpm run bench -- --grid=<id>.
Add an entry to src/config/scenarios.js. The viewer and the runner pick it up automatically. No adapter changes unless the scenario needs a new flag.
npm ci && npm run vendorThat's the whole setup; nothing is downloaded by hand. All grids are served from the same vendor/
directory, so the harness has no CDN variance.
vendor/ is not committed. These are commercial trial builds, and redistributing them isn't ours
to do. Installing them from npm isn't redistribution: every package below is published to a public
registry by its own vendor, anonymously installable, and npm run vendor only copies what npm ci
already put in node_modules.
Versions are pinned exactly in dependencies, never with a ^ range. A published number has to name
the build it measured, and a range re-resolves on every install, so two people running this would
quietly measure different libraries. Bumping one is an explicit commit.
| Folder | Grid | Package |
|---|---|---|
ag-grid/ |
AG Grid Community | ag-grid-community |
bryntum/ |
Bryntum Grid | @bryntum/grid-trial |
devextreme/ |
DevExtreme DataGrid | devextreme — built by the vendor's own bundler, see METHODOLOGY.md §2 |
dhtmlx/ |
DHTMLX Grid Pro | @dhx/trial-grid, from DHTMLX's own registry (see .npmrc; anonymously readable, no account or token) |
jquery/ |
jQuery | jquery — shared dependency of Kendo UI and DevExtreme |
kendo/ |
Kendo UI Grid | @progress/kendo-ui for the widgets, @progress/kendo-theme-default for the stylesheet |
npm run vendor writes vendor/.versions.json with the version of every package it laid down, and
the runner copies that into every results file. A result that can't name its versions records null
there instead of looking complete, which is what happens if vendor/ was populated by hand. Grids
whose assets are missing fail to load with a readable error in the viewer; the others keep working.
npm run vendor --only=<ids> populates a subset. The manifest is merged instead of overwritten, so a
partial run doesn't erase the versions of folders it didn't touch, and it records which ones were
refreshed. A result from a partial run can't be mistaken for a full one.
