<!--
Core Web Vitals Performance Audit Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Core Web Vitals Performance Audit Playbook

**Portable spec for running a Core Web Vitals (CWV) lab audit on any new app's marketing surface — pre-launch baseline + 60-day field re-audit.**

Built from the BKE `cwv-2026-05-19` audit. Reference repo: `reference app`.

---

## 0. Use this spec

**Inputs:**
- Production URL (Vercel preview or `vercel --prod` deploy — NEVER dev mode)
- 5 priority URLs to test (landing, one cluster hub, one spoke/long-form, dashboard initial load, one free tool)
- Access to Chrome DevTools MCP (`mcp__chrome-devtools__lighthouse_audit` + `performance_start_trace`)
- Repo write access to drop the audit artifacts under `docs/audits/`

**Outputs:**
- `docs/audits/cwv-YYYY-MM-DD/REPORT.md` — human-readable summary with verdict per URL
- `docs/audits/cwv-YYYY-MM-DD/report.json` — raw Lighthouse JSON per URL
- `docs/audits/cwv-YYYY-MM-DD/report.html` — Lighthouse HTML for archival
- `docs/audits/cwv-YYYY-MM-DD/*.png` — full-page screenshots at mobile/tablet/desktop
- A locked-in performance budget in the repo's CLAUDE.md "Known Issues & Decisions"

---

## 1. The three metrics (Google CWV thresholds)

Targets are **75th percentile of real-user data** — meaning 75% of your visitors must clear them.

| Metric | Threshold | What it measures |
|--------|-----------|------------------|
| **LCP** (Largest Contentful Paint) | < **2.5s** | When the biggest above-fold element finishes rendering |
| **CLS** (Cumulative Layout Shift) | < **0.1** | Total visual instability — content jumping during load |
| **INP** (Interaction to Next Paint) | < **200ms** | Worst-case latency from any user input to next paint |

INP replaced FID in March 2024. Lab tools (Lighthouse, DevTools) cannot measure INP without scripted interactions — it surfaces only via field data (CrUX / RUM).

---

## 2. Lab vs field

- **Lab data** = synthetic device profile (Lighthouse, Chrome DevTools MCP, PageSpeed Insights "lab" tab). Reproducible, controllable, runs at launch with zero traffic. Doesn't measure INP.
- **Field data** = real users in the wild (Chrome User Experience Report = CrUX, or your own RUM beacon). Surfaces INP, captures real network conditions, surfaces regressions invisible to lab.

**At launch you only have lab data.** CrUX needs ~28 days of meaningful traffic before it populates. Plan a 60-90 day re-audit once field data is available.

---

## 3. URLs to test on each launch

Cover the five archetypes — every marketing surface lives in one of these buckets:

1. **Landing page** (homepage / primary marketing entry)
2. **One cluster hub** (category/index page — the highest-DOM-density type)
3. **One spoke / long-form post** (blog post, research page, case study)
4. **Dashboard initial load** if the app has one (auth'd shell render is its own beast)
5. **One free tool page** (calculator, generator, lookup — interactive, JS-heavy)

If the site doesn't have one of these, skip it. Don't pad the audit with redundant URLs.

---

## 4. Device profiles — mobile + desktop both

Run every URL at all three:

| Profile | Viewport | Why |
|---------|----------|-----|
| **Mobile** | 375×667, DPR 2, touch, mobile UA | Hard test. 70% of marketing traffic in 2026. Google ranks mobile-first. |
| **Tablet** | 768×1024 | Visual regression only — Lighthouse doesn't score tablet separately, but layout breaks here. |
| **Desktop** | 1440×900 | Easy mode — passes here mean nothing if mobile fails. |

Mobile is the audit. Desktop is the sanity check.

---

## 5. Running the audit — Chrome DevTools MCP

The tool is `mcp__chrome-devtools__lighthouse_audit`. For each URL × device profile combo:

```
1. mcp__chrome-devtools__new_page → URL
2. mcp__chrome-devtools__emulate (device profile: mobile / desktop)
3. mcp__chrome-devtools__lighthouse_audit (categories: ['performance'])
4. Capture report.html + report.json → save to docs/audits/cwv-YYYY-MM-DD/
5. mcp__chrome-devtools__take_screenshot (fullPage: true) → save PNG
```

For deeper LCP/CLS/INP traces beyond Lighthouse's aggregate score, use `performance_start_trace` → interact → `performance_stop_trace` → `performance_analyze_insight`.

---

## 6. Performance budget (hard limits — set in CLAUDE.md, enforce in PRs)

| Asset | Mobile budget |
|-------|---------------|
| Initial JS bundle (compressed) | **< 150KB** |
| Initial CSS (compressed) | **< 50KB** |
| Total page weight | **< 500KB** |
| LCP element size | < 200KB image, < 4KB if text |
| Third-party scripts | 0 at launch (revisit at re-audit) |

PR review rule: if `npm run build` output shows the route exceeds these, the PR doesn't merge until the regression is justified or fixed.

---

## 7. Common LCP culprits + fixes

| Culprit | Fix |
|---------|-----|
| **Render-blocking CSS** | Inline critical CSS — Next.js: `experimental: { inlineCss: true }` in `next.config.ts`. Saves **1–1.3s mobile LCP** on BKE. |
| **Web fonts FOIT/FOUT** | Preload via `<link rel="preload" as="font" crossorigin>` + `font-display: swap` in `@font-face`. Self-host (no Google Fonts CDN hop). |
| **Hero image** | Use `next/image` with `priority` prop + responsive `sizes` + AVIF/WebP via `formats: ['image/avif','image/webp']` in next.config. |
| **Above-the-fold JS** | Defer non-critical scripts (`<Script strategy="lazyOnload">`). Lazy-load below-fold components via `next/dynamic` with `ssr: false` where appropriate. |
| **Third-party trackers** | First-party analytics only at launch (cross-ref `CUSTOMER-ANALYTICS-PLAYBOOK`). Hotjar/Mixpanel/Intercom each cost 50–300ms of LCP. |

---

## 8. Common CLS culprits + fixes

| Culprit | Fix |
|---------|-----|
| **Late-loading fonts** | Preload + `size-adjust` / `ascent-override` in `@font-face` to match fallback metrics. |
| **Images without dimensions** | Always set `width` + `height` on `<img>`, or `next/image` with explicit `aspectRatio`. Browser reserves space pre-load. |
| **Banner ads / cookie banners** | Reserve space upfront with `min-height` on the container. Never let banners shove content down. |
| **Infinite scroll** | Use `aspect-ratio` CSS placeholders on every list item so newly-loaded items don't shift siblings. |
| **Non-composited animations** | `transform: scaleY()` / `translate3d()` instead of `height`/`top`. BKE homepage hit CLS 0.0015 because of `height` animations on a hero canvas (informational only — 66× under threshold). |

---

## 9. Common INP culprits + fixes

| Culprit | Fix |
|---------|-----|
| **Heavy event handlers on input** | Wrap non-urgent work in `requestIdleCallback` (or `scheduler.postTask` priority='background'). Debounce search/filter handlers. |
| **JS-heavy hydration** | React Server Components for static parts. Code-split client components via `next/dynamic`. Hoist client boundaries as far down the tree as possible. |
| **Large lists** | Virtualize via `react-window` / `react-virtual`. Callback-ref pattern for IntersectionObserver sentinels (BKE lab note 2026-05-06 — `useRef + useEffect` races mount order, callback ref doesn't). |
| **Long tasks** | Break into chunks with `await new Promise(r => setTimeout(r, 0))` between heavy iterations so the browser can service input. |

---

## 10. Performance-impacting Next.js settings (lock in at scaffold time)

```ts
// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    inlineCss: true,           // critical CSS inlined — saves 1-1.3s mobile LCP
  },
  images: {
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 31536000, // 1 year — let the CDN cache hard
  },
  compress: true,              // gzip on by default; confirm not disabled
};
```

**Bundler decision:** webpack, NOT Turbopack. Turbopack has a root-inference memory pile-up bug. Use `--webpack` flag on `next dev`. This is a locked-in tooling decision — don't re-litigate.

---

## 11. Audit artifact storage

Every audit lands in a dated folder so you have a regression trail:

```
docs/audits/cwv-YYYY-MM-DD/
├── REPORT.md          # Human summary (verdict per URL, trace insights, recommendation)
├── report.json        # Raw Lighthouse JSON (one per URL × device — name accordingly)
├── report.html        # Lighthouse HTML report (archival)
├── mobile-<url>.png   # 375×667 full-page screenshot per URL
├── tablet-<url>.png   # 768×1024 full-page screenshot per URL
└── desktop-<url>.png  # 1440×900 full-page screenshot per URL
```

Reference: `docs/audits/cwv-2026-05-19/REPORT.md` and `report.json`.

---

## 12. REPORT.md structure

Match this shape — it's the format that survived BKE's audit and reviews well:

1. **Header** — date, scope (N URLs × M device profiles), method, verdict (one line).
2. **CWV results table** — one table per device profile, columns: URL · LCP · CLS · INP · Verdict (✅/⚠️/❌).
3. **Trace insights** — non-actionable findings worth noting (DOM size, render-blocking, CLS sub-causes).
4. **Visual regression findings** — layout per breakpoint, anything that wraps/clips/scrolls.
5. **What this audit does NOT cover** — CrUX field gap, INP gap, anything scoped out.
6. **Recommendation** — what to do next (fix list, or "skip further CWV work, focus on X").
7. **Screenshots** — bullet list of saved PNGs.

---

## 13. 60–90 day re-audit (field data)

Once the site has 28+ days of real traffic that CrUX captures, run a second audit via **PageSpeed Insights** (not Lighthouse). PSI surfaces:

- **Field LCP / CLS / INP** from CrUX (real users, 28-day window, 75th percentile)
- **Real INP** — the metric lab tools cannot measure
- **Origin-level data** when individual URLs don't have enough traffic

If field data diverges from lab (e.g., lab LCP 0.3s but field LCP 3.8s), the gap is real-world network/device variance — that's the data that matters for SEO ranking signals.

Re-audit cadence: once at 60–90 days post-launch, then quarterly, then on any major framework upgrade.

---

## 14. The workspace-loading anti-pattern (BKE-specific, generalizes)

Cross-ref BKE memory: `bke_workspace_loading_perf_followup.md`. BKE's dashboard layout blocks on `hydrateWorkspaces → hydrateWorkspaceSettings + hydrateBilling` before rendering any children, producing a 4–5s "Loading workspace…" screen.

**Generalized rule for any auth'd dashboard:**
- Don't gate first paint on N hydration calls. Render the shell + skeleton states immediately, hydrate per-pane in parallel below.
- Single `supabase.auth.getUser()` at the top — never parallelize auth-validating calls at boot (gotrue Web Lock contention; BKE lab note 2026-05-06).
- Defer non-critical hydration (billing, settings) behind interaction or below-fold mount.

---

## 15. Common pitfalls

**❌ Don't trust dev-mode performance.** Always test production build: `npm run build && npm run start`, or against a Vercel preview URL. Dev mode is 5–10× slower and the numbers are meaningless.

**❌ Don't optimize lab metrics blindly.** Lab is a leading indicator. Field data is what Google ranks. Chasing a 100/100 Lighthouse score when field LCP is bad is wasted engineering time.

**❌ Don't skip mobile testing.** 70% of marketing traffic is mobile in 2026. A site that aces desktop and tanks mobile fails the audit.

**❌ Don't add third-party trackers without measuring impact.** Hotjar / Mixpanel / Intercom / Drift each cost 50–300ms of LCP and can wreck INP. Measure before/after every script added.

**❌ Don't skip the report archive.** Without a dated history under `docs/audits/`, you can't tell whether the latest deploy regressed. The archive IS the regression detector.

**❌ Don't chase 100/100 Lighthouse score.** Diminishing returns past 90. Engineering time better spent on content, backlinks, and conversion than squeezing 92 → 98.

**❌ Don't conflate CLS in lab with CLS in field.** Lab CLS only measures load-phase shifts. Field CLS includes session-long shifts (banner late-loads, lazy-loaded images below fold). They're different numbers.

---

## 16. Kickoff prompt

> Execute the Core Web Vitals Performance Audit Playbook at `CWV-PERFORMANCE-AUDIT-PLAYBOOK.md` for this app. Reference the BKE audit at `docs/audits/cwv-2026-05-19/REPORT.md` for format.
>
> Inputs:
> - Production URL: [provided]
> - 5 priority URLs: [landing, cluster hub, spoke, dashboard, free tool]
> - Audit date: today (YYYY-MM-DD)
>
> Run Lighthouse via Chrome DevTools MCP on each URL × {mobile, desktop}. Capture report.json + report.html + full-page screenshots at all three device profiles. Drop everything under `docs/audits/cwv-YYYY-MM-DD/`. Write REPORT.md matching §12 structure.
>
> Pause after the first URL's full set lands for review of the format, then continue.

---

**Reference files in BKE:**
- `docs/audits/cwv-2026-05-19/REPORT.md` — full audit reference (5 URLs × mobile/tablet/desktop)
- `docs/audits/cwv-2026-05-19/report.json` — raw Lighthouse JSON
- `next.config.ts` — `experimental.inlineCss: true` and image-format config
- BKE memory: `bke_workspace_loading_perf_followup.md` — dashboard hydration anti-pattern
- BKE memory: `no_turbopack.md` — webpack-only rule
