Vanilla-Grid Performance Analysis

An audit of the Vanilla-Grid codebase identifying performance problems and improvements. Each item gives the location, the current behaviour, the measured or estimated impact, and a recommended change. Items are grouped by category and keep their numbers across revisions — other documents cite them — so within a category they appear in the order they were found. Open items at a glance and Recommended Priority rank them.

Last full re-analysis: 2026-09-23. What changed in it is listed under Revision notes; how the figures were taken is under Method.


Open items at a glance

Ranked by value for effort. "What-if" figures come from emulating the fix inside the benchmark page (see Method): they bound the gain, they are not shipped behaviour.

Rank § Finding Impact Headline measurement Effort
1 2.5 Every render pass forces a layout between pool rotation and the cell writes High ~75% of every scroll-frame render pass; two layouts per frame instead of one Low
2 3.4 Selection key map rebuilt on every setRows(), even with selection off High (large data) ~250 ms of the ~315 ms stall of every million-row reload, filter clear and search clear; 33 MB Low–Medium
3 2.6 Scaled virtualization lays the table out three times per frame High (large data) What-if: render pass 15.6 → 2.6 ms, layouts 3 → 1 per frame Medium
4 1.6 Frozen-column styles rewritten on every cell of every render pass Medium 1.2 ms per pass plus style invalidation; with § 2.5 the pass goes 8.8 → 1.4 ms Low
5 2.7 Tooltip measurement on every drag frame and once per auto-fitted column Medium Drag: 3.0–3.9 ms per frame; autoFitAllColumns() 285 → 185 ms Low
6 3.5 The toolbar copies the whole selection on every state recompute Medium 35–40 ms per grid event with a million rows selected Low
7 6.8 appendRows() re-sorts the whole dataset per page under a client sort Medium (narrow reach) 100k rows in 1k pages: +2.9 s of sorting, 43 long tasks Medium
8 6.3 One full buildRenderEntries() scan per grouped reorder Medium 210–280 ms at a million rows High
9 5.3 Sample renderCells create a paint layer per positioned span Low (sample code) Paint time +59% while scrolling Low

Revision notes

2026-09-23 — § 6.5 re-measured

2026-09-23 — Excel export rewritten

2026-09-23 — full re-analysis


Method

Measured on 2026-09-23 on an AMD Ryzen 7 5700U — the machine § 6.7 used — in Playwright 1.59's headless Chromium shell at 1400×900, against the unminified source tree served with caching disabled. Unless stated otherwise the fixture is samples/grid-minimal-js/?rows=N (add &group=none for a flat grid): 21 data columns plus the selection column, a 37 px row (Fiori theme) and a 61–64-row pool. Column work also used northwind-orders-js (25 columns, 51 pool rows) with its backend stubbed by tests/playwright/_northwind-stub.js, as the auto-fit guard does.

The headless shell rasterizes in software, which makes painting expensive, so absolute frame intervals are pessimistic next to a GPU-backed browser. Layout counts, forced-layout counts and relative differences carry over. Assume slower hardware is 2–4× worse, as § 6.7 does.


1. Rendering Hot Path

1.1 Checkbox column rebuilds DOM every render cycle — HIGH ✅ DONE

Location: features/rendering.feature.jsrenderVisibleRows(), inner cell loop

Current behaviour:
For every selection-checkbox column cell on every render pass, the code calls cell.innerHTML = '', creates a new <input> element, attaches a new change event listener, and appends it. This happens for every visible row on every scroll, not just when the row data changes.

Impact: Creates and destroys DOM nodes and event listeners at ~60 fps scroll rate. The garbage-collected listener closures and DOM churn are the single most impactful bottleneck in the render loop.

Recommendation:
Reuse the existing checkbox element when the cell already contains one. Only update checked state and dataset.vnGridRowIndex. Create the checkbox only on the first pass (or when the pool row is first assigned a cell count). Move the event listener to a single delegated handler on the <tbody> using data-vn-grid-row-index lookup instead of per-checkbox listeners.

Implemented: Checkbox is now reused via cell.firstElementChild check — only created on first pass. Event handling moved to a single delegated change listener (_onBodyCheckboxChange, features/interaction-pointer.feature.js) on the viewport, eliminating per-checkbox listener closures.


1.2 updateTooltips() queries all body cells — HIGH ✅ DONE

Location: features/viewport.feature.jsupdateTooltips() (in interaction.feature.js when this item was written)

Current behaviour:
this.body.querySelectorAll('td') returns every <td> in the body — including spacer cells and hidden pool rows. For each cell it reads scrollWidth and clientWidth (triggering a layout reflow) and then writes title.

Impact: For a pool of 80 rows × 10 columns = 800 cells, this performs 800 forced-reflow reads on every tooltip update. The method is scheduled 300 ms after every scroll settle.

Recommendation:
Iterate only over visible pool rows (this.poolRows[0..clampedVisibleCount]) and their child cells. Skip cells with display: none parent rows. Batch the reads first, then batch the writes to avoid layout thrashing.

Implemented: Replaced this.body.querySelectorAll('td') with iteration over visible pool rows only (breaking at first hidden row). All scrollWidth/clientWidth reads are batched into an array first, then all title writes are applied in a second pass, eliminating layout thrashing.

Bug fix (right-aligned columns): scrollWidth only detects overflow that extends to the right. For vn-grid-temporal (date/datetime/time) and vn-grid-numeric columns, text is right-aligned so overflow goes to the leftscrollWidth stays equal to clientWidth and no tooltip was set. Fixed by adding a canvas measureText fallback for those cell types: a hidden <canvas> 2D context is created lazily, and its measureText(text).width is compared against cell.clientWidth - paddingLeft - paddingRight. Because canvas measurement bypasses CSS overflow/clipping entirely it reliably detects left-side truncation. Font and padding are read once per column index via getComputedStyle and cached in a Map for the duration of the update pass. Note: the Range.getBoundingClientRect() approach does not work here because browsers clip the Range rect to the overflow: hidden boundary, giving a false negative for left-side overflow.

Bug fix (small-overflow tooltip miss): scrollWidth and clientWidth are integer-rounded, so sub-pixel overflow (< 1px) is invisible to the comparison even though CSS renders the ellipsis. The canvas measureText fallback (which provides sub-pixel accuracy) has been promoted to the universal detection path for all cell types and header texts — not just right-aligned columns. The scrollWidth > clientWidth check is kept as a fast path for large overflows; when it returns false, the canvas measurement is used as a precise fallback.

On the scroll path the pass stays debounced (300 ms after the last render) and costs 4–5 ms on a 20-column, 61-row pool. Two column paths call it synchronously instead — see § 2.7.


1.3 Intl.NumberFormat created on every formatInteger call — MEDIUM ✅ DONE

Location: vanilla-grid.js_initFormatting(), the default formatInteger

Current behaviour:

this.formatInteger = (value) => {
    try { return new Intl.NumberFormat(this.locale).format(value); }
    catch (err) { return String(value); }
};

A new Intl.NumberFormat is instantiated every time formatInteger is called — once per scroll-indicator update.

Impact: Intl.NumberFormat construction is relatively expensive (~0.1 ms). During scrollbar drag this can fire hundreds of times.

Recommendation:
Cache the Intl.NumberFormat instance in the constructor (or lazily on first use) and reuse it. Invalidate only if this.locale changes.

Implemented: Added _intlNumberFormat field initialized to null. The default formatInteger now lazily creates and caches the Intl.NumberFormat instance on first use, reusing it for all subsequent calls.


1.4 renderHeader() uses innerHTML for header cell construction — MEDIUM ✅ DONE

Location: features/rendering.feature.jsrenderHeader()

Current behaviour:
Each header cell is built via an innerHTML assignment containing concatenated HTML strings:

th.innerHTML = '<div class="vn-header-label"><div class="vn-header-text-wrapper">…</div></div>';

