The lab app ships slow on purpose: an unsplit bundle, an unoptimized hero image, and an INP trap in the search box. You drive an agent through chrome-devtools-mcp to record a trace, triage its insights, fix, and re-trace — claims without before/after traces fail the lab.
Ines owns web performance at an e-commerce company where "it feels faster" has been accepted as a deploy justification for years. Her rule now: no performance PR merges without a trace showing the metric moved. AI makes this rule easy to follow and easy to fake — an assistant will happily explain why your change should improve LCP without ever measuring anything. This lab closes the loop: the agent records real traces in a real browser via chrome-devtools-mcp, you triage the insights, and the pass condition is measured deltas, not narrative.
~120 minutes hands-on · Verify stage (maps to module M6, playbooks PB-34/PB-37) · $0 — Chrome, chrome-devtools-mcp, Claude Code or any MCP-capable agent, Lighthouse as cross-check.
npm create vite@latest perf-lab -- --template react-ts
cd perf-lab && npm install
npm install moment lodash # yes, both. That's part of the point.
mkdir -p public
# Hero image: grab any ~2000px-wide photo and save it UNRESIZED:
# curl -L -o public/hero.jpg "https://picsum.photos/2400/1200"
src/App.tsx — the seeded storefront. Copy verbatim:
import { useState } from "react";
import moment from "moment";
import _ from "lodash";
const PRODUCTS = Array.from({ length: 8000 }, (_, i) => ({
id: i,
name: `Product ${i} ${Math.random().toString(36).slice(2, 8)}`,
price: Math.round(Math.random() * 900) + 100,
}));
function score(name: string, q: string) {
// naive fuzzy score, deliberately expensive
let s = 0;
for (let i = 0; i < name.length; i++)
for (let j = 0; j < q.length; j++)
if (name[i].toLowerCase() === q[j].toLowerCase()) s += 1;
return s;
}
export default function App() {
const [q, setQ] = useState("");
const results = q
? _.orderBy(PRODUCTS.map((p) => ({ ...p, s: score(p.name, q) })), "s", "desc").slice(0, 20)
: PRODUCTS.slice(0, 20);
return (
<main style={{ maxWidth: 960, margin: "0 auto", fontFamily: "system-ui" }}>
<img src="/hero.jpg" style={{ width: "100%" }} alt="Season sale" />
<h1>Spring sale — ends {moment().add(3, "days").format("dddd, MMMM Do")}</h1>
<input
aria-label="Search products"
placeholder="Search 8,000 products…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<ul>
{results.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</main>
);
}
Wire the MCP server, .mcp.json:
{
"mcpServers": {
"chrome-devtools": { "command": "npx", "args": ["chrome-devtools-mcp@latest"] }
}
}
The evidence ledger, perf-log.md:
| Metric | Before | After fix 1 | After fix 2 | After fix 3 | Target |
|--------|--------|-------------|-------------|-------------|--------|
| LCP | | | | | < 2.5s |
| INP (search keystroke) | | | | | < 200ms|
| CLS | | | | | < 0.1 |
| JS transferred on load | | | | | -30% |
Each cell must cite the trace it came from. No trace, no cell.
npm run build && npm run preview. Performance work happens against the production build — dev-mode traces lie. Expected: preview on port 4173.hero.jpg as the LCP resource. Fill the Before column. Cross-check once with Lighthouse (npx lighthouse http://localhost:4173 --preset=perf) so you trust the instrument.perf-log.md with one sentence of mechanism each. If the agent's insight blames something you can't verify in the trace ("consider memoizing App"), mark it unverified and drop it — insights you can't see in the trace are hypotheses, not findings.npx sharp-cli -i public/hero.jpg -o public/hero-1280.webp resize 1280 or any image tool), serve the WebP, and add explicit width/height attributes (this also addresses the layout shift the baseline trace attributed to the unsized image). Rebuild, re-trace exactly as in step 2, fill the After-fix-1 column. Expected: LCP drops by seconds; CLS toward ~0.npm run build prints chunk sizes; or npx vite-bundle-visualizer), what dominates the JS payload?" Expected suspects: moment (with its full locale data) and lodash imported whole — for one formatted date and one orderBy. Fix: replace moment with Intl.DateTimeFormat (or date-fns), replace _ with a plain sort. Rebuild, re-trace, record JS-transferred delta. Expected: bundle shrinks dramatically; LCP/TBT improve on throttled load.useDeferredValue/startTransition (and fix the accidentally-quadratic scorer while you're there — a Set-based character match or includes check). Re-run the step-3 interaction trace. Expected: longest interaction under 200ms.perf-log.md, each citing its trace. Add a three-sentence summary: which fix bought what, which agent insight was wrong or unverifiable, and the one-line team rule ("perf PRs attach before/after traces").The agent will occasionally propose fixes for problems the trace does not show — memoization where re-renders aren't the bottleneck, preconnect hints for origins you don't use. Every fix in this lab must point at a line in the trace report. Optimizing unmeasured code is how perf work becomes cargo cult.
perf-log.md complete; every number cites a specific trace (agent-reported or Lighthouse).Performance work with AI is only trustworthy when the loop is closed — trace, fix one thing, re-trace — because an agent can narrate a plausible optimization story about any code; the browser's trace is the only party in the conversation that cannot be talked into agreeing.