Performance
How the grid behaves at scale, and what to do about it.
What changed in 0.2
Version 0.1 was fine on demo data and fell over on real data. The reason was structural rather than incidental.
Every paint asked for "the visible rows" from five places — the ARIA row count, the header, the body,
the footer, the virtualiser — and each call re-ran the whole pipeline, including a deep clone of the
entire dataset. Then each rendered row asked twice more, to compute aria-posinset and
aria-setsize, and each of those re-walked the whole visible list.
For 1,000 rows that is over 2,000 full clones per paint.
Version 0.2 changed three things:
- The pipeline is memoised on a single cache slot keyed by the inputs. Every consumer within a paint after the first gets the same object back.
- The pipeline is non-mutating. It reads the original nodes and shares untouched branches by reference, so no clone is needed at all.
- ARIA positions are precomputed during the single flatten pass, rather than derived per row.
The work is the same filter and the same sort. It now happens once per input change instead of thousands of times per paint.
| Rows | 0.1, per paint | 0.2, per input change |
|---|---|---|
| 100 | ~200 tree clones | 1 pass |
| 1,000 | ~2,000 tree clones | 1 pass |
| 10,000 | unusable | 1 pass |
Virtual scrolling
Above roughly 200 visible rows, the cost stops being the pipeline and starts being the DOM. Virtual scrolling renders only the rows near the viewport and reserves the rest with spacer elements, so the scrollbar still reflects the whole dataset.
<!-- always on -->
<bm-treelistview virtualize="true" row-height="38"></bm-treelistview>
<!-- on automatically once the pipeline produces more than 200 rows -->
<bm-treelistview virtualize-threshold="200" row-height="38"></bm-treelistview>
The threshold exists because virtualisation is a trade. It makes 100,000 rows possible, and it makes 30
rows marginally worse: row heights become fixed, and a scroll listener runs that otherwise would not.
virtualizeThreshold gets both — a small grid stays simple, a large one switches automatically.
rowHeight must be right
The spacers are computed from rowHeight. If it does not match what a row actually renders at, rows
overlap or leave gaps.
| Density | row-height |
|---|---|
compact |
32 |
comfortable |
38 |
spacious |
48 |
If you change padding or font size through --tlv-* variables, measure a row and set row-height to
match.
overscan
Rows rendered beyond each edge of the viewport. Default 8.
Without overscan, a row is created in the same frame it becomes visible, and fast scrolling shows a band of blank space. Raise it to 12–16 for a grid people flick through; lower it to 4 if each row is expensive to render.
Jumping to a row
await grid.scrollToNode('row-84210', 'center');
await grid.scrollToIndex(50000, 'start');
'auto' (the default) does nothing when the row is already visible — which is what keyboard navigation
wants, and why arrow keys do not make the view jump.
Revealing a buried row
await grid.expandToNode('deep-node-id'); // opens every ancestor, then scrolls
Where the time actually goes
In rough order, for a large grid:
- Filtering — one pass over every node. Cheap per node, but it runs on every keystroke.
- Sorting — O(n log n) comparisons. Type-aware comparison costs a little more than raw string
compare; the cached
Intl.Collatorkeeps that small. - Flattening — one pass.
- Rendering — proportional to the number of rows in the DOM, which virtualisation caps.
Filter before sort, which the pipeline does, because filtering usually removes most rows and sorting is the expensive stage.
Practical guidance
Debounce the filter box above a few thousand rows
Each keystroke re-runs filter and sort.
input.addEventListener('input', debounce(() => grid.setFilterText(input.value), 150));
The built-in filter box does not debounce, because for the common case an immediate response is better.
Prefer keyed cells
cells: { name: 'Platform', owner: 'Priya' } // object lookup
cells: ['Platform', 'Priya'] // array index
Both are fast. Keyed objects are safer, and the difference is not measurable.
Replace arrays; do not mutate them
The pipeline cache is keyed on a revision counter that the component bumps whenever the data is replaced. Mutating a node object in place changes what is on screen without bumping anything, and the cache will happily serve a stale result.
// ✅
await grid.setItemsData([...items, newRow]);
await grid.updateNode('p1', { budget: 5000 });
// ❌ mutates a node the grid is holding
items[0].cells.budget = 5000;
// ...if you must, tell the grid:
await grid.refresh();
Use updateNode() for small changes
await grid.updateNode('p1', { status: 'done' });
Rebuilds only the branch containing that node and reuses every other branch by reference, rather than re-normalising the whole dataset.
Load a tree lazily
The fastest way to render 100,000 rows is to render 40 of them. See Lazy loading.
Keep custom renderers cheap
A cellRenderers function runs for every visible cell of that column on every paint. Do not build a
Date, run a regular expression, or query the DOM inside one — precompute into the cell value instead.
Aggregates cost one pass
aggregate on a column adds a pass over the group's rows. Several aggregated columns share a single
pass. On 100,000 rows with grouping this is noticeable; below 10,000 it is not.
Measuring
grid.addEventListener('tlvRowsRendered', event => {
const { startIndex, endIndex, total } = event.detail;
console.log(`painted ${endIndex - startIndex} of ${total} rows`);
});
If endIndex - startIndex is in the thousands, virtualisation is off or the threshold is too high.
For pipeline timing, the unit test suite includes scale tests that flatten, filter and sort 10,000 rows and assert an upper bound — a regression back to quadratic behaviour fails the build rather than reaching a customer.
Realistic expectations
Measured on a mid-range laptop, Chromium, mode="list", virtualised:
| Rows | First paint | Filter keystroke | Sort click |
|---|---|---|---|
| 1,000 | instant | instant | instant |
| 10,000 | < 100 ms | ~20 ms | ~30 ms |
| 100,000 | ~400 ms | ~150 ms | ~300 ms |
Above 100,000 rows, move the filtering and sorting to your server and feed the grid a page at a time. No client-side grid makes a million rows pleasant, and the honest answer is to stop trying.