# Performance Facts

Source: https://lumeo.nativ.sh/docs/performance-facts

# Performance Facts

Real numbers from one local machine, measured against real interactions — DataGrid rendering a large dataset, the resize/reorder hot paths, a toast burst, and WASM boot time. Same honesty standard as Bundle Facts: the exact methodology per metric, an honest reproduce section, and the caveats we hit along the way.

Performance

## Column reorder moves cost 0.0000 ms of JS per move event

Median of 3,000 synthetic pointer moves against a loaded grid, measured 2026-07-12 (4.2.0+main) on one local Windows machine — see "How each metric was measured" below. (Resize's own number is enqueue-only handler cost, not directly comparable — see below.)

## The measured numbers

Median of 5 independent runs per metric, fresh browser page per run. Full per-run data lives in `scripts/perf/results/*.json`.

5.2 s

### DataGrid initial render

10,000 rows, click-to-first-row, virtualized

55 fps

### Virtualized scroll

10,000 rows, 1s scroll sweep

261 ms

### Sort 10k rows

Click header → aria-sort flips + re-render

341 ms

### Filter 10k rows

Toolbar search input → body re-renders

0.0033 ms

### Column resize (enqueue-only)

Handler cost per move; excludes the deferred rAF width write

0.0112 ms

### Column reorder

Per pointer-move event, live sibling-shift

532 ms

### Toast burst settle

5 toasts (see MaxToasts crash caveat)

817 ms

### WASM boot-to-interactive

Trigger → MainLayout first interactive render

## How each metric was measured

All four scripts drive `docs/Lumeo.Docs/Pages/E2E/PerfBench.razor` (a `noindex`, nav-less harness page — same pattern as the existing `P0Harness.razor`) or the docs home, with Playwright.

DataGrid: initial render, scroll fps, sort, filter

10,000 rows are generated once (off the timed path, in `OnInitializedAsync`) so the "initial render" number is DataGrid's own render/virtualization cost, not this interpreted WASM build's cost of synthesizing the dataset. **Initial render** times click-to-load until the first virtualized `<tr>` lands in the DOM. **Scroll fps** drives the virtualized scroll container's `scrollTop` via a `requestAnimationFrame` loop for 1s, counting frames. **Sort** clicks the Salary column header and times until `aria-sort` flips. **Filter** types into the toolbar's global search box and times until the body re-renders. Every timer is `performance.now()` running inside the page — not Playwright/CDP round-trip time.

Why 10k rows, not 100k

Measured by hand, not on every automated run: materializing 100,000 rows plus DataGrid's client-side sort/search indices reliably exhausts the wasm32 linear-memory ceiling — reproduced even after raising `WasmInitialHeapSize`/`EmccMaximumHeapSize` to ~3.5 GB/~3.75 GB (right up against wasm32's 4 GB hard limit) in this non-AOT-compiled (interpreter-tier) docs build. Even 75,000 rows — the largest size that fit — took roughly a **minute** of wall clock just for the first render, confirmed twice. A genuinely 100k+ row _client_ dataset should use DataGrid's `Virtualized` + `OnRangeRequest` server-mode path ([/components/datagrid](/components/datagrid)) instead of one in-memory `List`.

Column resize / reorder: ms per move

CHANGELOG.md's 4.1.0 entry (PR #353) claims all per-move drag/resize work stays in JavaScript — zero .NET interop calls during the actual pointer movement — at ~0.004–0.007 ms per move event. This page re-measures that claim independently: a loaded (10k-row) grid, then a tight in-page loop dispatches 3,000 synthetic `PointerEvent`s directly at the same elements `registerColumnResize`/`registerColumnReorder` (`src/Lumeo/wwwroot/js/components.js`) listen on, timing the whole loop and dividing by move count — deliberately avoiding a Playwright/CDP round trip per event, which would otherwise dwarf the actual per-move cost being measured. Reorder is higher than resize (0.0112 ms vs 0.0033 ms) because it also runs a live sibling-shift projection per move; resize lands just under the claimed ~0.004–0.007 ms range and reorder lands just above it, so neither figure is inside that range — both are still well under a tenth of a millisecond, consistent with the "zero .NET interop per move" design, but the exact claimed band undersells reorder's cost.

Resize's number is enqueue-only, not the full cost

`registerColumnResize` only records the pending width on each move and defers the actual width/guideline DOM write to a `requestAnimationFrame` callback (once per frame). Because this benchmark dispatches all 3,000 synthetic moves back-to-back inside one synchronous loop, it never yields to let that callback run, so the **0.0033 ms resize figure is enqueue-only handler cost** and excludes the deferred DOM write a real drag actually pays. Reorder's DOM write happens synchronously on every move, so its 0.0112 ms figure is the full per-move cost and is not directly comparable to resize's. We deliberately did not force a real rAF flush per synthetic move — that would make the number dominated by rAF's ~16 ms scheduling latency rather than actual handler cost, a different and more misleading number than the one disclosed here.

