busy-office-ui
Menu

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:

RowsSelect-all
1,0004 ms
5,00018 ms
20,00049 ms
5,000 4× CPU throttle15 ms
20,000 4× CPU throttle60 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

SituationDo this
Up to ~1,000 rowsRender them. Native scrolling, find-in-page and print all keep working.
~1,000–5,000 rowsStill fine on modern hardware; add pagination or load-more before it becomes a habit.
Beyond ~5,000, searchingPaginate server-side. The framework ships no virtualiser for search — see below.
Beyond ~5,000, scanningLoad-more while the feed is bounded; a windowed list only once memory must stay flat.
200 columnsHorizontal scroll plus a frozen first column (--sticky-col); offer a column chooser rather than shrinking type past legibility.
Bulk select at scaleinitDataTables() 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 dashboardLoad 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.

RowReference
51REF-000051
52REF-000052
53REF-000053
54REF-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 facesWhat the user is doingUse
up to ~2,000reading and acting on a filtered listplain data table — nothing special
~2,000 – 50,000searching, then acting on a fewfilter server-side and paginate — page numbers. The right answer for most ERP lists
~2,000 – 50,000scanning forward through a bounded feedload-more (initLoadMore(), with data-load-more-auto for infinite scroll) — memory grows as you scroll, fine for a feed nobody scrolls indefinitely
50,000+, unboundedscanning where memory must stay flat, not just grow slowera windowed list (initWindowedList()) — evicts what's off-screen instead of only ever appending
anytabbing across cells all day — planning boards, price matrices, allocation gridsa 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.

↑↓ move Enter open Esc close