Benchmark harness

Measure grid time-to-first-paint at every (row count × column count) you care about. Run on your branch, your machine, your data shape. Use the output to track regressions and to justify "this dataset needs server-side pagination".

When

You're answering:

The same matrix runs in CI as a perf gate, and locally before a PR.

The harness

The pattern: a tiny <SvGrid> mounted in an off-screen probe div, wrapped in performance.now() brackets. The probe unmounts between measurements so each cell starts from a clean state.

<script lang="ts">
  import {
    SvGrid, tableFeatures,
    rowSortingFeature, columnFilteringFeature, rowSelectionFeature,
    type ColumnDef,
  } from '@svgrid/grid'

  const ROW_SIZES = [100, 1_000, 10_000, 50_000, 100_000] as const
  const COL_SIZES = [5, 10, 20, 50] as const

  type Cell = { rows: number; cols: number; ms: number | null }
  const matrix = $state<Cell[][]>(ROW_SIZES.map((r) =>
    COL_SIZES.map((c) => ({ rows: r, cols: c, ms: null })),
  ))
  let busy = $state(false)
  let running = $state<{ r: number; c: number } | null>(null)

  type Row = Record<string, unknown> & { __id: number }
  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
  })

  /** Deterministic seeded data so the same matrix is reproducible. */
  function makeData(rows: number, cols: number) {
    let prng = 0xABCDEF >>> 0
    const rnd = () => (prng = (prng * 1664525 + 1013904223) >>> 0) / 0xFFFFFFFF
    const columns: ColumnDef<typeof features, Row>[] = []
    for (let i = 0; i < cols; i += 1)
      columns.push({ field: `c${i}` as keyof Row & string, header: `Col ${i + 1}`, width: 100 })
    const data: Row[] = new Array(rows)
    for (let i = 0; i < rows; i += 1) {
      const r: Row = { __id: i }
      for (let j = 0; j < cols; j += 1) r[`c${j}`] = Math.round(rnd() * 10000)
      data[i] = r
    }
    return { data, columns }
  }

  /** The off-screen probe. The grid mounts here for measurement. */
  type Probe = { data: Row[]; columns: ColumnDef<typeof features, Row>[] } | null
  let probe = $state<Probe>(null)

  async function runOne(rIdx: number, cIdx: number) {
    busy = true
    running = { r: rIdx, c: cIdx }
    matrix[rIdx]![cIdx]!.ms = null
    probe = null
    await new Promise((r) => requestAnimationFrame(() => r(null)))

    const { rows, cols } = matrix[rIdx]![cIdx]!
    // Generate data BEFORE the timer so the allocation doesn't
    // pollute the time-to-paint number. Yield once after so the
    // status-cell repaint doesn't get blocked by the alloc.
    const { data, columns } = makeData(rows, cols)
    await new Promise((r) => requestAnimationFrame(() => r(null)))

    const t0 = performance.now()
    probe = { data, columns }
    // One rAF = first paint with the new grid mounted. That's
    // "time to first paint".
    await new Promise((r) => requestAnimationFrame(() => r(null)))
    const t1 = performance.now()

    matrix[rIdx]![cIdx]!.ms = Math.round((t1 - t0) * 10) / 10
    running = null
    busy = false
  }

  async function runAll() {
    if (busy) return
    busy = true
    for (let r = 0; r < ROW_SIZES.length; r += 1)
      for (let c = 0; c < COL_SIZES.length; c += 1)
        await runOne(r, c)
    busy = false
  }

  function heatColor(ms: number | null): string {
    if (ms === null) return 'transparent'
    if (ms < 16)   return 'rgba(34,197,94,0.45)'
    if (ms < 50)   return 'rgba(132,204,22,0.55)'
    if (ms < 100)  return 'rgba(245,158,11,0.55)'
    if (ms < 250)  return 'rgba(249,115,22,0.65)'
    return 'rgba(239,68,68,0.75)'
  }
</script>

<button onclick={runAll} disabled={busy}>{busy ? 'Running…' : 'Run all'}</button>

