Performance at scale
Where this framework's numbers actually sit, measured rather than claimed, and the honest limits: what to do at 5,000 rows, 200 columns, and on the ten-year-old laptop your users are issued.
Measured, not guessed
Full table stack (sticky header, row checkboxes, badges, container
query, initDataTables()) in the
examples/po-app stress harness — the same numbers on the
data table page, repeated
here because they are the framework's scaling story:
| Rows | Select-all |
|---|---|
| 1,000 | 4 ms |
| 5,000 | 18 ms |
| 20,000 | 49 ms |
| 5,000 4× CPU throttle | 15 ms |
| 20,000 4× CPU throttle | 60 ms |
The throttled rows approximate the corporate hardware ERP users actually get. Read them as the real numbers, not the optimistic ones.
This table carried an Initial render
column until 2026-09-08, when it was withdrawn: its
measurement window was never written down, so nothing can re-run it and
nothing can check even its shape. The
data table page carries the
withdrawal in full, and npm run measure:stress -w docs is the
kept probe that reports a defined render window — on a recorded four-core
container it reads ~4.1 s at 20,000 rows, which is the
figure the data table page's guidance now rests on.
The decisions those numbers imply
| Situation | Do this |
|---|---|
| Up to ~1,000 rows | Render them. Native scrolling, find-in-page and print all keep working. |
| ~1,000–5,000 rows | Still fine on modern hardware; add pagination or load-more before it becomes a habit. |
| Beyond ~5,000, searching | Paginate server-side. The framework ships no virtualiser for search — see below. |
| Beyond ~5,000, scanning | Load-more while the feed is bounded; a windowed list only once memory must stay flat. |
| 200 columns | Horizontal scroll plus a frozen first column (--sticky-col); offer a column chooser rather than shrinking type past legibility. |
| Bulk select at scale | initDataTables() keeps the selection count O(1) rather than re-counting the DOM; the style flush after checking 20,000 boxes is the cost, and it is the browser's, not ours. |
| Slow aggregates on a dashboard | Load each widget as its own fragment so one slow query never blocks the screen — the dashboard pattern's data contract. |
Why there is no virtualiser for search
Virtualisation buys smooth scrolling at 50,000 rows and costs find-in-page, native keyboard scrolling, correct print output, and screen-reader row counts — the things back-office users rely on daily. It also forces every consumer into a JS render loop, which is the opposite of this project's stance. Server-side pagination keeps all of it and is what a real ERP search screen does anyway — if your screen is genuinely about searching 50,000 rows, this framework's answer is still filters and page numbers, full stop.
What changed: this refusal was narrowed, not reversed, on 2026-08-22 (roadmap 30.4b) — see below for the case it no longer covers.
Windowed list — the scanning exception
initWindowedList() exists for the other case in the
decision table above: scanning, not searching — a
monitoring feed, an audit log, a list someone pages through start to
finish rather than filters down. Everything above about search still
holds; this doesn't reopen it, it names the one case it never covered.
| Row | Reference |
|---|---|
| 51 | REF-000051 |
| 52 | REF-000052 |
| 53 | REF-000053 |
| 54 | REF-000054 |
View code
<table class="bo-data-table" data-windowed data-window-chunks="3"
data-table-total-rows="50000">
<thead><tr><th scope="col">Row</th><th scope="col">Reference</th></tr></thead>
<!-- Scrolled past and evicted: ONE spacer stands in for this chunk's 50
rows. Height = rowCount x a row height measured from one real row at
the FIRST eviction and cached, so no eviction after that forces a
layout read. Shown at 120px so the whole shape fits one screen;
running, this spacer is about 2,000px. -->
<tbody data-chunk-id="c0" data-chunk-offset="0" data-evicted="true">
<tr class="bo-data-table__spacer" style="block-size: 120px"><td colspan="2"></td></tr>
</tbody>
<!-- The chunk currently in view: real rows, real data-row-id. -->
<tbody data-chunk-id="c1" data-chunk-offset="50">
<tr data-row-id="R-51"><td>51</td><td>REF-000051</td></tr>
<tr data-row-id="R-52"><td>52</td><td>REF-000052</td></tr>
<tr data-row-id="R-53"><td>53</td><td>REF-000053</td></tr>
<tr data-row-id="R-54"><td>54</td><td>REF-000054</td></tr>
</tbody>
<!-- Not yet scrolled to: evicted the same way, symmetrically — this is
bidirectional windowing, not append-only infinite scroll. Same
illustrative 120px; same real ~2,000px. -->
<tbody data-chunk-id="c2" data-chunk-offset="100" data-evicted="true">
<tr class="bo-data-table__spacer" style="block-size: 120px"><td colspan="2"></td></tr>
</tbody>
</table> A chunk that scrolls far enough away
evicts to a computed-height spacer: its height is
rows × row height, and that row height is measured from
one real row once, at the first eviction, then cached —
so no eviction after that forces a layout read on the exact operation
this behavior exists to make cheap. (Deriving it from
--bo-density-row-height instead was tried and reverted:
real rows render taller than the token, so token-derived spacers ran
~250px short per 100-row chunk and the list jumped as you scrolled. The
token remains the fallback when no real row has rendered yet.) A spacer
re-requests its rows (the same bo:table-load-more
event load-more already
fires, now carrying an optional chunk-offset detail) when scrolled
back toward. Selection survives eviction too: a checked row's
data-row-id lives in a Set outside the DOM, so a bulk
action still reaches every row you picked, not just whatever's
currently rendered.
Named costs, not hidden ones:
browser find-in-page cannot find an evicted row, and printing gets
only what's currently loaded. Both are the same argument for
filtering server-side first — windowing is for the real remainder
after that, not a replacement for it. And it does not compose with
data-grid-nav's
two-axis keyboard grid in this version: that behavior's own
aria-rowindex assumes DOM position equals true position,
which windowing breaks by design.
So what do I use, and when?
The previous section says what this framework will not build. This one says what to do instead, which is the half that was missing.
| Rows the user faces | What the user is doing | Use |
|---|---|---|
| up to ~2,000 | reading and acting on a filtered list | plain data table — nothing special |
| ~2,000 – 50,000 | searching, then acting on a few | filter server-side and paginate — page numbers. The right answer for most ERP lists |
| ~2,000 – 50,000 | scanning forward through a bounded feed | load-more (initLoadMore(), with data-load-more-auto for infinite scroll) — memory grows as you scroll, fine for a feed nobody scrolls indefinitely |
| 50,000+, unbounded | scanning where memory must stay flat, not just grow slower | a windowed list (initWindowedList()) — evicts what's off-screen instead of only ever appending |
| any | tabbing across cells all day — planning boards, price matrices, allocation grids | a real grid. Roughly one screen in twenty; see the recipe below |
The search and scanning rows can carry the same row count and still get different answers, because the question that decides it is not "how many rows" but what the user is doing with them. Someone searching wants filters and page numbers; someone scanning wants the next batch to appear — reaching for windowing when the real problem is a missing filter is still the most common way to make a screen slower and less accessible at once. Windowing earns its place only once scanning itself needs memory to stay flat, not merely once row counts get large.
The residual case: a real grid, themed with these tokens
For the one screen in twenty that genuinely is a spreadsheet, use a grid that already solved virtual scroll, and make it look like the rest of your app by pointing its variables at these tokens. AG Grid is the example because its theming is CSS variables; the same mapping idea works for any grid that exposes them.
/* your-app.css — after both stylesheets */
.ag-theme-quartz {
--ag-background-color: var(--bo-color-bg-surface);
--ag-foreground-color: var(--bo-color-text-primary);
--ag-secondary-foreground-color: var(--bo-color-text-secondary);
--ag-header-background-color: var(--bo-color-bg-surface-raised);
--ag-border-color: var(--bo-color-border-default);
--ag-row-hover-color: var(--bo-color-bg-hover);
--ag-selected-row-background-color: var(--bo-color-accent-subtle);
--ag-font-size: var(--bo-density-font-size);
--ag-row-height: var(--bo-density-row-height);
--ag-cell-horizontal-padding: var(--bo-density-cell-padding-x);
}
/* Density comes along for free: the tokens above are already
density-aware, so data-density="compact" on an ancestor
re-sizes the grid with the rest of the screen. */ What you are buying and paying. You get virtual scroll, column
virtualisation and cell editing that this framework deliberately does not own. You
pay a JS render loop, a second theming system to keep in sync, and the
accessibility work that windowing always costs — find-in-page misses unrendered
rows, printing gets only what is rendered, and the grid must publish
aria-rowcount itself or a screen reader reports the wrong size. Those
are not reasons to avoid it on the screen that needs it; they are reasons not to
reach for it on the nineteen that do not.
The bytes
The whole framework is 0.9.0 at 98 kB minified / 16.1 kB gzipped of CSS, plus optional behaviors — smaller than most single component libraries' JS runtime, and the CSS is cacheable across every screen. Per-component files ship too, so a single-screen pilot can import three files and nothing else. There is no build step to tune, no tree-shaking to configure, and no runtime cost per component instance: a table with 20,000 rows loads exactly the same stylesheet as a login form.