Impact: Each innerHTML write triggers HTML parsing. For the header this runs once per setColumns/refreshHeaderLayout, not on scroll, so it is not critical-path. However, the string concatenation with user-supplied rawLabel is an XSS surface if labels contain unescaped HTML.

Recommendation:
Replace with document.createElement / textContent for both performance and security. This also avoids the HTML parser overhead.

Implemented: Replaced all three innerHTML usages with DOM API calls. Header cell structure (div.vn-grid-header-label > div.vn-grid-header-text-wrapper > span.vn-grid-header-text + optional span.vn-grid-sort-indicator + optional span.vn-grid-header-secondary-text) is now built via createElement/textContent. Selection column header unified — both multiple and single modes use the same wrapper path. rawLabel and secondaryText use textContent, eliminating the XSS surface.


1.5 decorateCell called without change detection — MEDIUM ✅ DONE

Location: features/rendering.feature.jsrenderVisibleRows()

Current behaviour:
When a decorateCell callback is provided, it is invoked for every cell on every render pass, even when the same row data is already displayed in that pool row.

Impact: If decorateCell performs DOM mutations (class toggling, style changes, child creation), these run unnecessarily on unchanged data. The extent depends on the callback implementation.

Recommendation:
Track the last-rendered dataIndex on each pool <tr> element (already done via dataset.vnGridRowIndex). Skip decorateCell when the pool row already displays the same data index and the render is not a forced full-refresh.

Implemented: Before updating dataset.vnGridRowIndex, the previous value is captured. A rowChanged flag (force || prevDataIndex !== String(dataIndex)) gates the decorateCell call, skipping it entirely when the pool row already displays the same data index.

Update (2026-09-23): the decorateCell option no longer exists. The gate it introduced now covers every cell write, renderCell included. rowChanged compares the pool row's rendered entry index and projection version (dataset.vnGridEntryIndex, dataset.vnGridProjectionVersion), so a row that did not change is not written at all.


1.6 Frozen-column styles are rewritten on every render pass — MEDIUM

Location: features/rendering.feature.js — end of renderVisibleRows() (this._applyFrozenStylesToPoolRows()); features/columns-freeze.feature.jsapplyFrozenStylesToPoolRows()

Current behaviour:
With any column frozen, every render pass recomputes the frozen offsets and then walks every cell of every pool row. That means every scroll frame that moves the window. Frozen cells get classList.add(…) and a style.left. Every other cell gets classList.remove('vn-grid-frozen-col', 'vn-grid-frozen-col-last') and style.left = ''. A class-list mutation rewrites the class attribute even when nothing changes, so each pass also invalidates style across the whole pool.

Nothing on the render path changes these classes. Pool rows keep their cells, and caption and footer population only touch other classes. The styles need applying once per pool and again when the frozen set or its widths change; applyFrozenColumnStyles() already handles the second case. The per-pass call is currently also how a fresh pool gets them, because initVirtualPool() applies none.

Impact: 100k rows, one frozen column plus the selection column, custom renderers hidden, 240 frames:

render pass frozen-style call style recalc / frame main-thread task / frame frames > 20 ms
as is 8.8 ms 1.2 ms 3.7 ms 26.6 ms 140
what-if: applied once per pool 6.6 ms ~0 3.2 ms 21.9 ms 77
what-if: that and § 2.5 1.4 ms ~0 2.8 ms 20.1 ms 53

In this environment freezing one column drops the frame loop to a 33 ms median interval; applying the styles once per pool brings it back to 16.7 ms.

Recommendation:
Apply frozen cell styles at the end of initVirtualPool() and from applyFrozenColumnStyles(), then drop the per-pass call. Alternatively, keep the call but gate it on a pool generation that initVirtualPool() bumps; that is what the what-if does. If per-pass application has to stay for some reason, touch only the first frozenCount + 1 cells of each row. The non-frozen tail needs clearing only when the frozen count shrinks.


2. Layout & Reflow

2.1 freezeAllColumnWidths forces layout with void headerTable.offsetHeight — MEDIUM ✅ DONE

Location: features/columns-resize.feature.jsfreezeAllColumnWidths()

Current behaviour:
When clearExisting is true, the method clears all <col> widths, then reads headerTable.offsetHeight to force a synchronous layout reflow, then measures each header cell via getBoundingClientRect().

Impact: The forced reflow is intentional to get accurate measurements, but it interleaves read/write cycles. The subsequent per-column getBoundingClientRect() calls each cause another micro-reflow.

Recommendation:
Clear all widths, force one reflow, then batch-read all getBoundingClientRect() values into an array in a single pass before writing any widths back. This collapses N reflows into 1.

Implemented: freezeAllColumnWidths now follows exactly this shape: clear → one intentional reflow (void headerTable.offsetHeight) → a pure read loop collecting every width into the widths array → a write loop applying them. No writes are interleaved with the measurement reads.


2.2 syncHeaderHorizontalScroll queries DOM on every scroll — MEDIUM ✅ DONE

Location: vanilla-grid.jssyncHeaderHorizontalScroll()

Current behaviour:
On every handleScroll event, this method:

  1. Reads this.viewport.scrollLeft
  2. Writes headerTable.style.transform
  3. If frozen columns exist: querySelector('tr'), Array.from(children), then loops writing style.transform per frozen header cell

Impact: The querySelector and Array.from allocations happen on every scroll event, even when scroll position hasn't changed horizontally. The frozen-cell loop re-evaluates .contains('vn-grid-frozen-col') each time.

Recommendation:

Implemented: _lastSyncedScrollLeft short-circuits the method when viewport.scrollLeft is unchanged (the vertical-scroll common case now exits after one property read). The header <tr> element is cached in _cachedHeaderRowEl by renderHeader(), which also resets the gate to −1 so freshly rebuilt cells are always re-synced. The frozen-prefix walk itself stays live (children + break at first non-frozen cell — O(frozen+1)) because freeze classes can be retoggled without a header rebuild (viewport-resize clamps), which would invalidate a cached cell list.

Re-measured during vertical scrolling: 0.015–0.02 ms per call.


2.3 _computeFrozenLeftOffsets reads offsetWidth per frozen column — LOW ✅ DONE

Location: features/columns-freeze.feature.js_computeFrozenLeftOffsets()

Current behaviour:
Reads ths[i].offsetWidth for each frozen column. offsetWidth forces a layout reflow if the layout is dirty.

Impact: Low because this is called from applyFrozenColumnStyles which typically runs after layout-settling operations. But it could compound when called from within the resize rAF handler.

Recommendation:
Use the <col> element style.width (which is already set and authoritative under table-layout: fixed) instead of measuring offsetWidth. This avoids any reflow.

Implemented: The offsets loop now parses headerColGroup.children[i].style.width (written by freezeAllColumnWidths / clamping) and falls back to offsetWidth only when no inline width has been frozen yet.


2.4 _measureColumnFitWidth forces a reflow per measured cell — HIGH ✅ DONE

Location: features/columns-resize.feature.js_measureColumnFitWidth()

Current behaviour: Auto-fit measured one string at a time through a _measureTextWidth helper that appended a hidden probe <span> into the live th/td, read span.getBoundingClientRect().width, and removed it again. Every measurement was a write → read → write cycle, so every read forced a full synchronous re-layout of the entire table.

Impact: The worst instance of §2.1's pattern in the codebase, and the only one users could see. On samples/northwind-orders-js/ (25 columns x 51 pool rows, 1600x900) one autoFitAllColumns() performed 701 measurements driving 2036 getBoundingClientRect() calls at ~0.58ms each, and blocked the main thread for ~1476ms with zero frames painted — the grid appeared hung, then repainted once with the fitted widths. _measureColumnFitWidth accounted for 1225ms (83%) of the total. Note what was not implicated: 1268 getComputedStyle calls cost 1.3ms combined. Only the interleaved geometry reads were expensive.

