Performance testing

Two tools, for two jobs: a script that measures the same scenarios every time (verifiable, comparable between changes), and the Chrome DevTools MCP for Claude to investigate why something is slow (traces, network waterfalls, Core Web Vitals) and suggest fixes.

Always measure a production build. The dev server serves unbundled modules and React's development build, which can be several times slower.

Ports

PortWhatWho
3100Dev server (bun run dev)Any session may restart it
3200Stable copy shared through ngrok, its own worktreeNever use
3300Production build for performance runs (bun run perf:serve)Performance runs

The loop

  1. bun run perf:serve: build and serve on :3300. Rebuild after every change you want to measure.
  2. bun scripts/perf-image-grid.ts: runs every scenario three times and prints a table of medians; the raw numbers go to perf/results/image-grid-<date>.json. Compare with the previous file.
  3. When a number is worse or odd, ask Claude to investigate with the DevTools MCP: open the page on :3300, record a performance trace while doing the same thing (load, scroll, switch layout), and read the insights (LCP breakdown, long animation frames, layout shifts, render-blocking requests).
  4. Fix, rebuild, run the script again, and record the new numbers in the experiment's docs (for the image grid: src/routes/features/image-grid/-docs/performance.md).

The script

scripts/perf-image-grid.ts drives headless Chrome (playwright-core, the installed Google Chrome) with fixed conditions: 1440 × 900 at DPR 2, CPU slowed 4×, cache disabled, a new browser context per run. Per scenario (engine × result size) it measures:

MetricHow
LCP, CLSPerformanceObserver, during load
TBTLong tasks over 50 ms, during load
INPEvent Timing: slowest interaction (layout switch and back; arrow keys where there is keyboard support)
Scroll smoothnessFrame gaps from requestAnimationFrame (frames over 33 ms, worst frame) and Long Animation Frames (count, blocking time) over 40 wheel steps of 400 px
CostDOM nodes, images and bytes from the preview server on load, tiles in the DOM after scrolling, duplicate tiles

Headless Chrome is used on purpose: a background tab in a normal Chrome pauses IntersectionObserver and animation frames, which stops load-more and timing.

Images come from NTB's preview server over the internet, so image timings vary with the network. Compare counts and bytes first, and times as medians.

Chrome DevTools MCP

Configured for this repo in .mcp.json: chrome-devtools-mcp 1.10.1 (pinned), launching its own Chrome with a temporary profile (--isolated), 1440 × 900, without sending URLs to CrUX and without usage statistics. Claude Code asks once to approve the project's MCP server, and loads it at the start of a session. If its tools don't appear, check /mcp: a rejected approval is remembered in .claude/settings.local.json (disabledMcpjsonServers). Use its emulate tool for CPU slowdown (4×, as the script) before tracing, or a fast machine hides the problems.

Useful requests to Claude:

  • "Record a performance trace of http://localhost:3300/features/image-grid/app?total=2000 while scrolling, and explain the long animation frames."
  • "Show the network waterfall for the first screen of the image grid. What delays LCP?"
  • "Emulate a slow 4G connection and a 4× slower CPU, and compare LCP for both engines."