DOMAIN:FRONTEND:PERFORMANCE¶
OWNER: nessa (performance audit), floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects
STANDARD: Core Web Vitals (Google), RAIL model
ALSO_CHECK: domains/performance/index.md (SLO definition, regression detection)
CORE_WEB_VITALS¶
THRESHOLDS¶
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | < 200ms | 200ms - 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1 - 0.25 | > 0.25 |
NOTE: INP replaced FID (First Input Delay) in March 2024. INP measures ALL interactions throughout page lifecycle, not just first. 43% of websites fail INP as of 2025.
LCP_OPTIMIZATION¶
WHAT_IS_LCP: time until the largest visible element renders (hero image, main heading, large text block).
CHECKLIST:
- [ ] hero image uses <Image priority /> for preloading
- [ ] critical CSS is inlined (Next.js does this automatically)
- [ ] fonts preloaded with next/font (no FOIT/FOUT)
- [ ] server response time (TTFB) < 800ms
- [ ] no render-blocking JavaScript above the fold
- [ ] no redirects on the critical path
// GOOD: priority flag on hero image
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.webp"
alt="Product showcase"
width={1200}
height={600}
priority // Preloads this image, no lazy loading
className="w-full h-auto"
/>
);
}
ANTI_PATTERN: lazy loading the LCP image. priority disables lazy loading and preloads.
ANTI_PATTERN: using <img> instead of next/image — misses optimization pipeline.
ANTI_PATTERN: large unoptimized images (>200KB for hero images).
INP_OPTIMIZATION¶
WHAT_IS_INP: time from user interaction (click, tap, keypress) to next visual update. Measures ALL interactions, reports worst at 75th percentile.
CHECKLIST:
- [ ] event handlers execute in < 50ms
- [ ] no synchronous heavy computation in click handlers
- [ ] long tasks broken up with requestIdleCallback or scheduler.yield()
- [ ] useTransition for non-urgent state updates
- [ ] minimize client-side JavaScript (Server Components help)
- [ ] avoid layout thrashing (reading then writing DOM in loops)
"use client";
import { useTransition } from "react";
export function SearchResults({ query }: { query: string }) {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState<Product[]>([]);
function handleSearch(value: string) {
// Mark filtering as non-urgent — input stays responsive
startTransition(() => {
const filtered = allProducts.filter((p) =>
p.name.toLowerCase().includes(value.toLowerCase()),
);
setResults(filtered);
});
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <p className="text-muted-foreground">Filtering...</p>}
<ProductGrid products={results} />
</>
);
}
ANTI_PATTERN: synchronous filtering of 10,000+ items in onChange handler.
FIX: use useTransition (React marks it as non-urgent, keeps input responsive).
FIX: for very large lists, use Web Worker or server-side filtering.
CLS_OPTIMIZATION¶
WHAT_IS_CLS: cumulative score of all unexpected layout shifts during page lifecycle. Elements moving after being visible = bad UX.
CHECKLIST:
- [ ] all images have explicit width/height (or aspect-ratio)
- [ ] no content injected above existing content (ads, banners, toasts)
- [ ] fonts use font-display: swap with size-adjust fallback (next/font does this)
- [ ] Suspense fallbacks match real content dimensions
- [ ] no dynamically loaded content that pushes other content down
- [ ] animations use transform not width/height/margin/top/left
// GOOD: image with explicit dimensions prevents CLS
<Image src="/product.webp" alt="Product" width={400} height={300} />
// GOOD: aspect ratio for responsive images
<div className="aspect-video relative">
<Image src="/video-thumb.webp" alt="Video" fill className="object-cover" />
</div>
// BAD: no dimensions — browser doesn't know size until image loads
<img src="/product.webp" alt="Product" />
ANTI_PATTERN: injecting a cookie banner that pushes page content down.
FIX: reserve space for the banner or use an overlay that does not shift content.
IMAGE_OPTIMIZATION¶
NEXT_IMAGE¶
RULE: always use next/image for all images. Never use <img> in production code.
FEATURES:
- automatic format conversion (WebP/AVIF based on browser support)
- automatic responsive srcset generation
- lazy loading by default (except priority images)
- prevents CLS with required width/height or fill prop
- blur placeholder support
// Standard image with known dimensions
<Image src="/product.webp" alt="Product name" width={400} height={300} />
// Fill container (responsive)
<div className="relative h-64 w-full">
<Image src="/banner.webp" alt="Banner" fill className="object-cover" sizes="100vw" />
</div>
// Responsive with sizes hint for optimal srcset
<Image
src="/product.webp"
alt="Product"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
RULE: always provide sizes prop when using fill or responsive images. Without it, Next.js generates srcset but browser downloads largest size.
RULE: use priority ONLY on the LCP image (typically 1 per page). Do not priority everything.
IMAGE_FORMATS¶
PREFER: WebP for photos (30% smaller than JPEG). AVIF for even better compression (50% smaller) but slower encoding.
NEXT_IMAGE: converts automatically. No manual format work needed.
ICONS: use SVG or Lucide React icons. Never use PNG/JPG for icons.
FONT_OPTIMIZATION¶
NEXT_FONT¶
RULE: always use next/font for font loading. Never use <link> to Google Fonts or self-host manually.
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
BENEFIT: fonts self-hosted at build time (no external requests). Automatic font-display: swap. Size-adjust fallback prevents CLS. Zero FOIT (Flash of Invisible Text).
BUNDLE_ANALYSIS¶
ANALYZE_BUNDLE¶
# Enable bundle analyzer
ANALYZE=true next build
# Or install @next/bundle-analyzer
npm install @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {};
export default process.env.ANALYZE === "true"
? (await import("@next/bundle-analyzer")).default({ enabled: true })(config)
: config;
BUNDLE_SIZE_BUDGETS¶
| Page Type | JS Budget | CSS Budget |
|---|---|---|
| Landing page | < 100KB gzipped | < 30KB |
| Dashboard page | < 200KB gzipped | < 50KB |
| Form/settings page | < 150KB gzipped | < 40KB |
CHECK: next build output shows per-route bundle sizes. Review after every PR.
ALERT: if any page exceeds budget, investigate before merging.
CODE_SPLITTING¶
AUTOMATIC¶
Next.js automatically code-splits by route. Each page only loads its own JS.
DYNAMIC_IMPORTS¶
USE_WHEN: heavy component not needed on initial load (chart library, rich text editor, map).
import dynamic from "next/dynamic";
// Loaded only when rendered
const HeavyChart = dynamic(() => import("@/components/charts/revenue-chart"), {
loading: () => <div className="h-64 animate-pulse rounded-lg bg-muted" />,
});
// Client-only component (no SSR)
const MapView = dynamic(() => import("@/components/map-view"), {
ssr: false,
loading: () => <div className="h-96 animate-pulse rounded-lg bg-muted" />,
});
LAZY_LOADING_LIBRARIES¶
"use client";
export function ExportButton() {
async function handleExport() {
// Library loaded only when button clicked
const { saveAs } = await import("file-saver");
const { utils, writeFile } = await import("xlsx");
const ws = utils.json_to_sheet(data);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "Export");
const buffer = writeFile(wb, { type: "buffer" });
saveAs(new Blob([buffer]), "export.xlsx");
}
return <button onClick={handleExport}>Export to Excel</button>;
}
BENEFIT: xlsx (500KB+) only loaded when user clicks export. Not in initial bundle.
PREFETCHING¶
LINK_PREFETCH¶
Next.js <Link> automatically prefetches routes when they appear in the viewport.
import Link from "next/link";
// Prefetched automatically when visible in viewport
<Link href="/products">Products</Link>
// Disable prefetch for rarely-used links
<Link href="/admin/audit-log" prefetch={false}>Audit Log</Link>
ROUTER_PREFETCH¶
"use client";
import { useRouter } from "next/navigation";
export function SmartButton({ href }: { href: string }) {
const router = useRouter();
return (
<button
onMouseEnter={() => router.prefetch(href)} // Prefetch on hover
onClick={() => router.push(href)}
>
Go
</button>
);
}
LIGHTHOUSE_CI¶
SETUP¶
# .lighthouserc.yaml
ci:
collect:
numberOfRuns: 3
url:
- http://localhost:3000/
- http://localhost:3000/dashboard
- http://localhost:3000/products
assert:
assertions:
categories:performance:
- error
- minScore: 0.9
categories:accessibility:
- error
- minScore: 0.95
categories:best-practices:
- error
- minScore: 0.9
interactive:
- warn
- maxNumericValue: 3500
cumulative-layout-shift:
- error
- maxNumericValue: 0.1
largest-contentful-paint:
- error
- maxNumericValue: 2500
PERFORMANCE_BUDGETS_PER_PAGE_TYPE¶
| Page Type | Performance Score | LCP | INP | CLS |
|---|---|---|---|---|
| Landing/marketing | >= 95 | < 1.8s | < 100ms | < 0.05 |
| Dashboard (authenticated) | >= 85 | < 2.5s | < 200ms | < 0.1 |
| Form/settings | >= 90 | < 2.0s | < 150ms | < 0.05 |
| Data table/list | >= 80 | < 3.0s | < 200ms | < 0.1 |
| Rich media (maps, charts) | >= 75 | < 3.5s | < 250ms | < 0.1 |
MEASUREMENT_TOOLS¶
SYNTHETIC (lab data):
- Lighthouse (Chrome DevTools, CI)
- WebPageTest (waterfall analysis)
- next build output (bundle sizes per route)
FIELD (real user data):
- Chrome UX Report (CrUX) via PageSpeed Insights
- Vercel Analytics (if deployed on Vercel)
- Custom RUM with web-vitals library
// lib/vitals.ts — Real User Monitoring
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
page: window.location.pathname,
});
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
}
}
export function reportWebVitals() {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
}
QUICK_WINS¶
HIGHEST_IMPACT (do these first):
1. <Image priority /> on LCP element
2. next/font for all fonts
3. Server Components for data-heavy pages (eliminates client JS)
4. Suspense boundaries for independent data fetches (eliminates waterfall)
5. sizes prop on all responsive images
MEDIUM_IMPACT:
6. Dynamic imports for heavy libraries (charts, maps, exporters)
7. Bundle analysis to find unexpected large dependencies
8. useTransition for non-urgent state updates
9. Proper Suspense fallback sizing (prevents CLS)
ONGOING:
10. Lighthouse CI in CI/CD pipeline
11. Real User Monitoring with web-vitals
12. Performance budget enforcement
SELF_CHECK¶
BEFORE_EVERY_DEPLOY:
- [ ] LCP image has priority flag?
- [ ] all images use next/image with width/height or fill?
- [ ] all images have sizes prop for responsive?
- [ ] fonts use next/font?
- [ ] Suspense fallbacks sized correctly (no CLS)?
- [ ] heavy libraries loaded with dynamic import?
- [ ] no unused large dependencies in bundle?
- [ ] Lighthouse score meets page-type budget?
- [ ] event handlers complete in < 50ms?
- [ ] CSS animations use transform/opacity not layout properties?