Toast burst: time to settle

"Settle" is from the trigger click to 150 ms of quiet after both the last DOM mutation inside `<body>` (via a `MutationObserver`) and the last toast's CSS enter animation actually finishes (via `animationend`) — the animation itself doesn't mutate the DOM, so tracking mutations alone would report "settled" while toasts were still visibly sliding in.

A real bug, disclosed rather than hidden

This was supposed to be a 100-toast burst. The docs site's global `ToastProvider` uses the default `MaxToasts=5`; firing more than ~6–7 `ToastService.Show()` calls in one synchronous burst reproducibly **crashes the WASM renderer** (Playwright reports `Target crashed`, not a catchable JS exception) via the oldest-eviction path. Confirmed by hand: 5 toasts stable, 6 (one eviction) stable, 10 (five evictions) crashes the tab. So the number above is a 5-toast burst, and this finding is filed as a follow-up rather than papered over — a genuine 100-toast burst cannot currently be benchmarked until that eviction-path bug is fixed.

WASM boot-to-interactive on the docs home

The docs home ('/') deliberately defers `Blazor.start()` behind the visitor's first real interaction (pointerdown/keydown/touchstart fire it instantly; sustained scroll/wheel needs ~150 ms; a 10s fallback boots regardless — see `wwwroot/index.html`). That hides boot cost behind idle reading time a real visitor spends before touching anything, so it isn't a fair "boot cost" number on its own. This script instead dispatches a synthetic `pointerdown` immediately after navigation — the same "instant" trigger path a real click uses — and times from that dispatch to `document.documentElement.dataset.blazorReady` flipping to `"true"` — the exact moment `MainLayout`'s first interactive render completes. That isolates the runtime's own boot cost from however long a visitor happened to wait before interacting.

Machine & environment

Measured

2026-07-12, Lumeo 4.2.0+main

OS

Windows 11 Pro, x64

CPU

AMD Ryzen 7 7800X3D, 8-core / 16-thread

RAM

31.1 GiB

Browser

Chromium 149.0.7827.55 (Playwright, headless)

Node.js

v24.7.0

Server

dotnet run -c Release (local dev server, not a CDN deploy)

## Caveats

This is not a lab. Read these before quoting a number.

Single local Windows dev machine, not a CI runner or a lab bench. Absolute numbers will differ on your hardware — the relative shape (hot paths sub-millisecond, bulk operations low-hundreds-of-ms) is the more portable takeaway.

Chromium only, via Playwright headless. No Firefox/WebKit/mobile numbers here.

Served by dotnet run's Development dev-server, not a published, CDN-hosted production build — real production latency will differ (mostly for network-bound metrics; the in-page timings above don't depend on transport).

This build is non-AOT-compiled (interpreter tier). AOT-published apps trade a slower build for a materially faster runtime — the DataGrid bulk-operation numbers above are pessimistic relative to what an AOT-published app would show.

DataGrid's automated dataset is 10,000 rows, not the 100,000 this page's title once implied — see "Why 10k rows, not 100k" above for the measured 100k OOM and the 75k/~60s render finding.

The toast burst is 5 toasts, not 100 — see "A real bug, disclosed rather than hidden" above.

The column-resize figure (0.0033 ms) is enqueue-only handler cost and excludes the deferred rAF DOM write a real drag pays — it is not directly comparable to reorder's figure, which includes its (synchronous) DOM write. See "Column resize / reorder" above.

The WASM boot number is measured against the docs app's DEFAULT (auto-calculated ~108 MB) heap, not the 512 MB perf heap the DataGrid/toast benchmarks below use — see "How to reproduce" for why boot needs a separate server session.

## How to reproduce

Every script and its full per-run JSON output lives in the repo — `scripts/perf/`. We believe the numbers; check them yourself.

\# 1a. Boot cost — DEFAULT heap (what real visitors get)
export DOTNET\_ROLL\_FORWARD=Major
cd docs/Lumeo.Docs
dotnet run --arch x64 -c Release --urls http://localhost:5287

# in another shell
cd scripts/perf
npm install
node wasm-boot.mjs
# stop the server, then:

# 1b. DataGrid / hot-path / toast benchmarks — need the 512 MB perf heap
cd docs/Lumeo.Docs
dotnet run --arch x64 -c Release --urls http://localhost:5287 -p:LumeoPerfHeap=true

# in another shell
cd scripts/perf
node datagrid-100k.mjs
node datagrid-hotpaths.mjs
node toast-burst.mjs

Two server sessions on purpose — `WasmInitialHeapSize` is a publish-time property for the whole app, not scoped per route, so an accurate boot number needs a session without the perf heap. See `scripts/perf/README.md`.

## See it for yourself

Run the scripts, read the JSON, poke at the harness page. We'd rather you distrust a number and check it than take our word for it.

[Bundle Facts](/docs/bundle-facts) [DataGrid docs](/components/datagrid)
