Tutorial: Build a Performance Budget and Profiling Lab
Why this tutorial matters
Performance architecture is not "run Lighthouse at the end." It is a system of budgets, profiling, field telemetry, CI gates, and incident response. This tutorial builds a mid-level Performance Lab around a content-heavy lead page and an authenticated dashboard.
The goal is to practice diagnosing and preventing regressions, not only optimizing one page.
What you will build
| Capability | Purpose |
|---|---|
| Route budgets | Define allowed JS, CSS, image, request, and CWV limits |
| Web Vitals capture | Measure LCP, INP, CLS in the field |
| Synthetic checks | Run repeatable lab tests before release |
| Bundle analysis | Track route-level JavaScript cost |
| Profiling workflow | Diagnose long tasks and render cost |
| CI gate | Block obvious regressions |
| Incident playbook | Respond when production performance regresses |
Core Web Vitals targets from web.dev are the baseline: LCP within 2.5s, INP within 200ms, and CLS no more than 0.1 at the 75th percentile. For critical funnels, set stricter internal budgets.
Milestone 1: Define route budgets
Create performance-budgets.json:
{
"/": {
"lcpMs": 1800,
"inpMs": 150,
"cls": 0.05,
"initialJsKb": 120,
"imageKb": 350,
"requests": 45
},
"/dashboard": {
"lcpMs": 2500,
"inpMs": 200,
"cls": 0.1,
"initialJsKb": 250,
"requests": 80
}
}
Rules:
- budgets are route-specific
- user-centric metrics and quantity metrics are both included
- budgets have owners
- exceptions require expiry date
Acceptance criteria:
- every critical route has a budget
- budget decisions are documented in an ADR
- product/design know the tradeoffs
Milestone 2: Capture Web Vitals
Install field metric capture using web-vitals or your RUM provider.
Event shape:
type WebVitalEvent = {
name: "LCP" | "INP" | "CLS";
value: number;
rating: "good" | "needs-improvement" | "poor";
route: string;
release: string;
deviceClass?: string;
connectionType?: string;
};
Segment by:
- route
- release
- device class
- region
- traffic source
- experiment variant
- logged-in vs anonymous
Acceptance criteria:
- p75 metrics are visible per critical route
- metrics can be compared before/after release
- dashboard excludes obvious bot/test traffic where possible
Milestone 3: Add synthetic checks
Use Lighthouse, WebPageTest, Playwright tracing, or equivalent. Synthetic tests do not replace field data; they catch repeatable regressions before users see them.
Test profiles:
| Profile | Purpose |
|---|---|
| mobile slow CPU | catches hydration/rendering cost |
| slow 4G | catches network and asset weight |
| desktop fast | catches layout and route regressions |
| repeat view | validates cache behavior |
Acceptance criteria:
- synthetic test runs on main route and one authenticated-like route
- results are stored with build id
- regressions produce actionable diff: JS, image, request, main-thread, layout
Milestone 4: Bundle and request budget gate
Add a CI step that checks:
- initial route JS
- async chunk size
- CSS size
- image bytes
- third-party domains
- request count from static analysis or synthetic run
Gate rule:
fail if initial JS increases by >20KB on a critical route
warn if a new third-party domain appears
fail if route budget is exceeded without an exception file
Acceptance criteria:
- a deliberate budget failure blocks CI
- exceptions are reviewed and expire
- dashboard shows trend over time
Milestone 5: Profile INP
Create a deliberately slow interaction, then fix it.
Example slow paths:
- filtering a large table synchronously
- rendering 1000 rows without virtualization
- expensive derived state on every keystroke
- client-side markdown/rendering in the input path
- analytics call blocking a click handler
Workflow:
- Record interaction in Chrome DevTools Performance panel.
- Identify long tasks over 50ms.
- Identify React render cause.
- Move expensive work off input path.
- Add memoization only where profiling proves it helps.
- Re-test INP and user-visible behavior.
Acceptance criteria:
- before/after trace is attached to the ADR or PR
- long task count decreases
- INP improves without breaking behavior
Milestone 6: Optimize LCP and CLS
LCP checklist:
- identify LCP element
- avoid delaying it behind client JS
- use correct image dimensions and priority
- reduce render-blocking CSS
- serve from CDN
- avoid late personalization for hero content
CLS checklist:
- reserve image/media dimensions
- avoid inserting banners above content after load
- keep skeleton dimensions stable
- avoid font swap layout shifts
- avoid generated UI that changes layout without reserved space
Acceptance criteria:
- LCP element is known for each critical route
- CLS sources are documented and fixed
- synthetic and field metrics both improve
Milestone 7: Third-party governance
Inventory scripts:
| Script | Owner | Purpose | Load strategy | Cost | Removal plan |
|---|
Rules:
- no third-party script without owner
- async/defer is not enough if script consumes CPU
- analytics should batch or move server-side where possible
- replay tools should be sampled or delayed
- marketing tags need performance budget impact
Acceptance criteria:
- third-party request count is visible
- one high-cost script is removed, delayed, or moved server-side
- future additions require review
Milestone 8: Performance incident playbook
Define:
- detection threshold
- owner
- rollback path
- communication channel
- regression triage checklist
- postmortem template
Example trigger:
p75 LCP for /quote worsens by 20% for mobile traffic after release.
Triage:
- Compare by release.
- Segment by route/device/region/variant.
- Check bundle delta and new domains.
- Review RUM errors and long tasks.
- Roll back feature flag if user harm is material.
- Add regression guard.
Lab pages to build
Build two intentionally imperfect pages:
| Page | Initial flaws |
|---|---|
| Lead landing page | oversized hero image, late font, third-party tag, layout shift banner |
| Dashboard | large table, expensive filter, heavy chart library, duplicate fetches |
Then optimize them in stages. This teaches diagnosis instead of cargo-cult fixes.
Optimization backlog
| Problem | Fix | Metric affected |
|---|---|---|
| oversized hero | responsive image, dimensions, priority | LCP, CLS |
| late font swap | preload or font-display strategy | CLS, LCP |
| heavy chart on first load | lazy load below fold/tab | JS KB, INP |
| large table render | virtualization or pagination | INP |
| synchronous filtering | debounce, worker, memoized index | INP |
| third-party tag | delay, server-side route, remove | requests, CPU |
| duplicate fetches | route loader/cache key ownership | requests, TTFB |
Every optimization should include before/after evidence.
CI budget file shape
Create one checked-in budget file and one generated report.
{
"route": "/lead",
"owner": "growth-web",
"budgets": {
"initialJsKb": 120,
"totalImageKb": 400,
"thirdPartyRequests": 5,
"lighthousePerformance": 90
},
"exceptions": []
}
Exception example:
{
"metric": "initialJsKb",
"allowedUntil": "2026-07-01",
"reason": "temporary chart migration",
"owner": "analytics-team"
}
Performance review packet
For a serious change, attach:
- route budget diff
- bundle diff
- Web Vitals before/after
- top long tasks
- new third-party domains
- screenshots of LCP element before/after
- rollback plan
This packet makes performance review factual rather than taste-based.
Source lens
This tutorial is informed by web.dev Web Vitals and performance budget guidance, plus the book's chapters on Core Web Vitals, bundle topology, RUM/synthetic correlation, interaction latency, and performance incident response.