<table>
  <thead>
    <tr><th></th>
      {#each COL_SIZES as c (c)}<th>{c} cols</th>{/each}
    </tr>
  </thead>
  <tbody>
    {#each matrix as row, rIdx (rIdx)}
      <tr>
        <th>{ROW_SIZES[rIdx]?.toLocaleString()} rows</th>
        {#each row as cell, cIdx (cIdx)}
          <td style="background: {heatColor(cell.ms)}">
            <button onclick={() => !busy && runOne(rIdx, cIdx)} disabled={busy}>
              {cell.ms === null ? '-' : cell.ms < 10 ? cell.ms.toFixed(1) + ' ms' : Math.round(cell.ms) + ' ms'}
            </button>
          </td>
        {/each}
      </tr>
    {/each}
  </tbody>
</table>

<!-- The off-screen probe. Mount target for measured timing brackets. -->
<div style="position: fixed; left: -9999px; top: -9999px; width: 800px; height: 400px; visibility: hidden;">
  {#if probe}
    <SvGrid
      data={probe.data}
      columns={probe.columns}
      features={features}
      showRowSelection={false}
      showPagination={false}
      enableInlineEditing={false}
      enableCellSelection={false}
      rowHeight={26}
      containerHeight="400px"
      fitColumns={false}
    />
  {/if}
</div>

What the timing actually captures

The off-screen probe has visibility: hidden but real width / height so the browser still computes layout - paint cost is real.

Reading the matrix

Suggested thresholds for an internal "is this still good?" gauge:

Size Healthy Server-side recommended above
100×5 < 16 ms -
1k×10 < 50 ms -
10k×20 < 100 ms -
50k×20 < 250 ms dataset > 50k
100k×50 < 1 s absolutely yes

If a "healthy" cell jumps red between branches, you regressed something - bisect with a smaller matrix.

Run in CI

The same harness runs headless in Playwright. Mount the matrix page at whatever route you put it on, click "Run all", read the cells, fail the job if any cell exceeds the threshold. Substitute your own route below - /dev/stress-matrix is a placeholder, not a route this package ships:

test('grid time-to-first-paint regression gate', async ({ page }) => {
  await page.goto('/dev/stress-matrix') // your route, wherever you mounted the matrix
  await page.getByRole('button', { name: /Run all/ }).click()
  await page.waitForFunction(() => !document.querySelector('button[disabled]'))
  const cells = await page.locator('td button').allTextContents()
  // Smoke-check: every cell now has a number, none are placeholders.
  expect(cells.every((c) => /\d/.test(c))).toBe(true)
  // Project-specific gates here:
  //   expect(parseFloat(cells[0])).toBeLessThan(20)
  //   ...
})

Demo

Two shipped demos already instrument themselves with performance.now() and make reasonable targets to point a harness at:

Open the live example: 100k rows × 100 columns (Getting Started)

06-large-dataset builds up to 100,000 rows x 95 columns and prints its own mount time. 78-million-rows goes to 1,000,000 rows, chunking the build at 50,000 per animation frame; it is the one driven by tests/e2e/million-rows-scroll.spec.ts.

Timing a render

The number worth measuring is the one your users feel: how long from handing the grid new rows to the browser painting them. requestAnimationFrame after the assignment brackets exactly that.

<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'

  type Row = { id: number; a: string; b: number; c: number }

  const make = (n: number): Row[] =>
    Array.from({ length: n }, (_, i) => ({
      id: i, a: 'row ' + i, b: (i * 37) % 1000, c: (i * 91) % 500,
    }))

  const columns: GridColumns<Row> = [
    { field: 'a', header: 'Label', width: 160 },
    { field: 'b', header: 'B', width: 100 },
    { field: 'c', header: 'C', width: 100 },
  ]

  let rows = $state<Row[]>(make(1000))
  let last = $state('(not measured)')

  function measure(n: number) {
    const t0 = performance.now()
    rows = make(n)
    // One frame later the browser has painted the new rows.
    requestAnimationFrame(() => {
      last = n.toLocaleString() + ' rows in ' + Math.round(performance.now() - t0) + ' ms'
    })
  }
</script>

<div>
  <button type="button" onclick={() => measure(1000)}>1,000 rows</button>
  <button type="button" onclick={() => measure(10000)}>10,000 rows</button>
  <button type="button" onclick={() => measure(50000)}>50,000 rows</button>
</div>

<p>{last}</p>

<SvGrid data={rows} {columns} sortable />

See also

Related articles