# Performance

> Optimize @toolbox-web/grid for large datasets — virtualization tuning, bundle optimization, rendering best practices, and benchmarks.

`@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.

:::tip[Looking for benchmarks?]
We publish a head-to-head benchmark against AG Grid, Tabulator, and SlickGrid that **runs live in your browser** — render 1k / 10k / 100k rows side-by-side and watch the numbers. See [Compared to other grids](/grid/comparison.md) for the methodology, results, and a feature matrix.
:::

## 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](/grid/architecture.md).

| 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

### 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.

```typescript
grid.gridConfig = {
  columns: [...],
  rowHeight: 28,   // Fixed row height in pixels (default: auto-measured)
};
```

- **Default row height:** Auto-measured from first row (respects `--tbw-row-height` CSS variable)
- **Variable row heights:** Supported via `rowHeight: (row, index) => number | undefined`

### Column Virtualization

For grids with many columns (50+), enable column virtualization:

```typescript
import '@toolbox-web/grid/features/column-virtualization';

grid.gridConfig = {
  columns: manyColumns,
  features: { columnVirtualization: true },
};
```

### 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

### Tree-Shaking Features

The most important optimization: **only import the features you use**.

```typescript
// ✅ Optimal: ~45 KB core + only features you need
import '@toolbox-web/grid';
import '@toolbox-web/grid/features/selection';
import '@toolbox-web/grid/features/editing';

// ❌ Heavy: imports ALL plugins even if you use two
import '@toolbox-web/grid/all';
```

### 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

### 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:

```typescript
grid.gridConfig = {
  rowHeight: 32,
};
```

### Disable Animations

Row animations add visual polish but cost CPU cycles. Disable them for very large datasets:

```typescript
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

When updating many rows, assign the entire array at once rather than using `insertRow()` / `removeRow()` in a loop:

```typescript
// ✅ Single assignment — triggers one sort/filter/render cycle
grid.rows = updatedData;

// ❌ Loop — triggers N animations and N re-renders
for (const newRow of newRows) {
  await grid.insertRow(0, newRow);
}
```

### Debounce Filtering

When using filtering with a large dataset, increase the debounce to reduce re-filtering:

```typescript
import '@toolbox-web/grid/features/filtering';

grid.gridConfig = {
  features: { filtering: { debounceMs: 300 } }, // Default: 200
};
```

### Custom Sort Comparators

For fields with expensive comparisons, provide a custom `sortComparator`:

```typescript
{
  field: 'name',
  sortable: true,
  sortComparator: (a: string, b: string) =>
    a.localeCompare(b, undefined, { sensitivity: 'base' }),
}
```

## Rendering Best Practices

### Avoid Direct DOM Manipulation

Let the grid manage its DOM. Direct manipulation can conflict with the render scheduler and virtualization:

```typescript
// ❌ Don't do this — bypasses the grid's render cycle
document.querySelector('.data-grid-row')!.style.background = 'red';

// ✅ Use rowClass/cellClass or a renderer instead
grid.gridConfig = {
  rowClass: (row) => row.status === 'error' ? 'row-error' : '',
};
```

### 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()`):

```typescript
grid.registerStyles('my-highlights', `
  .highlight-row { background: yellow; }
`);

// Clean up when done
grid.unregisterStyles('my-highlights');
```

### 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:

```typescript
// ✅ 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

### 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](/grid/plugins/server-side.md) for queries |
| **1M+ rows** | Don't load into memory — paginate or use [Server-Side](/grid/plugins/server-side.md) | Server-Side plugin with infinite scroll |
| **× 50+ columns** | Horizontal scroll may stutter without column virtualization | Enable [`ColumnVirtualizationPlugin`](/grid/plugins/column-virtualization.md) |

### 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.

1. **Record a representative interaction.** Open Chrome DevTools → Performance → click ⏺ Record → do the slow thing (scroll, sort, filter, edit) for 2-3 seconds → stop.
2. **Find the long tasks.** Anything > 50ms shows up as a red triangle. Click into the flame graph.
3. **Read the stack:**
   - **`renderScheduler.flush` dominates** → grid-internal work. Look one level deeper:
     - `renderRow` / `updateRowDom` heavy → your `renderer` or `cellClass` is slow. Switch to `format` if you only return text.
     - `measureRow` heavy → variable row heights with expensive content. Set a fixed `rowHeight` or memoize.
     - `applyColumns` heavy → too many visible columns. Enable [`ColumnVirtualizationPlugin`](/grid/plugins/column-virtualization.md).
   - **Framework code (React reconciler / Angular CD / Vue render) dominates** → your wrapper is re-rendering the grid on unrelated state changes. Pass `rows` / `gridConfig` as a stable reference (`useMemo`, `computed`, `signal`).
   - **`Recalculate Style` / `Layout` dominate, outside the grid** → something on the page is reacting to the grid's resize. Usually a parent flexbox or a `ResizeObserver` you wrote.

4. **Mark your own boundaries** to make the trace readable:

   ```ts
   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.

