Skip to content

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

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?