Good performance benchmarks for the web should measure things that actually affect the user's experience: how quickly components appear, how promptly interactions update the screen, and how smoothly collections scroll. Useful measurements require representative workloads in realistic environments.
The suite builds production React and React Aria with Parcel and runs evaluations in Chromium through Playwright. It runs locally, with no CI integration. Snapshots and results stay in the git-ignored perf/.local/ directory.
- When to use it
- Quick start
- Compare a change
- Choose a workload
- Read the results
- Investigate unexpected results
- Maintain the suite
- Measurement reference
Use the suite to investigate a performance regression, evaluate an optimization, or check changes to shared rendering, interaction, collection, or virtualization code. Choose scenarios that exercise the affected behavior; add a representative scenario when coverage is missing.
The fixtures cover mounting, keyboard and pointer interactions, selection, drag and drop, collection updates, incremental loading, scrolling, and resizing. They include application logic, React work, and browser rendering work. Hydration, network loading, memory retention, and other browsers are outside the current coverage.
From the repository root, with dependencies already installed:
# Install the matching full Chromium browser if it is missing.
yarn playwright install chromium
# Check the setup and all scenarios.
yarn perf smokeSmoke builds a temporary production snapshot, compares it with itself, and removes the snapshot afterward. It runs two browser launches with one warmup and one measured trial per arm, scenario, and launch. Every action must complete, its assertions must pass, and the report must be generated. Results are kept. Allow a few minutes for the full suite.
Smoke verifies that the suite works; it does not establish a performance improvement or regression.
For a focused check, use yarn perf smoke --scenario table.scroll. Pass --build <name> to reuse an existing snapshot. Use yarn perf list to list actions and yarn perf --help for all options.
With the harness present on the source you want to compare against:
yarn perf build --name baselineChoose an unused name for each snapshot. Builds cannot overwrite existing names, and the runner does not manage branches or change your working tree. If the baseline branch predates the suite, apply the same harness there before building it.
Apply the library change, keeping the harness and dependencies identical, then run:
yarn perf build --name candidate
yarn perf compare --base baseline --candidate candidate --scenario listbox.focus,tableSelect the affected actions or fixtures with --scenario; omit it to run all scenarios. Keep the default measurement counts for performance conclusions: eight browser launches, five measured trials per arm per launch, and two discarded warmups. Reducing counts is useful for checking setup, but reduces confidence in the comparison.
Both snapshots are measured in the same run with alternating arm order. Use the same machine, browser, and power settings for follow-up runs, close heavy workloads, and avoid concurrent builds or benchmarks. The default is headless full Chromium. With --headed, keep the measured window foreground.
If the harness or dependencies change, rebuild both snapshots. Comparisons reject incompatible snapshots and modified bundles; see snapshot verification.
Open report.md in the results directory printed by the command. If an improvement or regression is detected, confirm it before relying on it:
yarn perf confirm --results <results-id> --runs 2<results-id> is the directory name under perf/.local/results/. Confirmation repeats detected scenarios with the original settings and new seeds; use --scenario to select other scenarios from that run. It disables diagnostic tracing. Choose the number of confirmation runs before starting; every requested run is performed regardless of early verdicts.
Confirmation checks both snapshots' artifact, harness, dependency-lock, and tool-version fingerprints against the original results. Reusing a deleted snapshot's name for a different build cannot confirm the old comparison. If the snapshots have changed, start a new comparison.
The confirmation summary.md places each repeat beside the original:
| Conclusion | Meaning |
|---|---|
| Confirmed | Every confirmation run reached the original verdict. |
| Not confirmed | At least one run contradicted the original verdict. |
| Unresolved | The repeats did not consistently support or contradict it. |
Judge the size of the change from the confirmation runs: selecting an initial run because it showed an effect can overstate that effect.
A filter accepts an exact scenario ID or a whole fixture, separated by commas. For example, --scenario table.scroll,collection runs one table action and all collection-mutation actions.
| Fixture filter | Default workload | Actions |
|---|---|---|
listbox |
100 items with labels, team, and status | Mount, focus, select, unrelated parent update |
combobox |
500 options with an icon, label, and description | Open, type 9 to filter, type a second 9 to narrow, keyboard and pointer navigation |
select |
500 rich options in a controlled Select | Pointer navigation in an open popup, choose an option and close the popup |
combobox-virtual |
10,000 rich options in a virtualized popup, fixed 52 px rows | Open, type 9 to filter, keyboard navigation |
select-virtual |
10,000 rich options in a virtualized, controlled Select | Open, choose an option and close the popup |
table-data |
100 nonvirtualized rows × six columns | Sort, filter by 9, clear the filter, move cell focus without selection |
table-cells |
100 nonvirtualized rows × six columns with controls | Mount, select one/all rows, open/close a menu or dialog |
table |
10,000 virtualized rows × six columns, fixed 40 px rows | Mount, scroll 4,000 px near the top or after traversing 4,000 rows, update one visible record, select all rows, shift-click a 4,001-row range |
infinite |
A virtualized table that loads 50-row pages from 500 prepared records | Scroll 4,000 px while two more pages load |
table-measured |
1,000 virtualized rows × eight columns, measured heights and wrapping labels | Mount, drag the labels resizer, narrow/widen labels, resize another column, narrow or continuously animate the container with fitted labels |
variable |
10,000 virtualized items with wrapping descriptions | Mount, scroll 4,000 px near the top or after traversing 160,000 px, narrow from 600 to 360 px |
collection |
100 GridList records, a prepared 50-record page, and a replacement dataset | Append, delete five selected records, replace the dataset |
tree |
20 projects with 50 files each | Expand, navigate into a project, collapse |
resize |
500 nonvirtualized rows × six flexible columns | Mount, narrow the container from 960 to 660 px |
form |
30 mixed text fields, checkboxes, and switches | Mount, type in a controlled field |
reorder |
200 GridList items with drag buttons and useDragAndDrop reordering |
Start a keyboard drag, move the drop target, drop |
dnd |
200 draggable cards beside an independently controlled text field | Type after pointer input, plus a baseline that types while already in keyboard mode |
table-measured uses estimatedRowHeight: 32 and rows containing two, four, or six wrapping labels. Its discrete actions change the labels column from 300 to 100 px, widen it back, resize another column from 150 to 170 px while labels remain narrow, or narrow the container from 1,000 to 800 px. Container actions first fit the labels column, so it shares the flexible width equally with the review column: 199 px each at 1,000 px and 99 px each at 800 px. Narrowing the container therefore rewraps labels and changes row heights, as a sidebar opening beside a wrapping column would. table-measured.resize-animation starts from the same fitted layout and animates container width between 1,000 and 800 px over five 600 ms legs, ending at 800 px. This exercises repeated ResizeObserver, table context updates, and row remeasurement during a sidebar animation; it does not drive a column resize handle. Completion requires the animation to end, three stable layout frames, the requested column widths, natural content heights, and contiguous rows filling the viewport.
table.scroll-deep first traverses 160,000 px in the same mounted table using a browser-generated wheel gesture at 40,000 px/s. This preparation builds scroll history outside the measurement window. It then measures the same 4,000 px at 2,000 px/s as table.scroll, ending at row 4,100. variable.scroll-deep does the same in the variable-height list: rows skipped by the fast traversal keep their estimated heights, rows near the destination are measured as they render, and the measured scroll must settle to a valid layout beyond record 1,000. The history distance is fixed as --scale increases the dataset. Each deep action adds about four seconds of preparation per trial, plus rendering overhead.
To check the new interaction workloads together:
yarn perf smoke --scenario combobox-virtual,select-virtual,infinite,reorder,table.select-range,table-measured.resize-dragUse run to observe one build without a comparison verdict:
yarn perf run --build candidate --scenario table-data,table-cells--scale multiplies record/control counts, not the viewport dimensions. For example, --scale 5 gives the two 100-row table fixtures 500 rows. For trees it increases files per project; collection deletion stays at five records. --cpu applies browser CPU throttling (Emulation.setCPUThrottlingRate) as a separate experiment; it does not emulate mobile hardware.
yarn perf compare --base baseline --candidate candidate --scenario table-data --scale 5
yarn perf compare --base baseline --candidate candidate --scenario table.scroll --cpu 4Each run saves these files under perf/.local/results/<results-id>/:
| File | Contents |
|---|---|
report.md |
Comparison verdicts, changes by browser launch, and supporting measurements |
results.json |
Raw samples, timestamps, ordering, build fingerprints, options, and machine/browser metadata |
*.trace.json |
Separate diagnostic reruns, when --trace is requested |
failure.png |
A failure screenshot when the active page can be captured |
Failed or interrupted measurements retain partial results but produce no comparison verdicts. Completion timeouts, failed assertions, and browser runtime/console errors invalidate the run; they are not discarded as outliers. A diagnostic trace failure is reported separately and does not invalidate completed measurements.
Lower values are better for both primary metrics:
| Metric | Used for | Meaning |
|---|---|---|
opportunity |
Discrete actions | Time from the initiating trusted event to two animation-frame callbacks after the expected DOM/focus state is observed. A rendering-opportunity proxy, not proof that pixels were presented. |
frameMean |
table.scroll, table.scroll-deep, variable.scroll, variable.scroll-deep, infinite.scroll, table-measured.resize-drag, table-measured.resize-animation |
Mean animation-frame callback interval within a trial. Measures main-thread callback cadence, not compositor frame delivery. The action's total duration is not the score. |
Base and candidate columns show medians of trial values. Paired change is estimated from each launch's candidate median minus its base median, so it may differ from subtracting the two displayed columns. Negative changes favor the candidate. The interval describes uncertainty in that paired change.
frameMean cannot fall below the display's refresh period, such as 8.33 ms at 120 Hz. When every frame's work fits in that budget, both arms score the refresh period and the comparison is within threshold however much the work changed. It detects only work that delays frames, so it cannot show an improvement in a scenario whose base already scores the refresh period. Compare base values with the refresh period before drawing conclusions; --cpu lifts some scenarios off it, by an amount that depends on the machine.
The practical threshold is the larger of 8 ms or 5% of the base median for opportunity, and 0.25 ms or 5% for frameMean. These are exploratory defaults, not calibrated merge budgets. Override them with --absolute-threshold, --relative-threshold, and --frame-threshold after investigating noise on your machine.
| Verdict | Meaning |
|---|---|
| Improvement detected | The entire interval is below the negative practical threshold. Confirm the result. |
| Regression detected | The entire interval is above the positive practical threshold. Confirm and investigate. |
| Within practical threshold | The entire interval lies inside the threshold bounds. |
| Inconclusive | The interval crosses a threshold, or there are too few launches to form an interval. |
Fewer than six launches always produce inconclusive verdicts, regardless of inner trial count. Comparisons require an even --blocks to balance which arm leads. Multiple scenarios are exploratory comparisons without adjustment for family-wide false positives; use them alongside correctness tests and inspection of the affected interaction.
Start with the report's Changes by browser launch table. A trend can indicate drift, a gap between base-led and candidate-led launches can indicate an order effect, and an isolated extreme launch shows where a result may be sensitive to noise.
Compare a build with itself (A/A) to inspect noise under the same workload and settings:
yarn perf compare --base baseline --candidate baseline --scenario table.scrollFor diagnostic detail, add --trace to a comparison:
yarn perf compare --base baseline --candidate candidate --scenario table.scroll --traceTraces run separately after all measured trials, following the same warmups, and are excluded from comparison scores. Open the JSON files in the Chrome DevTools Performance panel. User Timing measures in each trace show bench:ready (input to semantic readiness) and bench:window (input to rendering opportunity).
The report's Diagnostic traces table divides renderer main-thread time inside bench:window into script, style, layout, paint, GC, and other work. It attributes time to the innermost trace event and reports forced style/layout as a subset of style/layout time. Use it with DOM mutation counts and long-frame attribution to understand why work changed even if the primary score did not.
| Problem | Next step |
|---|---|
| Chromium is missing | Run yarn playwright install chromium using the repository's dependencies. |
| Snapshot name already exists | Choose a new name. Builds are immutable. |
| Harness/dependencies differ, or a bundle was modified | Rebuild both snapshots with the same harness and dependencies, then start a new comparison. |
| Confirmation fingerprints do not match | Use the original snapshots or start a new comparison. Renaming/rebuilding a snapshot does not preserve its identity. |
| A scenario fails or times out | Inspect the saved error and screenshot, then reproduce with yarn perf smoke --scenario <id> --headed. |
| Results remain inconclusive | Check workload coverage, launch-level drift, and A/A noise before changing sample counts or thresholds. |
After changing harness code, run:
yarn test:perf
yarn check-types --project perf/tsconfig.jsonFor fixture or driver changes, also run yarn perf smoke --scenario <filter>. For changes shared across scenarios, such as the runner or measurement code, run the full yarn perf smoke. Unit tests cover reports, statistics, confirmation fingerprints, driver specifications, layout validation, and trace summaries; smoke exercises actual browser actions and completion checks.
- Reuse a fixture or add one under
app/, registering new fixtures inapp/app.tsx. - Add the action's ID, fixture, label, and optional primary metric in
scenarios.mjs. Add a defaultsizesentry for a new fixture. - Add its action under the fixture in
drivers.mjs. Usepreparefor unmeasured prerequisites,specfor serializable input and completion conditions,verifyfor post-measurement assertions, andcleanupto undo state that outlives the fixture, such as an active drag. The runner sends the input described byspec; useinputonly for custom sequences. - Run the unit tests, type-check, and
yarn perf smoke --scenario <id>.
Completion conditions should describe the actual requested result, including focus or layout settling when relevant. Prepare deterministic data outside the measurement window, while keeping the application work caused by the action inside it. Rebuild both snapshots before comparing with a changed fixture or driver.
Use these when changing measurement behavior or investigating whether the harness detects effects reliably on your machine:
yarn perf calibrate --build baseline
yarn perf validate --build baseline --runs 20 --scenario listbox,table.scrollcalibrate runs paired experiments that each inject known work into the candidate:
| Experiment | Scenarios | Injected work |
|---|---|---|
| A/A | All of the scenarios below | None |
sync, deferred, layout |
listbox.mount |
40 ms of synchronous work, a deferred update, or forced-layout work |
input |
listbox.focus, select.hover |
16 ms of synchronous work while dispatching the measured keydown or pointerover |
frame |
table.scroll, variable.scroll |
4 ms of synchronous work in every frame of the measurement window |
Check whether the injected effects are detected. An undetected frame effect means the scenario's frames still fit within the refresh period. Calibration does not automatically set thresholds or establish machine stability.
validate repeats A/A comparisons with different seeds and checks how often intervals exclude zero against their nominal error rate. Scenarios share browser launches, so its probability calculation is approximate. Each repeat costs a full comparison; a 20-run validation can take overnight. Its summary is saved under perf/.local/results/validation-*/summary.md.
Each block launches a new Chromium process. Each scenario gets a fresh context and page per arm, with both pages kept open while trials alternate in ABBA order (AB, BA, AB, …). Which arm leads alternates across blocks. A seed controls scenario shuffles, leading-arm order, and short gaps between launches.
Before every trial, the fixture is unmounted and recreated in the same document. Fonts, setup, and prerequisite interactions settle outside the timing window. Warmups exercise the same action and are discarded. These are warm browser/module-cache measurements on fresh component instances; datasets and fixture modules are already loaded. They do not represent a component's first-ever execution.
The independent observations are browser launches. For each launch, the candidate's median trial is compared with the base's median trial. The reported change is the Hodges–Lehmann estimate (median of pairwise averages) of those launch-level changes. Its Wilcoxon signed-rank interval uses the smallest achievable confidence at or above 95%: 96.1% with the default eight blocks. There is no p95 interaction-latency verdict.
Timing begins at the trusted browser event timestamp, excluding Playwright's locator/actionability polling. Assertions and result collection happen after the measurement window. Measured trials use neither traces nor React profiling builds.
| Measurement | Interpretation |
|---|---|
Semantic readiness (ready) |
First observation of the expected DOM/focus state, through mutation/focus observation or the frame loop. Layout-dependent conditions run in the frame loop rather than mutation callbacks. This is not React render duration or paint time. |
| Event Timing | Browser-reported event durations, input delays, processing times, and interaction IDs. Entries are grouped by interaction ID for observed maxima. Durations are quantized and short entries may be absent; missing entries are not zeros. These scripted interactions do not measure field INP. |
| Long animation frames (LoAF) | Frames of at least 50 ms overlapping the window, with blocking duration, script, style/layout, and forced style/layout attribution. Scripts are listed only when they take at least 5 ms. A trial with no long frame has no attribution, not zero cost. Raw entries are retained; sources are minified production code. |
| rAF interval p95 and maximum | Supporting per-trial frame-interval statistics. The report shows the median p95 and worst interval. Intervals cluster around whole frame periods, making the tail percentile less stable than the mean for comparisons. |
| Missed frames | Estimated from intervals spanning multiple periods, using the trial's median interval as the period. This assumes fewer than half the callbacks are late and does not count compositor-dropped frames. |
| DOM mutations | Mutation records and nodes added/removed from input to rendering opportunity. Helps identify changed DOM work even when timing is noisy; does not capture re-renders that leave the DOM unchanged. |
Long-frame summary columns are medians over trials with entries, summed across each trial's overlapping long frames, except for the longest-frame maximum. The report also shows coverage so missing observations remain visible.
- Fixed-height scrolling: Chromium synthesizes a trusted mouse-wheel stream at its own cadence for 4,000 px at 2,000 px/s. Completion requires reaching the distance and exposing the expected row. Frame intervals must span at least 90% of the two-second gesture, however many frames were missed. The table must render a virtualized subset; updating one record retains the other record objects.
- Incremental loading: The table starts with 50 rows and appends the next prepared 50-row page synchronously when
TableLoadMoreItemcomes within one viewport of view. Pages stay 50 rows as--scalegrows. The scroll matches fixed-height scrolling; assertions require exactly 150 loaded rows and correct labels. - Variable-height lists:
ListLayoutobserves actual heights from wrapping descriptions. Readiness requires three consecutive frames with stable visible geometry: rows fill the viewport in order without gaps or overlap, fit their wrappers and width, and have different heights. DOM ordering and offscreen recycling do not affect the comparison. Resize preserves the first record at the top. Geometry checks and settling are included in the score; the layout is checked again when collecting results. - Resizable tables: Data and the table element remain stable as the container narrows, exercising ResizeObserver and column layout updates. Completion waits for width styles; assertions verify all six actual column widths and retained rows.
- Sorting/filtering tables: Actions operate on an already-mounted table and retain record identities. Completion checks the full expected row-ID sequence and sort direction; assertions check labels. Clearing measures restoration of all rows. Application filter/sort work is included.
- Table focus: ArrowDown moves from the first row's first data cell to the same column in the second row. Selection is disabled. Completion requires actual DOM focus on the destination and no selection; assertions verify all row identities, labels, and ordering.
- Column resize drag: The pointer presses the labels column's
ColumnResizerand moves 200 px left in 100 steps paced 10 ms apart by the runner, then releases. Controlled widths update throughonResize. Completion requires a 100 px labels column and the same settled layout astable-measured.narrow. Frame samples must span at least one second. Unlike wheel scrolling, the pointer stream is paced by Node timers. - Continuous table resizing: A three-second CSS width animation drives five narrow/wide legs without a parent React update on every frame. The score includes the animation and final layout settling. Frame samples must span at least 2.8 seconds, so an abbreviated run cannot pass as a fast result.
- DnD modality: The text field owns its state separately from the draggable cards, so typing does not re-render their parent. Preparation verifies pointer-mode drag instructions for
dnd.modality-type;dnd.typefirst uses ArrowLeft to establish keyboard mode, so it is a baseline for the same keystroke without the modality change. Both measure typing onea, require the updated field and retained focus, and verify every card's identity, label, draggable state, and keyboard instructions afterward. Locale is fixed toen-USby the runner. - Large selections: Select-all presses Ctrl/Cmd+A with focus on the first row. Range selection starts with the first row selected and scrolled to row 4,000, then shift-clicks it. Completion checks the selection size the table reports through
onSelectionChangeand the visible rows' selection state. - Keyboard reordering: Enter on the first item's drag button starts a drag; completion requires a drop indicator for every gap and focus on the first one. Moving presses ArrowDown to the next gap. Dropping presses Enter there and requires the new order, removed indicators, and focus on the moved item. Assertions check every label in order. Trials that leave a drag active cancel it with Escape afterward, because drag state is global and survives the fixture remount. Pointer drags are not covered: Playwright emulates native HTML drag events, so their timing would describe the emulation.
- Table controls: Selection waits for both row and checkbox state. Opening menus/dialogs waits for focus inside; closing waits for removal and focus restoration to the originating button. The table remains mounted behind overlays.
- Popup navigation: ComboBox and Select start open with the first option focused and measure one move to the second. Completion checks focus styling plus ComboBox
aria-activedescendantor Select DOM focus. Pointer timing begins atpointerover; coordinates are resolved beforehand. Hover does not generate an Event Timing interaction and does not measure a continuous pointer sweep. - ComboBox filtering: Each action measures one keystroke. Filtering types
9into an empty query; narrowing types a second9after the first filter has settled. Completion checks the query and the exact option count. Application filter work is included. - Select choice: Enter on the focused first option sets the controlled value, removes every option, and returns focus to the trigger. Assertions check the trigger's label.
- Virtualized popups: Fixtures must render fewer options than they hold. Opening waits for the first option, and for Select, focus on the listbox. Filtering requires the query, no
Record 0, and the first matchesRecord 9andRecord 19; it cannot check the full count. Keyboard navigation uses the same checks as the nonvirtualized ComboBox, without the option count. - Collection mutations: Append preserves objects and selection. Delete starts with five selected records and focus on the fifth, then requires their removal, cleared selection, and focus on the next survivor. Replace uses new keys, clears selection, and keeps focus on the switch button. Complete item order and labels are checked. Payloads are prepared before mounting.
- Trees: ArrowRight expands the first project, ArrowDown enters its first file, and ArrowLeft collapses it. Completion checks all exposed rows in order, expansion state, and focus; assertions check labels and hierarchy levels. All data is available and the tree is nonvirtualized.
Manifests record the git SHA, dirty state, tracked diff hash, dependency lock hash, actual React/React DOM/Parcel/Playwright versions, harness hash, and bundle hash. The bundle graph must contain production React DOM and React Aria/RAC/Stately source from this checkout; runtime checks also verify production mode and React version.
The harness and dependency fingerprints must agree across a comparison, and bundles must still match their manifests. A dirty snapshot cannot be reproduced from its git SHA alone, especially when it includes untracked source files.
Keep snapshots needed for confirmation. Remove obsolete directories under perf/.local/builds/ and perf/.local/results/ when they are no longer useful; these artifacts do not need to be committed.