5. **Compare against the [comparison page](/grid/comparison.md)** — 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

| 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

Try the interactive stress test below — adjust row and column counts to see how the grid performs with large datasets:

```ts
// PerformanceStressTestDemo.astro
  import '@toolbox-web/grid';
import type { ColumnConfig } from '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/column-virtualization';
import '@toolbox-web/grid/features/filtering';
import '@toolbox-web/grid/features/multi-sort';
import '@toolbox-web/grid/features/pinned-columns';
import '@toolbox-web/grid/features/selection';

  // #region Types & Helpers
  type ExtendedColumnConfig = ColumnConfig & { sticky?: 'left' | 'right'; filterable?: boolean };

  interface BenchmarkResult {
    name: string;
    category: 'render' | 'scroll' | 'operation' | 'memory';
    time: number;
    unit: string;
    target: number;
    passed: boolean;
    note?: string;
  }

  function generateColumns(count: number, options?: { sortable?: boolean; filterable?: boolean }): ExtendedColumnConfig[] {
    const columns: ExtendedColumnConfig[] = [
      { field: 'id', header: 'ID', type: 'number', width: 60, sortable: options?.sortable },
    ];
    for (let i = 1; i < count; i++) {
      columns.push({
        field: `col${i}`,
        header: `Column ${i}`,
        type: 'string',
        width: 100,
        sortable: options?.sortable,
        filterable: options?.filterable,
      });
    }
    return columns;
  }

  function generateRows(rowCount: number, columnCount: number): Record<string, unknown>[] {
    const rows: Record<string, unknown>[] = [];
    for (let i = 0; i < rowCount; i++) {
      const row: Record<string, unknown> = { id: i + 1 };
      for (let j = 1; j < columnCount; j++) {
        row[`col${j}`] = `R${i + 1}C${j}`;
      }
      rows.push(row);
    }
    return rows;
  }

  function formatTime(ms: number): string {
    if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
    if (ms < 1000) return `${ms.toFixed(2)}ms`;
    return `${(ms / 1000).toFixed(2)}s`;
  }

  function formatBytes(bytes: number): string {
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  }
  // #endregion

  // #region Results Rendering
  function renderResultsTable(results: BenchmarkResult[], isComplete = false): string {
    const categories = ['render', 'scroll', 'operation', 'memory'] as const;
    const categoryLabels: Record<string, string> = {
      render: '🎨 Initial Render',
      scroll: '📜 Scroll Performance',
      operation: '⚡ Operations',
      memory: '💾 Memory',
    };

    let html = `<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;">`;

    for (const cat of categories) {
      const catResults = results.filter((r) => r.category === cat);
      if (catResults.length === 0) continue;

      html += `
        <div style="background:var(--sl-color-gray-6,#1e1e1e);border-radius:6px;padding:12px;min-width:0;">
          <h4 style="margin:0 0 10px 0;color:var(--sl-color-white,#e5e5e5);font-size:13px;font-weight:600;">${categoryLabels[cat]}</h4>
          <div style="display:grid;grid-template-columns:1fr auto auto auto;gap:4px 12px;font-size:12px;align-items:center;">
            ${catResults.map((r) => `
              <div style="color:var(--sl-color-white,#e5e5e5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${r.name}</div>
              <div style="color:${r.passed ? '#4ade80' : '#f87171'};font-family:monospace;text-align:right;">
                ${r.unit === 'info' ? '—' : r.unit === 'bytes' ? formatBytes(r.time) : r.unit === 'bool' ? (r.time ? 'Yes' : 'No') : r.unit === 'count' ? r.time.toLocaleString() : formatTime(r.time)}
              </div>
              <div style="color:#888;font-family:monospace;text-align:right;font-size:11px;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${r.note || ''}">
                ${r.note ? r.note : (r.unit === 'bool' || r.unit === 'info' || r.unit === 'count') ? '' : r.target === Infinity ? '(info)' : '&lt;' + (r.unit === 'bytes' ? formatBytes(r.target) : formatTime(r.target))}
              </div>
              <div style="text-align:center;">${r.target === Infinity ? 'ℹ️' : r.passed ? '✅' : '❌'}</div>
            `).join('')}
          </div>
        </div>
      `;
    }

    html += `</div>`;

    if (isComplete) {
      const passed = results.filter((r) => r.passed).length;
      const total = results.length;
      const allPassed = passed === total;
      html += `
        <div style="margin-top:16px;padding:12px;background:${allPassed ? '#166534' : '#991b1b'};border-radius:4px;text-align:center;">
          <strong style="color:#fff;font-size:14px;">${allPassed ? '✅ All benchmarks passed!' : `⚠️ ${passed}/${total} benchmarks passed`}</strong>
        </div>
      `;
    } else {
      html += `<div style="margin-top:16px;padding:8px;opacity:0.6;text-align:center;font-size:12px;">Running... ${results.length} tests completed</div>`;
    }

    return html;
  }
  // #endregion

  // #region DOM References
  const grid = queryGrid('#demo-perf-stress')!;
  const rowsSlider = document.getElementById('stress-rows') as HTMLInputElement;
  const colsSlider = document.getElementById('stress-cols') as HTMLInputElement;
  const rowsVal = document.getElementById('stress-rows-val')!;
  const colsVal = document.getElementById('stress-cols-val')!;
  const runBtn = document.getElementById('stress-run') as HTMLButtonElement;
  const statusEl = document.getElementById('stress-status')!;
  const resultsEl = document.getElementById('stress-results')!;
  // #endregion

  // #region Slider Handlers
  function getRowCount() { return parseInt(rowsSlider.value, 10); }
  function getColCount() { return parseInt(colsSlider.value, 10); }

  rowsSlider.addEventListener('input', () => {
    rowsVal.textContent = getRowCount().toLocaleString();
    updateStatus();
  });
  colsSlider.addEventListener('input', () => {
    colsVal.textContent = String(getColCount());
    updateStatus();
  });

  function updateStatus() {
    statusEl.textContent = `Ready - ${getRowCount().toLocaleString()} rows × ${getColCount()} columns`;
  }

  function applyInitialData() {
    const rowCount = getRowCount();
    const colCount = getColCount();
    statusEl.textContent = `Ready - ${rowCount.toLocaleString()} rows × ${colCount} columns`;
    grid.gridConfig = {
      columns: generateColumns(colCount, { sortable: true, filterable: true }),
      fitMode: 'fixed',
      features: {
        selection: 'range',
        pinnedColumns: true,
        multiSort: { maxSortColumns: 3 },
        filtering: { debounceMs: 0 },
        ...(colCount >= 20 ? { columnVirtualization: { threshold: 20, overscan: 3 } } : {}),
      },
    };
    grid.rows = generateRows(rowCount, colCount);
  }

  // Show initial status without generating data
  updateStatus();
  // #endregion

  // #region Benchmark Runner
  runBtn.addEventListener('click', async () => {
    runBtn.disabled = true;
    const allResults: BenchmarkResult[] = [];
    const rowCount = getRowCount();
    const colCount = getColCount();

    const updateResults = () => { resultsEl.innerHTML = renderResultsTable(allResults); };

    const nextFrame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));

    const measure = async (fn: () => void | Promise<void>): Promise<number> => {
      await nextFrame();
      const start = performance.now();
      await fn();
      await nextFrame();
      return performance.now() - start;
    };

    // Scaling
    const baselineRows = 10_000;
    const baselineCols = 10;
    const rowScale = Math.max(1, Math.sqrt(rowCount / baselineRows));
    const colScale = Math.max(1, Math.sqrt(colCount / baselineCols));
    const cellScale = rowScale * colScale;
    const scaledByRows = (base: number) => Math.round(base * rowScale);
    const scaledByCells = (base: number) => Math.round(base * cellScale);

    const baseColumns = generateColumns(colCount, { sortable: true, filterable: true });
    const baseRows = generateRows(rowCount, colCount);

    // 🎨 INITIAL RENDER
    statusEl.textContent = 'Testing: Initial render (baseline)...';
    grid.gridConfig = { columns: [] };
    grid.rows = [];
    await new Promise((r) => setTimeout(r, 50));

    const renderTime = await measure(() => {
      grid.columns = baseColumns;
      grid.rows = [...baseRows];
    });
    allResults.push({ name: 'Baseline render', category: 'render', time: renderTime, unit: 'ms', target: scaledByCells(100), passed: renderTime < scaledByCells(100) });
    updateResults();

    // Render with features
    statusEl.textContent = 'Testing: Initial render (with features)...';
    grid.gridConfig = { columns: [] };
    grid.rows = [];
    await new Promise((r) => setTimeout(r, 50));

    const pluginColumns = baseColumns.map((col, i) => ({ ...col, sticky: i === 0 ? ('left' as const) : undefined }));
    const pluginRenderTime = await measure(() => {
      grid.gridConfig = {
        columns: pluginColumns as ColumnConfig[],
        fitMode: 'fixed',
        features: {
          selection: 'range',
          pinnedColumns: true,
          multiSort: { maxSortColumns: 3 },
          filtering: { debounceMs: 0 },
          columnVirtualization: { threshold: 20, overscan: 3 },
        },
      };
      grid.rows = [...baseRows];
    });
    allResults.push({ name: 'Render with 5 features', category: 'render', time: pluginRenderTime, unit: 'ms', target: scaledByCells(150), passed: pluginRenderTime < scaledByCells(150) });
    updateResults();

    // Warm render
    statusEl.textContent = 'Testing: Warm render (second render)...';
    const warmRows = generateRows(rowCount, colCount);
    const warmRenderTime = await measure(() => { grid.rows = warmRows; });
    allResults.push({ name: 'Warm render (data swap)', category: 'render', time: warmRenderTime, unit: 'ms', target: scaledByRows(50), passed: warmRenderTime < scaledByRows(50), note: warmRenderTime < pluginRenderTime ? 'Faster than cold ✓' : 'Slower than cold ⚠' });
    updateResults();

    // Time to interactive
    statusEl.textContent = 'Testing: Time to interactive...';
    grid.gridConfig = { columns: [] };
    grid.rows = [];
    await new Promise((r) => setTimeout(r, 50));
    const ttiStart = performance.now();
    grid.gridConfig = {
      columns: pluginColumns as ColumnConfig[],
      fitMode: 'fixed',
      features: {
        selection: 'range',
        pinnedColumns: true,
        multiSort: { maxSortColumns: 3 },
        filtering: { debounceMs: 0 },
        columnVirtualization: { threshold: 20, overscan: 3 },
      },
    };
    grid.rows = [...baseRows];
    await nextFrame();
    const headerCell = grid.querySelector('[role="columnheader"]') as HTMLElement | null;
    if (headerCell) { headerCell.click(); await nextFrame(); }
    const ttiTime = performance.now() - ttiStart;
    allResults.push({ name: 'Time to interactive', category: 'render', time: ttiTime, unit: 'ms', target: scaledByCells(200), passed: ttiTime < scaledByCells(200) });
    updateResults();

    // 📜 SCROLL PERFORMANCE
    statusEl.textContent = 'Testing: Scroll performance...';
    await new Promise((r) => setTimeout(r, 100));
    const scrollContainer = grid.querySelector('.faux-vscroll');
    if (scrollContainer) {
      const totalHeight = scrollContainer.scrollHeight;
      const viewportHeight = scrollContainer.clientHeight;
      const steps = 30;
      const stepSize = (totalHeight - viewportHeight) / steps;

      if (stepSize > 0) {
        // Warm-up
        for (let i = 0; i <= steps; i++) { scrollContainer.scrollTop = i * stepSize; await nextFrame(); }
        scrollContainer.scrollTop = 0;
        await new Promise((r) => setTimeout(r, 50));

        // Measure
        const frameTimes: number[] = [];
        for (let i = 0; i <= steps; i++) {
          const start = performance.now();
          scrollContainer.scrollTop = i * stepSize;
          await nextFrame();
          frameTimes.push(performance.now() - start);
        }

        const avgScroll = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
        const p95Scroll = [...frameTimes].sort((a, b) => a - b)[Math.floor(frameTimes.length * 0.95)];
        const p99Scroll = [...frameTimes].sort((a, b) => a - b)[Math.floor(frameTimes.length * 0.99)];
        const scrollTarget = Math.min(33.33, 17 * (1 + (colCount - 10) / 40));

        allResults.push({ name: 'Scroll avg frame', category: 'scroll', time: avgScroll, unit: 'ms', target: scrollTarget, passed: avgScroll < scrollTarget });
        allResults.push({ name: 'Scroll P95 frame', category: 'scroll', time: p95Scroll, unit: 'ms', target: 33.33, passed: p95Scroll < 33.33 });
        allResults.push({ name: 'Scroll P99 frame', category: 'scroll', time: p99Scroll, unit: 'ms', target: 50, passed: p99Scroll < 50 });
        updateResults();

        // Stress scroll
        statusEl.textContent = 'Testing: Stress scroll (random jumps)...';
        scrollContainer.scrollTop = 0;
        await new Promise((r) => setTimeout(r, 50));
        const stressJumps = 20;
        const stressFrameTimes: number[] = [];
        for (let i = 0; i < stressJumps; i++) {
          const randomPos = Math.random() * (totalHeight - viewportHeight);
          const start = performance.now();
          scrollContainer.scrollTop = randomPos;
          await nextFrame();
          stressFrameTimes.push(performance.now() - start);
        }
        const avgStressScroll = stressFrameTimes.reduce((a, b) => a + b, 0) / stressFrameTimes.length;
        const maxStressScroll = Math.max(...stressFrameTimes);
        allResults.push({ name: 'Stress scroll avg', category: 'scroll', time: avgStressScroll, unit: 'ms', target: 50, passed: avgStressScroll < 50 });
        allResults.push({ name: 'Stress scroll max', category: 'scroll', time: maxStressScroll, unit: 'ms', target: 100, passed: maxStressScroll < 100 });
        updateResults();
        scrollContainer.scrollTop = 0;
        await new Promise((r) => setTimeout(r, 50));
      }
    }

    // Horizontal scroll
    statusEl.textContent = 'Testing: Horizontal scroll performance...';
    await new Promise((r) => setTimeout(r, 100));
    const hScrollContainer = grid.querySelector('.tbw-scroll-area');
    const colVirtPlugin = (grid as any).getPluginByName?.('columnVirtualization');
    if (hScrollContainer && colCount >= 20) {
      const totalWidth = hScrollContainer.scrollWidth;
      const viewportWidth = hScrollContainer.clientWidth;
      const hSteps = 30;
      const hStepSize = (totalWidth - viewportWidth) / hSteps;
      if (hStepSize > 0) {
        for (let i = 0; i <= hSteps; i++) { hScrollContainer.scrollLeft = i * hStepSize; await nextFrame(); }
        hScrollContainer.scrollLeft = 0;
        await new Promise((r) => setTimeout(r, 50));
        const hFrameTimes: number[] = [];
        for (let i = 0; i <= hSteps; i++) {
          const start = performance.now();
          hScrollContainer.scrollLeft = i * hStepSize;
          await nextFrame();
          hFrameTimes.push(performance.now() - start);
        }
        const avgHScroll = hFrameTimes.reduce((a, b) => a + b, 0) / hFrameTimes.length;
        const p95HScroll = [...hFrameTimes].sort((a, b) => a - b)[Math.floor(hFrameTimes.length * 0.95)];
        allResults.push({ name: 'H-Scroll avg frame', category: 'scroll', time: avgHScroll, unit: 'ms', target: 50, passed: avgHScroll < 50 });
        allResults.push({ name: 'H-Scroll P95 frame', category: 'scroll', time: p95HScroll, unit: 'ms', target: 66.67, passed: p95HScroll < 66.67 });
        if (colVirtPlugin) {
          const isVirt = colVirtPlugin.getIsVirtualized();
          const range = colVirtPlugin.getVisibleColumnRange();
          allResults.push({ name: `Col virtualization (${range.end - range.start + 1}/${colCount} visible)`, category: 'scroll', time: isVirt ? 1 : 0, unit: 'bool', target: 1, passed: isVirt });
        }
        updateResults();
      }
    }

    // ⚡ OPERATIONS
    // Sort
    statusEl.textContent = 'Testing: Sort operation...';
    if (scrollContainer) scrollContainer.scrollTop = 0;
    await new Promise((r) => setTimeout(r, 50));
    const sortPlugin = (grid as any).getPluginByName?.('multiSort') as { setSortModel: (m: unknown[]) => void; clearSort: () => void } | undefined;
    if (sortPlugin) {
      const sortTarget = Math.round(150 * (Math.log2(rowCount) / Math.log2(10000)));
      const sortTime = await measure(() => { sortPlugin.setSortModel([{ field: 'id', direction: 'asc' }]); });
      allResults.push({ name: 'Sort (ascending)', category: 'operation', time: sortTime, unit: 'ms', target: sortTarget, passed: sortTime < sortTarget });
      updateResults();

      grid.rows = generateRows(rowCount, colCount);
      await nextFrame();
      await new Promise((r) => setTimeout(r, 50));
      const reverseSortTime = await measure(() => { sortPlugin.setSortModel([{ field: 'id', direction: 'desc' }]); });
      allResults.push({ name: 'Sort (descending)', category: 'operation', time: reverseSortTime, unit: 'ms', target: sortTarget, passed: reverseSortTime < sortTarget });
      updateResults();
      sortPlugin.clearSort();
      await nextFrame();
    }

    // Filter
    statusEl.textContent = 'Testing: Filter operation...';
    const filterPlugin = (grid as any).getPluginByName?.('filtering') as { setFilter: (f: string, v: unknown) => void } | undefined;
    if (filterPlugin) {
      const filterTime = await measure(() => { filterPlugin.setFilter('col1', { type: 'text', operator: 'contains', value: 'R1' }); });
      allResults.push({ name: 'Filter (to ~10%)', category: 'operation', time: filterTime, unit: 'ms', target: scaledByRows(50), passed: filterTime < scaledByRows(50) });
      updateResults();
      const clearFilterTime = await measure(() => { filterPlugin.setFilter('col1', null); });
      allResults.push({ name: 'Clear filter', category: 'operation', time: clearFilterTime, unit: 'ms', target: scaledByRows(50), passed: clearFilterTime < scaledByRows(50) });
      updateResults();
    }

    // Data replacement
    statusEl.textContent = 'Testing: Data replacement...';
    const newRows = generateRows(rowCount, colCount);
    const replaceTime = await measure(() => { grid.rows = newRows; });
    allResults.push({ name: 'Full data replace', category: 'operation', time: replaceTime, unit: 'ms', target: scaledByRows(50), passed: replaceTime < scaledByRows(50) });
    updateResults();

    // Selection
    statusEl.textContent = 'Testing: Selection...';
    const selectionPlugin = (grid as any).getPluginByName?.('selection') as { setRanges: (r: unknown[]) => void; clearSelection: () => void } | undefined;
    if (selectionPlugin) {
      const selectAllTime = await measure(() => {
        selectionPlugin.setRanges([{ from: { row: 0, col: 0 }, to: { row: rowCount - 1, col: colCount - 1 } }]);
      });
      allResults.push({ name: 'Select all cells', category: 'operation', time: selectAllTime, unit: 'ms', target: 50, passed: selectAllTime < 50 });
      updateResults();
      const clearSelectionTime = await measure(() => { selectionPlugin.clearSelection(); });
      allResults.push({ name: 'Clear selection', category: 'operation', time: clearSelectionTime, unit: 'ms', target: 50, passed: clearSelectionTime < 50 });
      updateResults();
    }

    // Rapid data updates
    statusEl.textContent = 'Testing: Rapid data updates...';
    const updateCount = 10;
    const updateTimes: number[] = [];
    for (let i = 0; i < updateCount; i++) {
      const modifiedRows = [...grid.rows] as Record<string, unknown>[];
      const updateSize = Math.max(1, Math.floor(rowCount * 0.01));
      for (let j = 0; j < updateSize; j++) {
        const idx = (i * updateSize + j) % rowCount;
        modifiedRows[idx] = { ...modifiedRows[idx], col1: `Updated-${i}-${j}` };
      }
      const t = await measure(() => { grid.rows = modifiedRows; });
      updateTimes.push(t);
    }
    const avgUpdateTime = updateTimes.reduce((a, b) => a + b, 0) / updateTimes.length;
    allResults.push({ name: `Rapid update (${updateCount}× 1%)`, category: 'operation', time: avgUpdateTime, unit: 'ms', target: scaledByRows(30), passed: avgUpdateTime < scaledByRows(30) });
    updateResults();

    // Column config change
    statusEl.textContent = 'Testing: Column config change...';
    const configChangeTime = await measure(() => {
      const newColumns = baseColumns.map((col, i) => ({ ...col, width: i === 1 ? 200 : col.width }));
      grid.columns = newColumns;
    });
    allResults.push({ name: 'Column config change', category: 'operation', time: configChangeTime, unit: 'ms', target: scaledByCells(50), passed: configChangeTime < scaledByCells(50) });
    updateResults();

    // Column resize
    statusEl.textContent = 'Testing: Column resize performance...';
    const resizeFrameTimes: number[] = [];
    for (let i = 0; i < 20; i++) {
      const width = 80 + (i % 2 === 0 ? 40 : -20);
      const start = performance.now();
      grid.columns = baseColumns.map((col, idx) => ({ ...col, width: idx === 1 ? width : col.width }));
      await nextFrame();
      resizeFrameTimes.push(performance.now() - start);
    }
    const avgResize = resizeFrameTimes.reduce((a, b) => a + b, 0) / resizeFrameTimes.length;
    const p95Resize = [...resizeFrameTimes].sort((a, b) => a - b)[Math.floor(resizeFrameTimes.length * 0.95)];
    allResults.push({ name: 'Resize avg frame', category: 'operation', time: avgResize, unit: 'ms', target: 33.33, passed: avgResize < 33.33 });
    allResults.push({ name: 'Resize P95 frame', category: 'operation', time: p95Resize, unit: 'ms', target: 50, passed: p95Resize < 50 });
    updateResults();

    // 💾 MEMORY
    statusEl.textContent = 'Calculating: Memory & DOM metrics...';
    const estimatedBytesPerRow = 40 + colCount * 52;
    const estimatedDataSize = rowCount * estimatedBytesPerRow;
    allResults.push({ name: 'Est. data size', category: 'memory', time: estimatedDataSize, unit: 'bytes', target: 500 * 1024 * 1024, passed: estimatedDataSize < 500 * 1024 * 1024 });
    allResults.push({ name: 'Est. bytes/row', category: 'memory', time: estimatedBytesPerRow, unit: 'bytes', target: 5000, passed: estimatedBytesPerRow < 5000 });

    const rowElements = grid.querySelectorAll('.data-grid-row');
    const renderedRowCount = rowElements?.length ?? 0;
    const maxExpectedRows = Math.min(rowCount, 100);
    allResults.push({ name: `DOM rows (${renderedRowCount}/${rowCount.toLocaleString()})`, category: 'memory', time: renderedRowCount, unit: 'count', target: maxExpectedRows, passed: renderedRowCount <= maxExpectedRows, note: renderedRowCount <= maxExpectedRows ? 'Virtualization active' : 'Too many DOM nodes!' });

    const cellElements = grid.querySelectorAll('.cell');
    const renderedCellCount = cellElements?.length ?? 0;
    const expectedCellsPerRow = colCount <= 20 ? colCount : Math.min(colCount, 30);
    const maxExpectedCells = maxExpectedRows * expectedCellsPerRow;
    allResults.push({ name: 'DOM cells rendered', category: 'memory', time: renderedCellCount, unit: 'count', target: maxExpectedCells, passed: renderedCellCount <= maxExpectedCells, note: `${renderedCellCount.toLocaleString()} cells in DOM` });
    updateResults();

    // COMPLETE
    statusEl.textContent = 'Benchmark complete!';
    runBtn.disabled = false;
    resultsEl.innerHTML = renderResultsTable(allResults, true);

    console.table(allResults.map((r) => ({
      Benchmark: r.name,
      Result: r.unit === 'bytes' ? formatBytes(r.time) : r.unit === 'count' ? r.time.toLocaleString() : r.unit === 'bool' ? (r.time ? 'Yes' : 'No') : r.unit === 'info' ? (r.note || '—') : formatTime(r.time),
      Target: r.unit === 'bytes' ? '< ' + formatBytes(r.target) : r.unit === 'count' || r.unit === 'bool' || r.unit === 'info' ? (r.note || '—') : '< ' + formatTime(r.target),
      Status: r.passed ? '✅ PASS' : '❌ FAIL',
    })));
  });
  // #endregion
```

## See Also

  - [Compared to other grids](/grid/comparison.md): Live benchmarks vs AG Grid, Tabulator, SlickGrid — runnable in your browser
  - [Architecture](/grid/architecture.md): How the render scheduler and virtualization work internally
  - [Column Virtualization](/grid/plugins/column-virtualization.md): Render only visible columns for wide grids (50+ columns)
  - [Server-Side Plugin](/grid/plugins/server-side.md): Stream rows for million-row datasets without loading them into memory
