Performance
@toolbox-web/grid is engineered for high performance out of the box. This guide helps you understand the performance characteristics and tune the grid for your specific workload.
Why it’s fast
Section titled “Why it’s fast”A short explanation lives here so you don’t have to dig — for the full architectural narrative (render scheduler, virtualization, DOM recycling, template cloning, faux scrollbar) see the Architecture deep-dive.
| Technique | What it buys you |
|---|---|
| Centralized render scheduler | All updates coalesce into a single requestAnimationFrame — no layout thrashing, no double work |
| Row + column virtualization | Rendering 100k rows costs the same as rendering 50 |
| DOM recycling with epoch invalidation | Row elements are reused via a pool — minimal GC, no element churn |
Template cloning (cloneNode(true)) | 3-4× faster than createElement + attribute assignment |
| Event delegation | One listener per event type on the container — scales to any dataset |
| Faux scrollbar | Scroll container is separate from the rendered content — zero reflow on scroll |
| Phase-priority execution | Highest-priority phase wins per frame — full re-renders absorb sub-renders |
Key Performance Features
Section titled “Key Performance Features”Row Virtualization
Section titled “Row Virtualization”The grid only renders rows visible in the viewport plus a configurable overscan buffer. This means rendering 100,000 rows is as fast as rendering 50 rows.
grid.gridConfig = { columns: [...], rowHeight: 28, // Fixed row height in pixels (default: auto-measured)};- Default row height: Auto-measured from first row (respects
--tbw-row-heightCSS variable) - Variable row heights: Supported via
rowHeight: (row, index) => number | undefined
Column Virtualization
Section titled “Column Virtualization”For grids with many columns (50+), enable column virtualization:
import '@toolbox-web/grid/features/column-virtualization';
grid.gridConfig = { columns: manyColumns, features: { columnVirtualization: true },};Render Scheduler
Section titled “Render Scheduler”All rendering is batched through a centralized RenderScheduler that coalesces multiple update requests into a single requestAnimationFrame callback.
Render phases (lowest → highest priority):
| Phase | Priority | What It Does |
|---|---|---|
| STYLE | 1 | CSS custom property updates |
| VIRTUALIZATION | 2 | Scroll position + visible window recalc |
| HEADER | 3 | Header row re-render |
| ROWS | 4 | Data row re-render |
| COLUMNS | 5 | Column structure rebuild |
| FULL | 6 | Complete re-render |
When multiple phases are requested in the same frame, only the highest-priority phase executes (it inherently covers lower phases).
Bundle Size Optimization
Section titled “Bundle Size Optimization”Tree-Shaking Features
Section titled “Tree-Shaking Features”The most important optimization: only import the features you use.
// ✅ Optimal: ~45 KB core + only features you needimport '@toolbox-web/grid';import '@toolbox-web/grid/features/selection';import '@toolbox-web/grid/features/editing';
// ❌ Heavy: imports ALL plugins even if you use twoimport '@toolbox-web/grid/all';Budget Reference
Section titled “Budget Reference”| Bundle | Raw | Gzipped |
|---|---|---|
Core (index.js) | ≤ 170 KB | ≤ 50 KB (soft warning at 45 KB) |
| Individual plugin | 2–15 KB | 1–5 KB |
All plugins (all.js) | ~300 KB | ~80 KB |
Tuning for Large Datasets
Section titled “Tuning for Large Datasets”Fixed Row Heights
Section titled “Fixed Row Heights”Variable row heights (rowHeight: (row) => number) require measuring each row, which is slower than fixed heights. For datasets over 10,000 rows, prefer fixed heights:
grid.gridConfig = { rowHeight: 32,};Disable Animations
Section titled “Disable Animations”Row animations add visual polish but cost CPU cycles. Disable them for very large datasets:
grid.gridConfig = { animation: { mode: 'off' },};By default mode is 'reduced-motion' — animations are already disabled when the user has prefers-reduced-motion: reduce set.
Batch Data Updates
Section titled “Batch Data Updates”When updating many rows, assign the entire array at once rather than using insertRow() / removeRow() in a loop:
// ✅ Single assignment — triggers one sort/filter/render cyclegrid.rows = updatedData;
// ❌ Loop — triggers N animations and N re-rendersfor (const newRow of newRows) { await grid.insertRow(0, newRow);}Debounce Filtering
Section titled “Debounce Filtering”When using filtering with a large dataset, increase the debounce to reduce re-filtering:
import '@toolbox-web/grid/features/filtering';
grid.gridConfig = { features: { filtering: { debounceMs: 300 } }, // Default: 200};Custom Sort Comparators
Section titled “Custom Sort Comparators”For fields with expensive comparisons, provide a custom sortComparator:
{ field: 'name', sortable: true, sortComparator: (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' }),}Rendering Best Practices
Section titled “Rendering Best Practices”Avoid Direct DOM Manipulation
Section titled “Avoid Direct DOM Manipulation”Let the grid manage its DOM. Direct manipulation can conflict with the render scheduler and virtualization:
// ❌ Don't do this — bypasses the grid's render cycledocument.querySelector('.data-grid-row')!.style.background = 'red';
// ✅ Use rowClass/cellClass or a renderer insteadgrid.gridConfig = { rowClass: (row) => row.status === 'error' ? 'row-error' : '',};Use registerStyles() for Dynamic Runtime CSS
Section titled “Use registerStyles() for Dynamic Runtime CSS”Standard CSS (stylesheets, <style> in <head>) works fine for static styles. For styles you need to inject or toggle from JavaScript at runtime, use registerStyles() instead of appending <style> elements inside the grid (which get removed by replaceChildren()):
grid.registerStyles('my-highlights', ` .highlight-row { background: yellow; }`);
// Clean up when donegrid.unregisterStyles('my-highlights');Prefer Formatters Over Renderers
Section titled “Prefer Formatters Over Renderers”Formatters return a plain string — significantly faster than creating DOM elements via a renderer. Use a renderer only when you need interactive elements or custom HTML structure:
// ✅ Fast — formatter returns a string (no DOM creation){ field: 'salary', format: (v) => `$${v.toFixed(2)}` }
// ⚠️ Slower — renderer creates DOM elements (use only when needed){ field: 'status', renderer: (ctx) => { const badge = document.createElement('span'); badge.className = `badge-${ctx.value}`; badge.textContent = String(ctx.value); return badge;}}Profiling
Section titled “Profiling”Scale-tier reference
Section titled “Scale-tier reference”Use this table to set expectations and pick which knobs matter for your dataset. All numbers assume the default plugin set on a 2023-class laptop (M-series Mac / Ryzen 7).
| Rows × Cols | What to expect | What you’ll tune |
|---|---|---|
| ≤ 1k × 20 | Instant — no tuning needed | Nothing |
| 10k × 20 | Smooth 60fps scroll / sort < 50ms | rowHeight: number (fixed) over variable; prefer format over renderer |
| 100k × 20 | Smooth scroll; sort 200-500ms (single-threaded JS) | Same as above + getRowId for stable identity; consider Server-Side for queries |
| 1M+ rows | Don’t load into memory — paginate or use Server-Side | Server-Side plugin with infinite scroll |
| × 50+ columns | Horizontal scroll may stutter without column virtualization | Enable ColumnVirtualizationPlugin |
Profile your own app
Section titled “Profile your own app”The “is the grid slow?” question is almost always one of: (a) the grid is doing too much work (too many columns rendered, expensive renderers, layout thrashing from outside the grid); or (b) your data pipeline is slow (re-allocating row arrays, re-deriving columns on every render, heavy formatters). The DevTools Performance tab can tell the two apart in 60 seconds.
-
Record a representative interaction. Open Chrome DevTools → Performance → click ⏺ Record → do the slow thing (scroll, sort, filter, edit) for 2-3 seconds → stop.
-
Find the long tasks. Anything > 50ms shows up as a red triangle. Click into the flame graph.
-
Read the stack:
renderScheduler.flushdominates → grid-internal work. Look one level deeper:renderRow/updateRowDomheavy → yourrendererorcellClassis slow. Switch toformatif you only return text.measureRowheavy → variable row heights with expensive content. Set a fixedrowHeightor memoize.applyColumnsheavy → too many visible columns. EnableColumnVirtualizationPlugin.
- Framework code (React reconciler / Angular CD / Vue render) dominates → your wrapper is re-rendering the grid on unrelated state changes. Pass
rows/gridConfigas a stable reference (useMemo,computed,signal). Recalculate Style/Layoutdominate, outside the grid → something on the page is reacting to the grid’s resize. Usually a parent flexbox or aResizeObserveryou wrote.
-
Mark your own boundaries to make the trace readable:
performance.mark('app:load-rows:start');grid.rows = bigArray;await new Promise(r => requestAnimationFrame(r));performance.mark('app:load-rows:end');performance.measure('app:load-rows', 'app:load-rows:start', 'app:load-rows:end');performance.measure()entries show up as labeled bars at the top of the flame chart. -
Compare against the comparison page — if your numbers are very different from ours on a similar dataset, the cause is in your app code, not the grid.
Key metrics to watch
Section titled “Key metrics to watch”| Metric | Target | How to measure |
|---|---|---|
| Scroll FPS | 60fps | DevTools → Performance → Frames row |
| Sort time (10K rows) | < 50ms | console.time() around grid.rows = sorted |
| Filter time (10K rows) | < 30ms | Measure in filter-change event handler |
| Initial render | < 100ms | Performance tab → First Paint |
| Memory (100K rows) | < 100 MB | Memory tab → Heap snapshot |
Stress Test
Section titled “Stress Test”Try the interactive stress test below — adjust row and column counts to see how the grid performs with large datasets:
Click Run Full Benchmark to test all grid operations.
Tests: Initial render, scroll performance, sort, filter, data update, selection, memory usage