Modernizing Web Performance
Run a baseline audit before touching any code:
Bash# Capture current performance baseline npx unlighthouse --site https://example.com --reporter json > baseline.json npx bundlesize npx source-map-explorer 'dist/**/*.js'
Never modernize blind. Baseline metrics (LCP, INP, CLS, TTFB, bundle size, TTI) are the contract you must not break.
Progress:
- [ ] Step 1: Establish baseline (Lighthouse/CrUX, bundle analysis, server timing)
- [ ] Step 2: Map architecture (framework version, rendering strategy, data fetching, hosting/CDN)
- [ ] Step 3: Identify bottlenecks (render-blocking JS, hydration cost, waterfalls, cache misses)
- [ ] Step 4: Prioritize upgrades by impact/risk ratio
- [ ] Step 5: Implement incrementally behind feature flags
- [ ] Step 6: Re-measure against baseline after each change
- [ ] Step 7: Codify performance budgets in CI/CD
- [ ] Step 8: Document rollback path for each change
Step 1 — Baseline: Collect field data (CrUX/RUM) if available, not just lab data. Record per-route metrics, not just homepage. Snapshot bundle composition (vendor vs app code, duplicate deps).
Step 2 — Architecture map: Identify: CSR/SSR/SSG/ISR mix, hydration strategy, data-fetching waterfalls, third-party script load, CDN/edge config, current framework major version and migration path to its 2026 equivalent (e.g., React 19 → RSC, SvelteKit → Svelte 5 runes, Next.js App Router, Astro islands).
Step 3 — Bottleneck triage: Classify issues:
- Network (TTFB, missing edge caching, no HTTP/3)
- Compute (over-hydration, unnecessary client components, blocking main thread)
- Payload (oversized JS/images, no code-splitting, unused CSS)
- Architecture debt (monolithic bundle, no streaming SSR, synchronous data fetching)
Step 4 — Prioritization matrix: Score each fix: (expected metric improvement) ÷ (implementation risk + regression surface). Do cheap/high-impact fixes first (image formats, caching headers, code-splitting) before architectural rewrites (RSC migration, edge function conversion).
Step 5 — Incremental implementation:
- Convert client components to Server Components route-by-route, not app-wide.
- Introduce edge functions for specific high-traffic endpoints first.
- Use feature flags / canary deploys to isolate risk.
- Keep old code path available until new path is verified in production.
Step 6 — Re-measure: Compare against Step 1 baseline on the same routes/devices/network conditions. Reject any change that regresses Core Web Vitals even if it "feels" more modern.
Step 7 — CI/CD performance budgets:
YAML# lighthouse-ci config example assertions: largest-contentful-paint: ["error", {maxNumericValue: 2500}] interaction-to-next-paint: ["error", {maxNumericValue: 200}] cumulative-layout-shift: ["error", {maxNumericValue: 0.1}] total-byte-weight: ["error", {maxNumericValue: 1500000}]
Fail the build on budget violation; block merge, not just warn.
Step 8 — Rollback path: Every architectural change must have a feature flag or deploy-level rollback documented before shipping.
Example 1:
Input: Next.js 13 Pages Router site, LCP 4.2s, fully client-rendered dashboard, no edge caching.
Output: Migration plan — move marketing pages to App Router + RSC first (low risk, high win), add edge caching for static assets via CDN headers, defer dashboard migration to phase 2 behind /dashboard-v2 flag. Projected LCP 4.2s → 1.8s on marketing pages; dashboard untouched until RSC data-fetching patterns validated in staging.
Example 2:
Input: Svelte 4 SPA with 1.2MB main bundle, INP 340ms due to large reactive stores recalculating on every keystroke.
Output: Incrementally port hot-path components to Svelte 5 runes ($state/$derived) to fine-grain reactivity, split bundle via dynamic import() for non-critical routes, add bundlesize CI gate at 400KB. Target INP < 200ms, verified via field RUM before full rollout.
Example 3: Input: Express/EJS server with average TTFB 800ms from a single-region host. Output: Move static/cacheable routes to edge functions (Vercel/Cloudflare Workers), keep stateful logic on origin, add stale-while-revalidate caching. TTFB target <200ms for edge-served routes; origin routes unchanged until load-tested.
- Always measure field data (RUM/CrUX), not just synthetic Lighthouse scores — they can diverge significantly.
- Modernize per-route or per-component, never a full-stack rewrite in one PR.
- Treat hydration as a cost center: default to server/static rendering, promote to client components only when interactivity requires it.
- Performance budgets belong in CI, enforced as build-blocking checks, not dashboards nobody reads.
- Prefer streaming SSR and partial hydration over full rewrites when the existing framework supports it.
- Document the "why" behind every architectural change — future maintainers need to know the tradeoffs, not just the result.
- Validate on real devices/networks (mid-tier Android, throttled 4G), not just developer machines.
- Chasing framework trends (migrating to the newest meta-framework) without profiling data showing it addresses the actual bottleneck.
- Rewriting the whole app at once — any regression becomes impossible to isolate.
- Optimizing lab scores (Lighthouse 100) while field metrics (INP, real-user LCP) stay flat or worsen.
- Adding edge functions/CDN layers without considering cache invalidation and personalization needs — causes stale or leaked data.
- Removing "legacy" code paths before the new path is validated in production under real traffic.
- Ignoring third-party scripts (analytics, ads, chat widgets) which are often the actual biggest bottleneck, not the framework.
- Setting performance budgets as warnings instead of merge-blocking checks — they get ignored within weeks.