Recommendation: Apply §2.1's split at column granularity: append every probe span for the column, read every rect in one pass, then detach and compute. Pull the remaining geometry reads (sort-indicator offsetWidth, cell widget rects) into the same read pass, since the detach is a write and anything read after it forces yet another reflow.

Implemented: _measureColumnFitWidth now runs three explicit phases — append all probes / read all geometry / detach and compute — and the _measureSpan singleton was replaced by a module-level pool, since N probes are now live simultaneously. Batching is safe because the probes are position:absolute (out of flow) and white-space:nowrap (intrinsic width, independent of the column's current width), so N live probes measure identically to one probe reused N times.

Measured on the same grid: 1476ms → ~350ms (4.2x), with all 25 header and body <col> widths byte-identical to the previous implementation. After the fix, measurement is no longer dominant (148ms); the residual is the per-column apply work autoFitAllColumns repeats 24 times (adjustTableWidthAfterResize 60ms, applyFrozenColumnStyles 19ms, freezeAllColumnWidths 17ms). Collapsing those into a single apply pass is the next available win (~200ms) but touches the stretch-to-fit coordination and persistence invariants (11-column-resizing-implementation.md §7.1, §10), so it was left out of a pure-measurement fix.

Guarded by tests/playwright/autofit-performance.spec.js (800ms budget; verified to fail at ~1.5s against the previous implementation) and by the idempotence test in tests/playwright/autofit.spec.js, which pins the probe-independence property the batching relies on.

Correction (2026-09-23): an earlier revision attributed the ~33k clientWidth reads that profile recorded to _getVisibleColumnCount(). They come from updateTooltips(), which autoFitColumn() runs once per column. _getVisibleColumnCount() reads one rect per header cell and costs ~1.4 ms per auto-fit. Re-measured in this environment, one autoFitAllColumns() on the same grid takes 285 ms (median of 5). Current per-call costs of the residual apply work, and the fix, are in § 2.7.


2.5 Every render pass forces a layout between pool rotation and the cell writes — HIGH

Location: features/rendering.feature.jsrenderVisibleRows()_updateSpacerHeights()_getVirtualMetrics()features/viewport.feature.js_getViewportHeight()

Current behaviour:
renderVisibleRows() reads what it needs up front: viewport.scrollTop, the viewport height, and _getVirtualMetrics(). Then it writes. _rotatePool() moves the rows that left the window to the other end of the pool with insertBefore, and the cell writes follow. Between those two writes it calls _updateSpacerHeights(). That calls _getVirtualMetrics() again, which calls _getViewportHeight(), which reads viewport.clientHeight (the function is documented as "always measured live").

The rotation has just dirtied layout, so that read forces a synchronous style recalc and a full layout of the table in the middle of the pass. The layout is wasted at once: the cell writes that follow dirty the same rows again, and the frame lays the table out a second time.

Impact: Every grid pays one extra layout on every scroll frame that renders. A trace of 120 wheel-driven frames (100k rows, natural mode) recorded 119 forced layouts, each attributed to _getViewportHeight ← _getVirtualMetrics ← _updateSpacerHeights. That chain accounts for ~75% of the render pass. The share is the same when grouped (4.9 of 6.5 ms) and with search highlighting active (5.3 of 7.1 ms).

grid-minimal-js, 100k rows, 240 frames render pass layouts / frame style + layout / frame main-thread task / frame frames > 20 ms
custom renderers hidden, as is 5.90 ms 1.99 7.9 ms 19.3 ms 40
custom renderers hidden, what-if 1.28 ms 1.00 4.0 ms 14.3 ms 1
sample as shipped, as is 6.63 ms 1.99 9.5 ms 23.9 ms 104
sample as shipped, what-if 1.62 ms 1.00 5.6 ms 19.7 ms 46

The what-if has _getViewportHeight() return the value the pass read at its top.

Recommendation:
Hand the pass's own values to _updateSpacerHeights() instead of letting it re-read the DOM: the vm computed at the top of renderVisibleRows(), which already carries viewportHeight, and domScrollTop, which also removes the scaled-mode read in § 2.6. Its other call sites can keep reading. The short-circuit branch, _onViewportResized() and the fix-up after measureActualRowHeight() all run before any write in their task.

A broader alternative is to serve _getViewportHeight() from _cachedViewportHeight, which the ResizeObserver path already maintains. That removes the whole class of hazard, but it changes a function documented as "always measured live", so its callers need auditing first.


2.6 Scaled virtualization lays the table out three times per frame — HIGH

Location: features/rendering.feature.js_updateSpacerHeights() (scaled branch: const domScrollTop = this.viewport.scrollTop) and the deferred buffer-row fill in renderVisibleRows() (step "2c"); features/interaction.feature.jshandleScroll(), which queues the pass as a VanillaGridScheduler write for scroll steps shorter than ten row heights

Current behaviour:
Above MAX_VIRTUAL_DOM_HEIGHT — 16M px in Blink and WebKit, 4M px in Gecko — the grid runs in scaled mode. At the 37 px Fiori row height that means from ~432k rows in Chromium and from ~108k in Firefox. Scaled mode adds two forced layouts per frame on top of § 2.5's:

  1. _updateSpacerHeights() re-reads viewport.scrollTop after _rotatePool().
  2. To halve a pass's synchronous cost, scaled mode fills only the on-screen rows and defers the buffer rows to a scheduler write. handleScroll() schedules scroll steps shorter than ten row heights through the same scheduler. The previous pass's deferred fill therefore runs first in the next flush, both being write tasks queued in that order. The next pass's first read then meets layout the fill has just dirtied, and that pass rotates and rewrites most of the rows the fill wrote. Larger steps and touch gestures render synchronously in the scroll event, and that path cancels a pending fill, so they avoid this second forced layout.

The scheduler's separate read and write phases cannot prevent this, because the render pass is queued as a write and does its own reads.

Impact: At 1M rows with custom renderers hidden, over 240 frames: three layouts per frame, and 233 of the 240 frames over 20 ms. A trace taken with § 2.5's what-if applied attributes the two remaining forced layouts per frame to the scrollTop read in _updateSpacerHeights() and to the viewport-height read at the top of the pass.

1M rows (scaled), 240 frames render pass layouts / frame layout / frame main-thread task / frame frames > 20 ms
as is 15.6 ms 2.98 12.7 ms 41.4 ms 233
what-if: § 2.5 only 14.5 ms 2.99 11.7 ms 38.3 ms 228
what-if: § 2.5 + one scrollTop read per pass 6.8 ms 1.99 9.7 ms 34.8 ms 210
what-if: both + buffer rows filled in the same pass 2.6 ms 1.00 5.6 ms 29.8 ms 178

Even with all three fixes, a scaled frame costs about twice a natural one: 29.8 against 14.3 ms of main-thread work in this environment. Every pass moves the whole pool block, so the visible rows are laid out and repainted each frame instead of being scrolled by the compositor. That part is inherent to scaled mode (see Row Virtualization § 1.5, "Engine-Scoped Virtual DOM Height Cap").

Recommendation:
Pass domScrollTop into _updateSpacerHeights() (§ 2.5). For the deferred fill there are two options:

Every measurement here is Chromium-only. Firefox's lower cap puts four times fewer rows in scaled mode, so a Firefox pass is still owed.


2.7 Tooltip measurement runs on every drag frame and once per auto-fitted column — MEDIUM

Location: features/columns-resize.feature.js — the rAF callback in handleResizeMove(), stopResize() and autoFitColumn(). All three call this._updateTooltips(), which vanilla-grid.js wires to the immediate updateTooltips(), not the debounced scheduleTooltipUpdate().

Current behaviour:
updateTooltips() (§ 1.2) reads scrollWidth/clientWidth for every visible cell, falls back to canvas measureText for most of them, and writes titles. After scrolling it runs once, 300 ms after the last render. Two column paths run it synchronously instead:

The same loop repeats the rest of the per-column apply work as well. freezeAllColumnWidths() re-reads every header rect after the previous column's write, and applyFrozenColumnStyles() runs once per column.

Impact:

Recommendation:

The tooltip part alone measured −100 ms. From the per-call costs above, hoisting the freeze and frozen-style passes would remove most of another ~75 ms (estimate).


3. Memory & GC Pressure

3.1 Array.from() allocations in hot paths — MEDIUM ✅ DONE

Location: Multiple methods in columns.feature.js and vanilla-grid.js

Current behaviour:
Methods like syncBodyColWidths, freezeAllColumnWidths, _clampFrozenColumnsToViewport, applyFrozenColumnStyles, and handleResizeMove all call Array.from(headerColGroup.children) or Array.from(headerRow.children) creating a new array each time.

Impact: These allocations are small but frequent during resize and scroll operations. The arrays are immediately discarded, adding GC pressure.

Recommendation:
For read-only iteration, use for loops directly on the HTMLCollection (headerColGroup.children[i]). This avoids allocation entirely. Alternatively, cache the array in the feature instance and invalidate it when columns change.

Implemented: Replaced all 14 Array.from(…children) calls across both files with direct HTMLCollection iteration via for loops. Also converted .map()/.forEach() on those arrays to equivalent for loops. Only two Array.from(Set.values()) calls remain (cold-path utility getters, not DOM collections). Re-checked 2026-09-23: no Array.from(…children) remains in any grid source file, including the sub-feature files split out since.


3.2 Event listener closures in renderHeader() — LOW ✅ DONE

Location: features/rendering.feature.jsrenderHeader(), _initHeaderDelegatedListeners()

Current behaviour:
Inside the columns.forEach loop, each <th> gets multiple event listeners (click, dragstart, dragover, drop, dragend, pointerdown, contextmenu) each creating a new closure capturing index.

Impact: Headers are rebuilt infrequently (column changes), so the absolute count is small. But each rebuild creates columns.length × 7 closures.

Recommendation:
Use a single delegated listener on the <thead> for each event type. Resolve the column index from the event target's dataset.colIndex at dispatch time. This reduces listener count from O(columns × events) to O(events).

Implemented: _initHeaderDelegatedListeners() (rendering.feature.js) registers click / dblclick / pointerdown / dragstart / dragend / contextmenu / mousemove / mouseleave once per grid on this.header; every <th> and resizer carries a stamped dataset.colIndex. dragover/drop were consolidated onto the drag-scoped document listeners (_onDocDragOver/_onDocDrop) instead of being delegated — the per-<th> duplicates were deleted. destroy() removes the delegated set.


3.3 showEmpty / showError use innerHTML with string interpolation — LOW ✅ DONE

Location: features/rendering.feature.jsshowEmpty(), showError(); templates.js

Current behaviour:

this.body.innerHTML = '<tr><td …>' + message + '</td></tr>';

The message string is interpolated directly into HTML without escaping.

Impact: Primarily a correctness/security concern (messages containing < or > would render as HTML). Performance impact is minimal since these run rarely.

Recommendation:
Build the elements with document.createElement and textContent to avoid HTML parsing and prevent any XSS if message comes from untrusted sources.

Implemented: both methods clone a cached row from templates.js (window.VanillaGridTemplates) and set the message with textContent. The only innerHTML left on that path is the static template markup, parsed once per page.


3.4 The selection key map is rebuilt on every setRows() — HIGH

Location: vanilla-grid.jssetRows()_rebuildKeyToRowMap(this.rows)features/selection.feature.jsrebuildKeyToRowMap()

Current behaviour:
Every setRows() rebuilds the key → row Map over the whole new row set. With StaticDataManager that includes every reload, search, filter, and clearing of a search or filter. For each row the rebuild resolves the row key (one String() conversion), checks a seenKeys Set for duplicates, and inserts.

The map has two readers. setSelectedKeys() uses it to find the row objects for the keys it is given, and the rebuild loop itself refreshes _selectedRowsCache for rows that are already selected. isRowSelected() does not use it. It is built unconditionally, including for grids with selection.mode: 'noselection', although the mode is fixed for the grid's lifetime.

Impact: At a million rows it is most of the main-thread stall left after filtering and search moved to a Worker:

1M rows, grid-minimal-js flat (StaticDataManager with Worker filter/search) longest main-thread task key-map rebuild within it
clear the search 317 ms 250 ms
clear a column filter 314 ms 244 ms
reload() 316 ms 259 ms
search "ali" (152,577 rows) 119 ms 70 ms
filter Salary > 150,000 (206,899 rows) 134 ms 83 ms

When grouped, the rebuild runs in the same load as buildRenderEntries() (§ 6.3). Clearing the search then blocks for 598 ms in two tasks, 267 ms of them the key map.

The map retains ~33 MB at a million rows: the heap drops from 343 to 310 MB when it is cleared. The rebuild also allocates a same-sized seenKeys set while it runs.

The same per-row key resolution dominates selectAll(): 306 ms at a million rows (46 ms at 100k). Afterwards _selectedKeys and _selectedRowsCache hold another 33 MB; while select-all is sticky, the cache is a second copy of the key map.

Recommendation:


3.5 The toolbar materialises the whole selection on every state recompute — MEDIUM

Location: src/vanilla-grid-toolbar/vanilla-grid-toolbar.js_buildState(), _vnToolbarStatesEqual()

Current behaviour:
<vn-grid-toolbar> recomputes its state on every grid event it listens to: selection, loading, loaded, error, busy, filter, sort, group, attribute, total row count and load-more. Every recompute calls gridEl.getSelectedKeys() and gridEl.getSelectedRows(), each of which allocates a fresh array of the whole selection. It then compares both arrays element by element against the previous state.

The grid itself avoids exactly this cost. emitSelectionChanged() exposes selectedKeys/selectedRows as lazy getters, because "with select-all active on a large dataset that is two O(n) allocations per change". A linked toolbar pays it anyway, on every event, whether or not anything reads the arrays. The toolbar's own items read only selectedCount.

Impact: With a million rows selected each recompute costs 35–40 ms, 25–28 ms of it in getSelectedRows(); at 100k rows it costs 7 ms. A load raises four to seven recomputes. A reload with everything selected therefore spends roughly 0.15–0.3 s in the toolbar on top of the grid's own work (estimate from the counted recomputes).

Recommendation:
Build state.selectedKeys and state.selectedRows as lazy getters, as the grid's event detail already does. Compare selections by selectedCount and selectAllActive plus a counter the toolbar increments in its own vn-grid-selection-changed handler, instead of by content. Readers of the public state (typed as arrays in vanilla-grid-toolbar.d.ts) see no change. dispatchCommand() can keep materialising the arrays in its event detail, since that runs once per user command.


4. Script Loading

4.1 document.write for synchronous script loading — RESOLVED

Location: vanilla-grid.js — IIFE at top of file

Current behaviour: The document.write path was removed entirely. The bootstrap now has a single code path: dependencies are injected sequentially via dynamic <script> tags with async = false (so ordering is preserved), and the bootstrap exposes window.VanillaGridReady (a Promise) that resolves when every feature script has loaded. Hosts must await window.VanillaGridReady (directly or via the <vn-grid> element's ready() method) before instantiating a grid. Static HTML pages that need a synchronous bootstrap should switch to the pre-bundled artifact dist/vanilla-components/latest/vanilla-grid/vanilla-grid.bundle.js, which inlines every dependency and disables the auto-loader via window.VanillaGridSkipAutoload = true.

Impact: No parser blocking, no Lighthouse warning, no HTTP/2 server-push incompatibility, no ad-blocker friction.


4.2 SheetJS is downloaded and evaluated on every page load — LOW ✅ DONE

Location: vanilla-grid.js — the auto-loader list (features/xlsx-js-style.bundle.min.js); build.jsBUNDLE_PARTS

Current behaviour:
The bundled SheetJS fork is in the auto-loader's list, and VanillaGridReady waits for it. The comment beside it says it is needed only at call time, and the Worker export path loads its own copy from VanillaGridXlsxURL regardless. build.js also inlines it into vanilla-grid.bundle.js and ships a standalone copy for the Worker. Bundle consumers therefore download it twice if they export, and once if they never do.

Impact: 425 KB raw, 142 KB gzipped, on every page load, plus ~11 ms of main-thread script evaluation per boot on this machine. That is a fifth of the ~55 ms that all component and sample scripts take together (booting grid-minimal-js from source, average of 3 boots).

Recommendation:
Load it on first use:

Then drop it from BUNDLE_PARTS and let the bundle resolve the standalone copy the same way.

Implemented: by removal rather than lazy loading. The export now writes .xlsx itself (§ 7.1), so SheetJS is gone: features/xlsx-js-style.bundle.min.js, its auto-loader entry, its BUNDLE_PARTS entry, the standalone copy in the slim dist, window.VanillaGridXlsxURL and the xlsxWorkerUrl option. No page downloads or evaluates the 425 KB (142 KB gzipped) any more, whether it exports or not. The ods and csv export formats, which only SheetJS provided, were dropped with it.


5. CSS & Paint

5.1 will-change: contents on virtual tbody — LOW ✅ DONE

Location: vanilla-grid.css.vn-grid-virtual-tbody

Current behaviour:

.vn-grid-virtual-tbody { contain: layout; will-change: contents; }

will-change: contents hints the browser that child additions/removals are frequent. However, the pool-based virtualization reuses the same DOM nodes and only changes textContent — the node tree doesn't actually change.

Impact: will-change: contents can prevent the browser from compositing the tbody efficiently since it expects structural DOM changes. In practice it may increase rather than decrease paint cost.

Recommendation:
Remove will-change: contents. Keep contain: layout which is correct and beneficial. If further paint optimization is needed, consider contain: layout style paint on the viewport.

Implemented: will-change: contents removed from .vn-grid-virtual-tbody; contain: layout kept as-is.


5.2 Redundant GPU-layer promotion on viewport — LOW ✅ DONE

Location: vanilla-grid.css.vn-grid-virtual-list-viewport

Current behaviour:

.vn-grid-virtual-list-viewport {
    will-change: scroll-position;
    backface-visibility: hidden;
    transform: translateZ(0);
}

Three separate properties all promoting the element to a composited layer.

Impact: Only one is needed. Having all three is harmless but noisy and may confuse optimization intent. will-change: scroll-position alone is the correct modern approach.

Recommendation:
Keep only will-change: scroll-position. Remove backface-visibility: hidden and transform: translateZ(0) which are legacy hacks for forcing GPU compositing.

Implemented: Removed from .vn-grid-virtual-list-viewport, .vn-grid-scrollbar-wrapper, and .vn-grid-scrollbar-track. The scrollbar thumb's will-change: transform + transform: translate3d(...) were kept — those elements genuinely have their transform written on every scroll/drag frame by _setThumbTop()/_setHorizontalThumbLeft() (viewport.feature.js), so the hint is load-bearing there, unlike the static wrapper/track/viewport declarations.


5.3 Sample renderCell content multiplies paint cost (grid-minimal-js) — LOW

Location: samples/grid-minimal-js/app.js — the rating and height columns' renderCell

Current behaviour:
Both renderers build positioned markup inside every cell. The rating's stars are a position: relative box holding an absolutely positioned, clipped copy. The height gauge is a position: relative track with five absolutely positioned children, three of them moved with transform: translateY(-50%). Positioned and transformed elements are painted as layers of their own.

Impact: Scrolling 100k rows with tracing on (120 frames), the trace records ~93 Paint events per frame with the two columns visible, against 2 with them hidden. Paint time is 2.2 s against 1.4 s (+59%). In an untraced run, main-thread work per frame falls from 23.9 to 19.3 ms when they are hidden. The grid's own body cells are not positioned, except frozen cells (position: sticky, § 1.6).

Recommendation:
Sample-level: draw the gauge and the stars without positioned children. Options include background gradients for the track and band, a clipped or gradient-filled star string, or one inline SVG per cell. Row Virtualization § 1.16, "Writing Fast renderCell Implementations", covers element reuse but not layer creation, and deserves a sentence on it.


6. Algorithm Efficiency

6.1 applySorting sorts in-place without stable flag — LOW

Location: features/sorting.feature.jsapplySorting(), sortRowsByState()

Current behaviour:
Uses Array.prototype.sort() which is not guaranteed to be stable in older engines (though modern browsers use TimSort which is stable). There is no fallback.

Impact: Low — all target browsers now use stable sort. Only relevant if supporting very old engines.

Recommendation:
No change needed for modern targets. Document the stable-sort assumption.


6.2 _rebuildKeyToRowMap traverses all rows on every setRows / appendRows — LOW ✅ DONE

Location: selection.feature.jsrebuildKeyToRowMap()

Current behaviour:
On appendRows, the full key-to-row map is rebuilt from all rows, not just the appended ones.

Impact: For incremental appends (infinite scroll), rebuilding the entire map (e.g. 50,000 rows) when only 1,000 new rows arrive is wasteful.

Recommendation:
For appendRows, only add the new rows to the existing map instead of clearing and rebuilding. Keep the full rebuild for setRows.

Implemented: addRowsToKeyMap(newRows) on the selection feature registers only the appended rows — O(page) per append — while preserving the duplicate-key warning by checking the existing map before inserting. appendRows() (and therefore loadDataProgressively's chunk loop) uses it; setRows keeps the full rebuild. The full rebuild setRows keeps is now the larger cost; see § 3.4.


6.3 "Reset grid layout" reordered a million rows five times — HIGH ✅ DONE

Location: vanilla-grid-element.jsclearPersistedSettings(); vanilla-grid.jsclearPersistedSettings(), setColumns(), setRows()

Current behaviour (before the fix): The toolbar's clearPersistedSettings command froze the tab for about four seconds on a one-million-row grid. The freeze was not one slow operation. It was the same reorder performed repeatedly and a million-entry selection key map rebuilt twice, and the last and largest reorder ran fully synchronously with neither the shimmer bracket nor the sort Worker that an ordinary sort already uses.

Measured on samples/grid-minimal-js/?rows=1000000, headless Chromium at 1400x900, unminified sources, long tasks collected with a PerformanceObserver on longtask. Times are relative to the click on the reset button.

time call duration
+76 ms selection.rebuildKeyToRowMap (from setColumns) 757 ms
+837 ms sorting.sortColumns (declarative rating:desc) dispatched to the Worker, discarded
+851 ms grouping.setGroupState (declarative country ▸ city) dispatched to the Worker, discarded
+1254 ms selection.rebuildKeyToRowMap (from setRows) 312 ms
+1571 ms sorting.sortRowsByState (in-thread, via applyEffectiveSort) 2450 ms
+4021 ms grouping.buildRenderEntries 420 ms

Impact: High — four seconds of a completely unresponsive tab on a documented sample, on a command a user is expected to press.

Implemented: four independent changes, each removing one whole pass.

  1. The reload is now conditional. <vn-grid>.clearPersistedSettings() reloads only when the reset actually changed the DataManager's query — an active column filter, an active search term, or server-side sorting. With none in effect the in-memory reset is already the complete answer. See Local Storage Settings § 6.5.
  2. When a reload IS needed, nothing reorders before it. clearPersistedSettings({ deferToReload: true }) clears the sort and group state without reordering and leaves the declarative sort / grouping parked; setRows() consumes the parked state and reorders once.
  3. setRows() reorders on the shared bracket. Its ordering step moved onto runReorderPipeline(), so a large reload gets the shimmer and, when the sort is worker-eligible, the off-thread path. Below sortShimmerThreshold it stays synchronous, and a load with nothing to order skips the bracket entirely. See Sorting § 7.2.1.
  4. setColumns() no longer rebuilds the selection key map unconditionally — only when the resolved key column actually moved, or when no map exists yet. Re-applying the same declaration produced an identical map for 757 ms.

A fifth change fell out of the measurement: the grouping projection was being built twice per grouped reorder, once by the setDisplayRows injection and once by the reorder's own finish. realignRenderEntriesForDisplayRows() now leaves the build to finish whenever grouping is applied, and does the zero-allocation re-alias otherwise. That is ~600 ms per grouped reorder, on every grouped sort and group change, not only on the reset.

Result (same fixture and method, total main thread blocked across the reset gesture):

scenario before after
ungroup every level, then reset 4053 ms 657 ms
reset straight from the grouped default 4308 ms 721 ms

At 300,000 rows the same gesture went from 1102 ms to 224 ms; tests/playwright/grid-reset-layout-requery.spec.js asserts a 700 ms ceiling there so a regression cannot ship silently.

Still open: what remains is a single buildRenderEntries() scan, which runs inside the shimmer. Re-measured on 2026-09-23 with two aggregates configured, it takes 210–280 ms at a million rows, down from ~570 ms: § 6.7's galloping boundary search also applies to full builds. The reset gesture from the grouped default now blocks for 440 ms in total, in two long tasks (721 ms above). The scan is the largest synchronous block of every grouped reorder, and on a full reload it sits next to § 3.4's key-map rebuild:

1M rows, grouped Country ▸ City, two aggregates settled after long tasks buildRenderEntries()
sort Salary descending 1.5 s 288 ms 235 ms
clear that sort 0.4 s 264 ms 210 ms
reload() 1.6 s 612 ms (2 tasks) 210 ms, plus 256 ms key map
clear the search 2.6 s 598 ms (2 tasks) 279 ms, plus 267 ms key map

Making the projection build incremental or chunked is a separate problem.


6.4 A group change looked idle for a second, then changed everything at once — MEDIUM ✅ DONE

Location: features/grouping.feature.jssetGroupState(); features/viewport.feature.js_onViewportResized()

This one is not about blocked main thread. Every measurement above answers "how long is the tab frozen"; this answers "what is on screen while it is not frozen", and the two came apart precisely because 6.3 succeeded. The reorder is off-thread, the page scrolls and clicks throughout — and that was the problem: the grid spent the whole window looking finished and wrong.

Behaviour before the fix. Grouping an ungrouped million-row grid painted the chip immediately, and then for the rest of the window showed the ungrouped rows, with the grouped column still carrying the value the captions were about to repeat, no captions, and no shimmer. Everything changed in a single frame at the end.

Two independent causes, in the same window:

  1. The visible column set was re-derived in the reorder's finish, behind the whole reorder, though it depends on nothing the reorder produces — the exclusion is a pure function of the group state, the resolved strategy and whether the bar can mount, all settled before the pipeline is invoked.
  2. Mounting the group bar resizes the viewport, the grid's ResizeObserver fires, and _onViewportResized() re-rendered real rows straight over the raised skeleton — with no check that a load was in flight. The shimmer was gone ~25 ms in.

Implemented. The column-set sync moved to setGroupState()'s prologue, alongside the chip render it already did there (Grouping § 6.1); finish keeps its own call for the R6 revert. And _onViewportResized() re-paints the skeleton at the new size instead of rendering rows whenever isLoading is true, in both its immediate and its 180 ms debounced branch (Row Virtualization § 1.12.1) — which fixes the collision for every load that overlaps a resize, not only grouping's.

Method. Same fixture and machine class as 6.3 — samples/grid-minimal-js/?rows=N, headless Chromium at 1400x900, unminified sources — but sampled on requestAnimationFrame across the whole gesture, starting from a fully ungrouped grid, so every figure is what the eye could actually see in that frame. Starting the sampler costs the first sample ~5 ms of its own.

1,000,000 before 1,000,000 after 300,000 before 300,000 after
chip painted 5 ms 73 ms 5 ms 71 ms
grouped column gone 1720 ms 73 ms 327 ms 71 ms
skeleton painted 5 ms 73 ms 5 ms 71 ms
frames showing real rows while loading 76 0 7 0
that window 1279 ms 0 ms 97 ms 0 ms

What it cost. The first paint of the gesture moves from ~5 ms to ~70 ms, because the prologue now re-lays out the header and renders one screenful before the shimmer goes up: at 300,000 rows the hoisted sync measures ~63 ms — header re-layout 29 ms, pool rebuild 9 ms, row render 24 ms — and the whole gesture runs ~110 ms longer end to end. The render pass is the part that is thrown away, since the skeleton replaces it in the same task. Recovering it means raising the shimmer before the column sync, which the reorder bracket cannot express today: it raises the shimmer itself, and setGroupState() calls it last by construction. That would need a pre-apply hook on a seam shared with sorting, which is a larger change than the ~24 ms it would save.

At a million rows the end-to-end delta is dominated by run-to-run variance in the Worker sort and the projection scan (both ±700 ms on the same build), so only the perceived-latency figures above are reported for that size.

tests/playwright/grouping-transition-shimmer.spec.js samples the same way and fails if any frame shows real rows over a raised loading state.


6.5 Group aggregates — the measured cost of the reduce

Measured, not estimated, at grid-minimal-js's million-row setting. Re-measured on 2026-09-23, after § 6.7 changed what the reduce is compared against. The figures time buildRenderEntries() alone, driven directly in Node 25 the way tests/node/ drives the grouping feature, on the machine in Method: the build without rendering, median of five runs.

A million rows, sum over no aggregate 1 column 2 columns 4 columns
Country, expanded 41 ms 204 ms 238 ms 278 ms
Country, collapsed 0.1 ms 165 ms 197 ms 238 ms
Country ▸ City, expanded 49 ms 334 ms 365 ms 433 ms
Country ▸ City, collapsed 0.1 ms 260 ms 298 ms 355 ms

The first aggregated column costs 160–170 ms per million rows at one level and 260–285 ms at two. Each further column adds 20–40 ms. The first column pays for reading every row. Since § 6.7 the boundary search gallops, so a build without an aggregate compares values at a few points per group and never reads the other rows: what is left expanded is pushing every row into renderEntries. An aggregate is the one part of the build that reads every row, and a grouped displayRows is in sorted order, so each read is a cache miss. Further columns share that read and add only their own getValue and step calls. At two levels every row steps both open levels.

The single visit is a requirement, not an optimization. When the feature was designed, an accumulation loop of its own over each group's range measured +110 ms per column, 3× the shared visit, because the cost is the row dereference and a second pass pays it again. A pass per level would do the same. Guarded by tests/node/aggregates-feature.test.js, which records the reads a build makes and fails unless each row is read once, in displayRows order, with every aggregated column read in that visit. A second pass returns the same totals, and at unit-test sizes the rows fit in cache and the extra pass costs too little to time, so the test checks the order of the reads, not a duration.

The +5–17% budget the projection scan was given no longer applies. It assumed a scan that already read every row. One aggregated column at two levels now makes the build 6.8× as expensive as the build without it (334 ms against 49 ms). The build itself is faster than when that budget was set: 334 ms against 434 ms.

A collapsed grid stops getting its traversal free, and only then — on a full build. With no aggregate configured a collapsed branch is not walked at all: it costs exactly one caption (0.1 ms at a million rows; 184 ms before § 6.7). With one configured, a collapsed branch must still be walked — its own total has to exist, which is what turns a fully collapsed grid into a summary table — so it is recursed into with emit: false: every row visited, nothing pushed to renderEntries, the data-index map or the cardinality counter. That measures 260 ms collapsed against 334 ms expanded at two levels (was 399 against 434): collapsing still saves the projection work, just no longer the traversal. This applies to full builds only — a rebuild caused by expanding or collapsing reuses the totals and skips collapsed branches again (§ 6.6).

The change runs on the reorder bracket for the same reason a sort does. Every aggregate change is a full build, 165–433 ms at a million rows while an aggregate is configured (the table above), well past a frame, and it uses the existing sortShimmerThreshold rather than a second constant. The reduce stays on the main thread: a host-registered reducer is host code and cannot be transferred to the sort Worker, so the shimmer is what covers the cost rather than offloading.

Build note. aggregate-registry.js is on build.js's BUNDLE_FAST_PATH because step() runs once per row per aggregated column. Left on the heavy obfuscation profile it costs roughly an order of magnitude — invisible under npm run build, and only at large row counts under npm run build:obfuscate.


6.6 Expand/collapse recomputed every aggregate — HIGH ✅ DONE

Problem. Every expand/collapse gesture — a caption's toggle, expandGroup()/collapseGroup(), and Expand all / Collapse all — rebuilt the projection through the same buildRenderEntries() a data change uses, so with an aggregate configured it re-ran every reducer over every row, including the emit: false traversal of collapsed branches (§ 6.5). Collapse changes which entries are projected, never which rows a group holds, so every recomputed value was identical to the one it replaced. For cheap reducers that was the § 6.5 cost; for countDistinct on a high-cardinality string column it was seconds per click, because the reducer sorts every exact-unique value with the grouping collator, at every open level.

Fix. The four collapse paths call buildRenderEntries({ reuseAggregates: true }), which takes the stored results map instead of running any accumulator, does not traverse collapsed branches (the no-aggregate rule), and refolds only the per-column extremes over the projected footers — O(projected groups), not O(rows). Reuse is guarded by a reference check on the configured aggregate set and falls back to a full build if a projected group is missing from the results. See Grouping Implementation § 4.

Measured in headless Chromium, grid-minimal-js/?rows=1000000, grouped Country ▸ City, fully expanded, one aggregate configured, timing the synchronous API call (projection rebuild plus the in-place re-render):

Aggregate Gesture Before After
Sum of Salary collapse / expand one country ~650 ms ~560 ms
Sum of Salary Collapse all ~700 ms ~380 ms
Distinct count of Email collapse / expand one country ~3.7 s ~0.55–0.8 s
Distinct count of Email Collapse all ~3.8 s ~390 ms

At 200,000 rows the Distinct count toggle went from ~690 ms to ~95 ms. What remains on a toggle is what it costs with no aggregate configured: the boundary rescan of the expanded part of the grid. § 6.7 removes that too.


6.7 One group's toggle cost as much as expanding every group — HIGH ✅ DONE

Symptom. On a large grouped grid every expand/collapse gesture froze the page for a time proportional to the whole dataset, not to the group toggled: at 1M rows one caption click blocked the main thread for 0.6–0.9 s, the same as Expand all, with no feedback, and clicks made meanwhile replayed afterwards.

Cause. Two, both in grouping.feature.js. Every single toggle ended in a full buildRenderEntries(), re-projecting every row although only one contiguous block of entries changes. And _scanLevel() found each group's end by calling the comparator once per row, even across runs it was not going to project: Collapse all at 1M rows spent ~300 ms finding 12 country boundaries. A CPU profile of one leaf collapse at 1M rows put ~200 ms in _scanLevel, ~130 ms in column._getValue and ~95 ms in defaultCompareValues, with the render pool under 5 ms.

Fix.

See Grouping Implementation § 3.2 and § 4.

Measured in headless Chromium 1400×900 on an AMD Ryzen 7 5700U, grid-minimal-js/?rows=N (two aggregates configured), starting fully expanded; median of 5 runs, source tree. Each figure is the call → next painted frame, which has a floor of about two frames (~33 ms); "toggle" is the range over collapsing/expanding a mid-list leaf group and the first top-level group.

Rows Levels Collapse all Expand all Toggle one group
100k 2 33 → 33 ms 72 → 38 ms 53–66 → 32–33 ms
100k 3 32 → 33 ms 91 → 54 ms 80–82 → 32–33 ms
300k 2 84 → 33 ms 183 → 41 ms 169–189 → 33–34 ms
300k 3 83 → 33 ms 235 → 66 ms 217–233 → 32–33 ms
1M 2 300 → 33 ms 630 → 76 ms 592–627 → 36–54 ms
1M 3 294 → 33 ms 892 → 103 ms 825–899 → 33–42 ms

Synchronous cost of the call itself at 1M rows: Collapse all 11–12 ms, Expand all 63–92 ms, a toggle 20–46 ms — most of a toggle's remainder is the O(n) array copy and the pool re-render. build:obfuscate (median of 3) stays close, because grouping.feature.js is on BUNDLE_FAST_PATH: at 1M rows Collapse all 33 ms, Expand all 91/124 ms (2/3 levels), a toggle 53–74 ms.

Expand all is the floor that remains. It must push every row into the projection, ~1M entries; at 76–124 ms to paint on this machine it stays under the ~150 ms at which a busy indicator was to be added, so none was. Slower hardware should be assumed 2–4× worse.

Re-verified 2026-09-23 with the same harness at 1M rows (median of 3). Two levels: Collapse all 33 ms, Expand all 57 ms, one group's toggle 29–43 ms. Three levels: 31 ms, 94 ms and 26–45 ms. Both are inside the figures above.


6.8 appendRows() re-sorts the whole dataset for every appended page — MEDIUM

Location: vanilla-grid.jsappendRows(): if (!this.serverSort && this._sorting.hasActiveSort()) this.applySorting();

Current behaviour:
With a client-side sort active, every appended page re-sorts all of rows in-thread. That is O(n log n) per page, so O(n²/p · log n) for a dataset loaded in pages of p rows. The call runs outside runReorderPipeline(), so it gets neither the shimmer nor the Worker.

Impact: loadDataProgressively() of 100k rows in 1,000-row chunks takes 2.5 s with no long tasks when unsorted. With a sort on Salary it takes 5.5 s: 100 re-sorts cost 2.9 s, in 43 long tasks that grow with every chunk (longest 84 ms).

The reach is narrow. It takes loadDataProgressively(), which no sample uses, or infinite scroll combined with a client sort. Every infinite-scroll sample sets sorting.serverSide: true, which skips it.

Recommendation:
Sort the appended page alone with the same precomputed-key comparator, then merge it into the already-sorted displayRows. That is O(n + p log p) per page. Resolving ties in favour of the rows already present gives the same result as the stable full sort. The grouping branch below it re-runs the effective sort for a documented edge case and can stay as it is.


7. Excel Export

7.1 A failed large export retries synchronously on the main thread — HIGH ✅ DONE

Location: features/excel-export.feature.jsexportToExcel(): when the Worker reply is not ok, or the Worker call rejects, it calls buildAoaAndCellStyles() and runSync()

Current behaviour:
When the Worker reports failure, exportToExcel() repeats the whole export synchronously on the main thread, at any row count. Export to Excel documents this as the transparent fallback for a Worker that cannot be created or that reports an internal error. It also catches a Worker that runs out of room, and at that size the synchronous retry fails the same way, after freezing the page.

Impact: grid-minimal-js, 21 exported columns, headless Chromium:

rows outcome
100,000 completes in 25 s, almost all of it inside the Worker (§ 7.2 covers the main-thread share)
200,000 the Worker fails after ~43 s with RangeError: Invalid array length. The synchronous retry then blocks the main thread for 108 s (a 100 ms heartbeat timer stopped firing for 107.7 s) and fails with the same error: ~150 s in all, and no file
300,000 the renderer process crashes ~60 s into the export, and the page is lost

There is no row ceiling, and the "Exporting…" overlay stays up the whole time. From the user's side the 200k case is a frozen tab followed by nothing.

Recommendation:
Fall back to the synchronous path only below the Worker threshold, or when the Worker could not start at all. For a failure inside a Worker that did run, reject with a clear error.

Then measure the practical ceiling (rows × columns) of the bundled SheetJS fork and check it before starting, so a request that cannot succeed fails in milliseconds instead of minutes. Raising that ceiling (less memory per cell in the Worker, streaming the sheet XML) is separate work, to be scoped against how large an export the samples need.

Root cause, measured afterwards. The failure was not the cell formatting: an export with no styles and no number formats failed at the same size with the same error. SheetJS 0.18.5's XLSX.write({ type: 'array' }) turns the finished ZIP into a binary string through a JS array holding one element per byte. Chromium cannot grow such an array past about 134 M elements, and the ZIP was written uncompressed at about 960 bytes per 21-column row, so the write threw RangeError: Invalid array length from about 140,000 rows. Behind that wall SheetJS held one JS object per cell: 1.9 GB at 100k rows, and a V8 property limit on its sheet object between 300k and 500k rows.

Implemented: the export was rewritten to stream the workbook itself (Export to Excel § How an Export Runs).

Same fixture, grid-minimal-js/?rows=N&group=none (21 columns, Fiori theme), exportToExcel({ scope: 'all' }), headless Chromium, source tree:

Rows Total File Peak renderer Longest main-thread task
100,000 before 40.5 s 91.6 MB 1,939 MB 455 ms
100,000 after 3.1 s 14.2 MB 507 MB none over 50 ms
200,000 before fails after ~150 s, 108 s of it frozen 108 s
1,000,000 before not possible (the renderer crashes from 300k)
1,000,000 after 31.4 s 143 MB 1,660 MB none over 50 ms

The file is smaller because the sheet is now deflated; the 1M-row peak includes the page's own dataset. Cell values are unchanged; date and datetime columns now also carry their documented yyyy-mm-dd / yyyy-mm-dd hh:mm:ss formats, which the SheetJS path silently replaced with Excel's short date. tests/playwright/excel-export-large.spec.js exports 150,000 rows and fails on any main-thread task over 150 ms.

A larger export stays bounded by Excel itself: rows past 1,048,576 continue on additional sheets of the same file.


7.2 The Worker export still blocks the main thread while it prepares the payload — MEDIUM ✅ DONE

Location: features/excel-export.feature.jsrunExport(): the rawRows loop and I.postToExportWorker(workerPayload)

Current behaviour:
On the preferred Worker path the main thread still builds rawRows in one synchronous loop: an array per row, a _getValue() per cell, and dates converted to epoch numbers. postMessage then structured-clones the whole payload, also synchronously. The overlay is raised first, so the user sees "Exporting…", but the page is frozen while it shows.

Impact: 100k rows × 21 columns: two long tasks totalling 428 ms (longest 369 ms), 167 ms of it the clone. Both parts scale linearly with rows × columns.

Recommendation:
Build the payload in chunks, yielding with VanillaGridYield.yieldToMainThread() between them, as StaticDataManager and the sort Worker's key extraction already do. Pack numeric and date columns into typed arrays that are transferred rather than cloned. Do this together with § 7.1: without a ceiling, a faster preparation step only reaches the failure sooner.

Implemented with § 7.1: there is no whole-dataset payload any more. Each chunk (≤ 5,000 rows, ≤ 8 ms of work) is handed to the Worker on its own, and the producer yields with VanillaGridYield.yieldToMainThread() between chunks. The per-cell style ids travel in a transferred Uint32Array. Values stay a plain array, which cloning a chunk at a time made cheap enough. At most two chunks are ever unacknowledged, which bounds memory. At 100k rows the longest main-thread task went from 455 ms to none over 50 ms. features/excel-export.feature.js, which holds the per-cell loop, is on BUNDLE_FAST_PATH.


Summary Table

# Item Category Impact Effort Status
1.1 Checkbox DOM rebuild per render Rendering High Medium Done
1.2 updateTooltips queries all cells Rendering High Low Done
1.3 Intl.NumberFormat re-created Rendering Medium Low Done
1.4 innerHTML in header construction Rendering Medium Medium Done
1.5 decorateCell without change detection Rendering Medium Low Done (option since removed)
1.6 Frozen-column styles rewritten every render pass Rendering Medium Low Open
2.1 Forced reflow in freezeAllColumnWidths Layout Medium Low Done
2.2 syncHeaderHorizontalScroll DOM queries Layout Medium Low Done
2.3 offsetWidth in frozen offset computation Layout Low Low Done
2.4 Reflow per measured cell in _measureColumnFitWidth Layout High Low Done
2.5 Forced layout inside every render pass Layout High Low Open
2.6 Scaled virtualization: three layouts per frame Layout High Medium Open
2.7 Tooltip pass per drag frame and per auto-fitted column Layout Medium Low Open
3.1 Array.from() allocations in hot paths Memory Medium Low Done
3.2 Per-column event listener closures Memory Low Medium Done
3.3 innerHTML with unescaped strings Memory/Security Low Low Done
3.4 Selection key map rebuilt on every setRows() Memory High Low–Medium Open
3.5 Toolbar materialises the selection per recompute Memory Medium Low Open
4.1 document.write script loading Loading Resolved
4.2 SheetJS loaded eagerly on every page Loading Low Low Done (SheetJS removed)
5.1 will-change: contents on tbody Paint Low Low Done
5.2 Redundant GPU-layer promotion Paint Low Low Done
5.3 Sample renderers create paint layers Paint (sample) Low Low Open
6.1 Sort stability assumption Algorithm Low None Accepted
6.2 Full key-map rebuild on append Algorithm Low Low Done
6.3 Reset layout reordered the dataset five times Algorithm High Medium Done; one projection scan remains open
6.4 Group change looked idle, then changed all at once Perceived latency Medium Low Done
6.5 Group aggregates: the reduce, and what a collapsed grid now costs Algorithm Measured Recorded
6.6 Expand/collapse recomputed every aggregate over the whole dataset Algorithm High Low Done
6.7 One group's toggle cost as much as expanding every group Algorithm High Medium Done
6.8 appendRows() re-sorts the dataset per page under a client sort Algorithm Medium Medium Open
7.1 A failed large export retries on the main thread Export High Low Done (export rewritten)
7.2 Export payload prepared in one blocking task Export Medium Medium Done

Every item below is open. The order is value for effort.

  1. 2.5 — pass the render pass's own vm and domScrollTop into _updateSpacerHeights(). A few lines, and it removes a layout from every scroll frame of every grid.
  2. 3.4 — skip the selection key map when the grid has no selection, and build it lazily otherwise. Removes ~80% of the stall of every million-row reload, filter clear and search clear, and 33 MB of heap.
  3. 1.6 — apply frozen cell styles when the pool is built, not on every pass.
  4. 2.6 — scaled mode: with § 2.5 in place, cancel a pending buffer fill when the next pass is scheduled, or fill synchronously. Then repeat the measurement in Firefox, where scaled mode starts at a quarter of the Chromium row count.
  5. 2.7 — debounce tooltip measurement during a drag. Run one tooltip pass, one width freeze and one frozen-style pass per autoFitAllColumns().
  6. 3.5 — toolbar: lazy selection arrays, compared by count and a change counter.
  7. 6.8 — merge an appended page instead of re-sorting everything.
  8. 6.3 — make the projection build incremental or chunked. It is the largest remaining grouped cost, and the largest change.
  9. 5.3 — sample renderers without positioned children.