BXUP Track progress & test yourself — free
Frontend & Mobile · Hands-on lab · ~4 min read
FRONTEND & MOBILE · HANDS-ON LAB

Lab 08 · Loop-Closed Web Performance Repair

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.

Time & tier

~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.

Setup

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.

Guided steps

  1. Build and serve production.npm run build && npm run preview. Performance work happens against the production build — dev-mode traces lie. Expected: preview on port 4173.
  2. Record the baseline trace through the agent. Prompt: "Using chrome-devtools-mcp: open http://localhost:4173, emulate Slow 4G and 4x CPU throttling, record a performance trace of a page load, and report the LCP value, the LCP element, layout shifts with their culprits, and the largest network transfers." Expected: the agent returns concrete numbers and names 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.
  3. Trace the interaction too. "In the same session, type 'pro' into the search box one character at a time and record a trace. Report the longest interaction duration, and where the time went (script vs render)." Expected: a long task in the hundreds of milliseconds attributed to the input handler. Record it. You now have the full Before row — and a ranked list of suspects.
  4. Triage before touching code. Ask the agent to rank the three biggest problems by expected user impact, then write your own ranking in 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.
  5. Fix 1 — the LCP resource. The hero ships as a multi-megabyte original JPEG, unsized. Fix: resize/compress to display width (e.g., 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.
  6. Fix 2 — the bundle. Ask the agent: "From the trace and the build output (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.
  7. Fix 3 — the INP trap. The scorer is O(name × query) across 8,000 products, synchronously, on every keystroke. Fix with mechanism, not magic: debounce the expensive computation or move it behind 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.
  8. Close the loop in writing. Complete every cell of 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").
Warning

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.

Verify

  • Four columns of perf-log.md complete; every number cites a specific trace (agent-reported or Lighthouse).
  • LCP < 2.5s and INP < 200ms and CLS < 0.1 on the final throttled trace; JS transferred down ≥ 30% from baseline.
  • Each of the three fixes has its own re-trace — no "fixed everything, measured once."
  • At least one agent claim was rejected as trace-unverifiable, and it's logged.
  • The search still returns correct results (type "Product 79" — sensible matches appear) — performance fixes that break behavior don't count.

Self-grading rubric (pass = 7/10)

  • Loop discipline (0–4): every fix bracketed by traces, fixes applied one at a time = 4; batched fixes with a single after-measurement = 1.
  • Diagnosis quality (0–3): all three seeded problems identified from the trace with mechanism (not from reading this lab's code comments) = 3.
  • Thresholds met (0–2): all three Core Web Vitals targets green on the final trace = 2.
  • Skepticism (0–1): at least one unverifiable insight caught and logged = 1.
▸Answer key — the three seeded problems and expected deltasoptional deep dive — click to expand
  • Seed 1 — unoptimized, unsized LCP image.hero.jpg is a ~2400px original served at 960px display width with no width/height, no modern format, no priority hint. It is the LCP element and the CLS culprit (content below reflows when it loads). Expected baseline on Slow 4G/4x CPU: LCP well above 4s, CLS noticeably above 0.1. Fix: resize + WebP/AVIF + explicit dimensions (fetchpriority="high" is a legitimate bonus). Expected delta: LCP improves by seconds; CLS to ≈ 0. This fix alone usually flips LCP past the 2.5s target on preview-server localhost even throttled.
  • Seed 2 — unsplit, oversized bundle.moment (plus locales) and full lodash are imported at top level for one date format and one orderBy — together typically 100–150 KB gzipped of the initial chunk, all parse/compile cost on a throttled CPU. The build output and the trace's script-evaluation time expose it. Fix: Intl.DateTimeFormat and a native sort (or per-method imports). Expected delta: ≥ 50% JS-transfer reduction in this toy app (≥ 30% is the pass bar), with visible script-time improvement in the after-trace. Route-level code-splitting is the real-world generalization; here dependency elimination is the honest fix.
  • Seed 3 — synchronous quadratic work on every keystroke.score() is O(len × len) per product × 8,000 products × every character typed, inside the controlled-input render path. The interaction trace shows one long task per keystroke, hundreds of ms, attributed to the change handler — this is a textbook INP failure and it also makes typing feel broken. Fix: useDeferredValue (keeps the input responsive, defers the list) or debounce + a linear scorer. Expected delta: longest interaction from 300–800ms down to < 100ms on most machines even throttled. Note for grading: memoizing results with useMemo alone does not fix INP here (the query changes every keystroke, so the memo always misses) — if you applied only useMemo and INP didn't move, the correct response was to believe the trace, and the answer key awards diagnosis credit only for identifying the synchronous-work-per-keystroke mechanism.

Common false finding: blaming React re-renders of the 20-item list. The trace attributes the time to scoring 8,000 items, not to DOM work on 20 — check where the self-time actually sits in the flame chart before accepting that story.

Key Takeaway:

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.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in