DevToolBoxGRATIS
Blogg

Web Performance Optimization Guide: Core Web Vitals, Caching, and React/Next.js

15 min readby DevToolBox

Web Performance Optimization Guide: Core Web Vitals, Caching, and Speed Techniques

Master web performance optimization with this comprehensive guide covering Core Web Vitals (LCP, INP, CLS, TTFB), image optimization, JavaScript bundling, caching strategies, font loading, server-side performance, React/Next.js patterns, and Lighthouse scoring — with real code examples and quick wins.

TL;DR — Web Performance in 60 Seconds
  • Core Web Vitals (LCP, INP, CLS) are Google ranking signals — target green thresholds
  • Image optimization (WebP/AVIF + srcset + lazy loading) is the single highest-impact change
  • Code splitting and tree shaking reduce JavaScript payload — aim for <150KB compressed
  • Cache aggressively: immutable for hashed assets, stale-while-revalidate for HTML
  • Use font-display: swap and preload key fonts to prevent invisible text (FOIT)
  • Brotli compression cuts transfer size by ~20% over gzip; enable on every server
  • React Server Components and ISR dramatically reduce client-side JavaScript
  • Measure with web-vitals JS in production — lab scores are a guide, not the goal

Why Web Performance Is a Business Priority

Web performance is no longer a nice-to-have — it is a direct revenue and SEO driver. A 100ms improvement in load time increases Amazon's revenue by 1%. Google reports that as page load time goes from 1s to 3s, the probability of bounce increases 32%. The BBC found that every additional second of load time caused 10% of their users to leave.

Beyond user experience, Google's Page Experience algorithm uses Core Web Vitals as explicit ranking signals. Pages that fail Core Web Vitals thresholds are at a systematic ranking disadvantage, regardless of content quality. This guide covers every layer of the performance stack — from bytes on the wire to React rendering — with measurement techniques, code examples, and prioritized quick wins.

Key Takeaways
  • Measure real-user field data (CrUX, web-vitals.js) before and after optimizations
  • The image optimization + CDN combo typically delivers the largest single performance gain
  • JavaScript bundle size directly controls Time to Interactive — split aggressively
  • HTTP cache headers and service workers together create a multi-layer caching system
  • Lighthouse scores are useful signals but real-user Core Web Vitals determine ranking
  • Performance budgets enforced in CI prevent regression over time

1. Core Web Vitals: Targets, Measurement, and What They Mean

Core Web Vitals are the subset of Web Vitals that Google considers most important for user experience. There are three: Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). Each has three performance bands — Good, Needs Improvement, and Poor.

Core Web Vitals Reference (2025/2026):

Metric   Full Name                    Measures              Good      Needs Work  Poor
------   ---------                    --------              ----      ----------  ----
LCP      Largest Contentful Paint     Loading performance   <2.5s     2.5-4.0s    >4.0s
INP      Interaction to Next Paint    Responsiveness        <200ms    200-500ms   >500ms
CLS      Cumulative Layout Shift      Visual stability      <0.1      0.1-0.25    >0.25

Supporting Metrics (also important):
FCP      First Contentful Paint       First visible render  <1.8s
TTFB     Time to First Byte           Server response       <800ms
TBT      Total Blocking Time          Main thread blocks    <200ms
TTI      Time to Interactive          Fully interactive     <3.8s
SI       Speed Index                  Visual progress       <3.4s

INP replaced FID (First Input Delay) as a Core Web Vital in March 2024.
INP measures all interactions during the page lifetime, not just the first.

Measuring Core Web Vitals in the Field

Field data (real user measurements) is what Google uses for ranking. Install the web-vitals library to collect real-user data from your production site:

// npm install web-vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,         // "LCP", "INP", "CLS", etc.
    value: metric.value,       // The metric value
    rating: metric.rating,     // "good" | "needs-improvement" | "poor"
    delta: metric.delta,       // Change from last reported value
    id: metric.id,             // Unique ID for this page load
    navigationType: metric.navigationType, // "navigate" | "reload" | "back-forward"
  });

  // sendBeacon is non-blocking — ideal for analytics
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", { body, method: "POST", keepalive: true });
  }
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

// PerformanceObserver for custom metrics
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {  // Long tasks (>50ms block the main thread)
      console.warn("Long task detected:", entry.name, entry.duration + "ms");
    }
  }
});
observer.observe({ entryTypes: ["longtask", "resource", "navigation"] });

2. Image Optimization: WebP/AVIF, Lazy Loading, Responsive Images

Images typically account for 50–70% of a page's total weight. A thorough image optimization strategy combines modern formats, responsive sizing, and smart loading priorities. The gains are dramatic — converting a 500KB JPEG to AVIF often produces a 200KB file with identical visual quality.

Modern Image Formats Comparison

FormatSize vs JPEGBrowser SupportBest ForTransparency
JPEGBaseline100%Photos (legacy)No
PNG+10-50%100%Sharp-edge graphicsYes
SVGVector99%Icons, logosYes
WebP-25-35%97%Photos + graphicsYes
AVIF-40-55%96%Best compressionYes

Responsive Images with srcset and picture

<!-- picture element: serve AVIF, fall back to WebP, fall back to JPEG -->
<picture>
  <source
    srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
    sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 1200px"
    type="image/avif"
  />
  <source
    srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
    sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 1200px"
    type="image/webp"
  />
  <img
    src="/hero-1200.jpg"
    srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
    sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 1200px"
    width="1200"
    height="600"
    alt="Hero image description"
    fetchpriority="high"
    decoding="async"
  />
</picture>

<!-- LCP image: preload in <head> -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="(max-width: 640px) 100vw, 1200px"
  fetchpriority="high"
/>

<!-- Below-fold images: lazy load -->
<img
  src="/below-fold.webp"
  loading="lazy"
  decoding="async"
  width="800"
  height="450"
  alt="Below fold image"
/>

Next.js Image Component (Recommended)

import Image from "next/image";

// LCP hero image — use priority prop, not loading="lazy"
<Image
  src="/hero.jpg"
  alt="Hero banner"
  width={1200}
  height={600}
  priority           // Preloads as high-priority
  quality={85}       // Balance quality vs size (default 75)
  placeholder="blur" // Blur-up placeholder
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."  // Tiny inline LQIP
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 1200px"
/>

// Regular below-fold image
<Image
  src="/card.jpg"
  alt="Card image"
  width={400}
  height={300}
  // lazy loading is the default
/>

// next.config.js — allow remote image domains
module.exports = {
  images: {
    formats: ["image/avif", "image/webp"],
    remotePatterns: [
      { protocol: "https", hostname: "cdn.example.com" },
    ],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

3. JavaScript Optimization: Code Splitting, Tree Shaking, Bundle Analysis

JavaScript is the most expensive resource on the web — not just in bytes, but in parse, compile, and execution time. 300KB of compressed JavaScript takes significantly longer to process than 300KB of compressed images. The goal is to minimize the amount of JS that must be parsed before the page becomes interactive.

Code Splitting with Dynamic Imports

// React.lazy for component-level code splitting
import React, { Suspense, lazy } from "react";

const HeavyChart = lazy(() => import("./HeavyChart"));
const AdminPanel = lazy(() => import("./AdminPanel"));
const MapComponent = lazy(() => import("./MapComponent"));

function App({ isAdmin }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyChart />
      {isAdmin && <AdminPanel />}
    </Suspense>
  );
}

// Next.js dynamic imports with additional options
import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("./RichTextEditor"), {
  loading: () => <p>Loading editor...</p>,
  ssr: false,           // Skip server-side rendering
});

const HeavyMap = dynamic(() => import("./HeavyMap"), {
  loading: () => <div style={{ height: 400, background: "#f0f0f0" }} />,
  ssr: false,
});

// Preload a chunk when user hovers
// Useful for improving perceived load time on navigation
const preloadAdmin = () => import("./AdminPanel");

<button
  onMouseEnter={preloadAdmin}
  onClick={() => setShowAdmin(true)}
>
  Open Admin
</button>

Tree Shaking: Import Only What You Use

// BAD: imports entire lodash bundle (~70KB gzipped)
import _ from "lodash";
_.debounce(fn, 300);
_.cloneDeep(obj);

// GOOD: named imports from ES modules — tree-shakeable
import { debounce, cloneDeep } from "lodash-es";

// BETTER: import single functions directly
import debounce from "lodash/debounce";  // ~2KB
import cloneDeep from "lodash/cloneDeep"; // ~5KB

// BEST: use native browser APIs or tiny utilities
const debounce = (fn, delay) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
};

// Analyze your bundle with:
// npx next build && npx next-bundle-analyzer
// Or: npx vite-bundle-visualizer
// Or: npx webpack-bundle-analyzer stats.json

// Enable bundle analysis in Next.js:
// npm install @next/bundle-analyzer
const withBundleAnalyzer = require("@next/bundle-analyzer")({
  enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({});

4. CSS Optimization: Critical CSS, Unused CSS, Containment

CSS is render-blocking by default — the browser cannot render any content until all CSS in the document is downloaded and parsed. Inlining critical CSS eliminates this render-blocking bottleneck for above-the-fold content.

/* Critical CSS technique: inline above-fold styles */
/* In your HTML <head>: */
<style>
  /* Only the styles needed for above-fold content */
  body { margin: 0; font-family: system-ui, sans-serif; }
  header { display: flex; padding: 16px; }
  .hero { min-height: 100svh; display: grid; place-items: center; }
</style>

/* Load non-critical CSS asynchronously */
<link
  rel="stylesheet"
  href="/styles/non-critical.css"
  media="print"
  onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="/styles/non-critical.css" /></noscript>

/* CSS Containment — isolate layout/style changes */
.widget {
  contain: layout style;   /* Optimizes browser rendering pipeline */
}

.infinite-scroll-item {
  contain: strict;         /* layout + style + size + paint */
  content-visibility: auto; /* Skip rendering off-screen items */
  contain-intrinsic-size: 200px; /* Reserve space to avoid CLS */
}

/* Remove unused CSS */
/* Tools: PurgeCSS, UnCSS, Tailwind built-in purge */
/* postcss.config.js */
module.exports = {
  plugins: [
    require("@fullhuman/postcss-purgecss")({
      content: ["./src/**/*.{html,js,ts,tsx}"],
      defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
    }),
    require("cssnano")({ preset: "default" }),
  ],
};

5. Caching Strategies: Browser Cache, CDN, Service Worker, HTTP Headers

Caching is the most impactful server-side performance optimization. A cached response requires zero compute, zero database queries, and minimal bandwidth. A comprehensive caching strategy layers browser cache, CDN cache, and service worker cache.

HTTP Cache-Control Headers Cheat Sheet

# Static assets with content hash in filename (e.g., app.abc123.js)
Cache-Control: public, max-age=31536000, immutable
# Browser and CDN cache for 1 year, never revalidate

# HTML pages — fast delivery with background revalidation
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
# CDN caches for 1h; serve stale for up to 24h while revalidating in background

# API responses — user-specific
Cache-Control: private, max-age=60
# Browser caches 60s; CDN must NOT cache (private)

# Real-time data (prices, stock, scores)
Cache-Control: no-cache, no-store
# Never cache anywhere

# Authenticated pages
Cache-Control: private, no-store
Vary: Cookie, Authorization

# Setting headers in Next.js (next.config.js)
module.exports = {
  async headers() {
    return [
      {
        source: "/_next/static/(.*)",
        headers: [
          { key: "Cache-Control", value: "public, max-age=31536000, immutable" },
        ],
      },
      {
        source: "/api/(.*)",
        headers: [
          { key: "Cache-Control", value: "private, max-age=60" },
        ],
      },
    ];
  },
};

Service Worker Caching Strategies

// service-worker.js — using Workbox strategies
import { registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";

// Cache First: static assets (CSS, JS, fonts)
// Fastest — serve from cache; update in background
registerRoute(
  ({ request }) => request.destination === "style" || request.destination === "script",
  new CacheFirst({
    cacheName: "static-assets",
    plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })],
  })
);

// Network First: API data
// Always try network; fall back to cache if offline
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new NetworkFirst({
    cacheName: "api-cache",
    plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60 })],
  })
);

// Stale While Revalidate: HTML pages, blog posts
// Serve from cache immediately; update in background
registerRoute(
  ({ request }) => request.mode === "navigate",
  new StaleWhileRevalidate({
    cacheName: "pages-cache",
    plugins: [new ExpirationPlugin({ maxEntries: 50 })],
  })
);

6. Font Optimization: font-display, Preload, Subsetting, Variable Fonts

Custom fonts can cause Flash of Invisible Text (FOIT) or Flash of Unstyled Text (FOUT), both of which hurt CLS and perceived performance. The right font loading strategy eliminates these issues while keeping download sizes small.

/* 1. Self-host fonts and use font-display: swap */
@font-face {
  font-family: "Inter";
  src:
    url("/fonts/inter-var.woff2") format("woff2-variations"),
    url("/fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;       /* Variable font weight range */
  font-style: normal;
  font-display: swap;         /* Show fallback immediately */
  unicode-range: U+0000-00FF; /* Only Latin characters (subsetting) */
}

/* 2. Match fallback font metrics to reduce CLS on font swap */
@font-face {
  font-family: "Inter-Fallback";
  src: local("Arial");
  ascent-override: 90%;       /* Adjust to match Inter metrics */
  descent-override: 22.4%;
  line-gap-override: 0%;
  size-adjust: 107.5%;        /* Scale to match Inter's x-height */
}

body {
  font-family: "Inter", "Inter-Fallback", system-ui, sans-serif;
}

/* 3. Preload critical font files in <head> */
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

/* 4. Next.js built-in font optimization */
import { Inter, Roboto_Mono } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
  preload: true,
});

// Next.js automatically: self-hosts, subsets, and inlines preload hints
export default function Layout({ children }) {
  return <html className={inter.variable}>{children}</html>;
}

7. Resource Hints: preload, prefetch, preconnect, dns-prefetch

Resource hints tell the browser about resources it will need, allowing it to start network work early. Used correctly they dramatically reduce perceived load time; used incorrectly they waste bandwidth and delay other critical resources.

<head>
  <!-- preconnect: establish TCP+TLS connection to critical 3rd-party origins -->
  <!-- Use for origins you will load resources from on THIS page -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />

  <!-- dns-prefetch: only DNS resolution, lower priority -->
  <!-- Use for origins you might use (analytics, ad networks) -->
  <link rel="dns-prefetch" href="https://analytics.example.com" />

  <!-- preload: fetch a critical resource ASAP, needed for current page -->
  <!-- Must specify "as" attribute; browser will error without it -->
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/hero.avif" as="image" fetchpriority="high" />
  <link rel="preload" href="/styles/critical.css" as="style" />

  <!-- prefetch: fetch a resource that will be needed on NEXT navigation -->
  <!-- Low priority; downloaded during idle time -->
  <link rel="prefetch" href="/next-page.js" />
  <link rel="prefetch" href="/next-page-hero.avif" />

  <!-- modulepreload: preload an ES module and its dependencies -->
  <link rel="modulepreload" href="/app.mjs" />
</head>

<!-- Resource Hint Strategy Guide:
     preconnect  -> 3rd-party domains used on this page (limit to 2-3)
     dns-prefetch -> 3rd-party domains that might be used
     preload     -> LCP image, critical fonts, critical CSS/JS
     prefetch    -> Resources needed on the next likely page
     AVOID: preloading resources that are not used = wasted bandwidth -->

8. Server-Side Performance: Brotli/gzip, HTTP/2, HTTP/3, Edge Computing

TTFB (Time to First Byte) directly impacts LCP. Every millisecond of server response time is milliseconds added to LCP. Server-side optimizations target TTFB by reducing compute time, reducing transfer size, and moving computation closer to the user.

# Nginx: enable Brotli and gzip compression
# Brotli compresses ~20% better than gzip

# nginx.conf
http {
  # Brotli (install nginx-module-brotli)
  brotli on;
  brotli_comp_level 6;
  brotli_types
    text/html text/css text/javascript application/javascript
    application/json image/svg+xml font/woff2;

  # Gzip fallback
  gzip on;
  gzip_comp_level 6;
  gzip_vary on;
  gzip_min_length 256;
  gzip_types
    text/html text/css application/javascript application/json
    image/svg+xml font/woff font/woff2;

  # Enable HTTP/2 (requires SSL)
  server {
    listen 443 ssl;
    http2 on;         # Nginx 1.25+ syntax
    # listen 443 ssl http2;  # Older Nginx syntax

    # HTTP/3 / QUIC
    listen 443 quic reuseport;
    add_header Alt-Svc 'h3=":443"; ma=86400';

    # Push critical resources (HTTP/2 Server Push)
    # Note: HTTP/2 push is deprecated in favor of preload hints
  }
}

# Edge computing with Vercel Edge Functions
// middleware.ts (Next.js)
import { NextResponse } from "next/server";
export const runtime = "edge";

export function middleware(request) {
  // Runs at the CDN edge — ~5ms latency globally
  const country = request.geo?.country || "US";
  const response = NextResponse.next();
  response.headers.set("x-user-country", country);
  return response;
}

9. Database Query Optimization: N+1 Problem, Indexes, Query Caching

Database query performance directly determines API response time and TTFB. The N+1 query problem is the most common performance killer — it occurs when fetching a list of N items triggers N additional database queries (one per item), turning one logical operation into hundreds of round trips.

// N+1 Problem (BAD): 1 query for posts + N queries for each author
const posts = await db.post.findMany();  // 1 query
for (const post of posts) {
  post.author = await db.user.findUnique({ where: { id: post.authorId } }); // N queries!
}

// Solution: Use include/join to fetch all data in one query
const posts = await db.post.findMany({
  include: { author: true, tags: true, _count: { select: { comments: true } } },
  where: { published: true },
  orderBy: { createdAt: "desc" },
  take: 20,
  skip: (page - 1) * 20,
});

// Database indexes: add for fields used in WHERE/ORDER BY/JOIN
-- PostgreSQL: create index concurrently (no table lock)
CREATE INDEX CONCURRENTLY idx_posts_published_created
  ON posts (published, created_at DESC)
  WHERE published = true;

-- Composite index for multi-column queries
CREATE INDEX CONCURRENTLY idx_users_email_status
  ON users (email, status);

-- Prisma schema index definition
model Post {
  id        Int      @id
  published Boolean
  createdAt DateTime

  @@index([published, createdAt(sort: Desc)])
}

// Query result caching with Redis
import { createClient } from "redis";
const redis = createClient();

async function getCachedPosts(page) {
  const key = "posts:page:" + page;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const posts = await db.post.findMany({ take: 20, skip: page * 20 });
  await redis.setEx(key, 300, JSON.stringify(posts)); // Cache 5 minutes
  return posts;
}

10. Lighthouse Scoring: How It Works and Key Improvement Strategies

Lighthouse is Google's open-source tool for measuring web page quality. It runs a series of audits in a simulated mobile environment and produces weighted scores for Performance, Accessibility, Best Practices, and SEO.

Lighthouse Performance Score Weights (approximate):

Metric                    Weight    Target     Impact
------                    ------    ------     ------
LCP (Largest CP)          25%       < 2.5s     Loading
TBT (Total Blocking)      30%       < 200ms    Interactivity
CLS (Layout Shift)        15%       < 0.1      Stability
FCP (First CP)            10%       < 1.8s     Loading
SI  (Speed Index)         10%       < 3.4s     Visual progress
TTI (Time to Interactive) 10%       < 3.8s     Interactivity

Note: TBT (lab metric) correlates with INP (field metric)
Reducing TBT in Lighthouse = reducing INP for real users

Common Lighthouse Quick Wins by Score Impact:

Issue                          Potential Gain    Fix
-----                          --------------    ---
Render-blocking resources      5-30 points       Defer CSS/JS
Oversized images               5-25 points       Resize + compress
Unused JavaScript              5-20 points       Tree shake, split
Unused CSS                     5-15 points       PurgeCSS
Missing image dimensions       5-10 points       Add width/height
No text compression            5-10 points       Enable Brotli
Inefficient cache policy       3-10 points       Set Cache-Control
No lazy loading                3-8 points        loading="lazy"
Multiple page redirects        2-5 points        Reduce redirects

11. Performance Budgets and Monitoring: Lighthouse CI, web-vitals JS

Performance budgets define upper limits for page weight, load time, and Lighthouse scores. Enforcing budgets in CI prevents performance regression — a pull request that adds a heavy dependency or forgets to optimize an image will fail the build before it reaches production.

# .lighthouserc.json — Lighthouse CI configuration
{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "url": ["http://localhost:3000/", "http://localhost:3000/blog"]
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "uses-optimized-images": "error",
        "uses-webp-images": "warn",
        "uses-text-compression": "error",
        "uses-rel-preconnect": "warn"
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

# GitHub Actions workflow for Lighthouse CI
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci && npm run build
      - run: npm run start &
      - run: npx @lhci/cli@0.14.x autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

12. React and Next.js Performance: React.memo, useMemo, Server Components, ISR

React's rendering model introduces unique performance challenges. Unnecessary re-renders, expensive computations on every render, and hydration costs all hurt INP and TTI. React 18 and Next.js 13+ introduce powerful primitives that solve these problems at an architectural level.

// React.memo: prevent re-renders when props have not changed
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
  return (
    <ul>
      {items.map(item => <li key={item.id} onClick={() => onSelect(item)}>{item.name}</li>)}
    </ul>
  );
}, (prevProps, nextProps) => {
  // Custom comparison: only re-render if items array reference changed
  return prevProps.items === nextProps.items;
});

// useMemo: memoize expensive computations
function Dashboard({ data }) {
  // Re-computed only when data changes, not on every render
  const processedData = useMemo(() => {
    return data
      .filter(item => item.active)
      .sort((a, b) => b.score - a.score)
      .slice(0, 100);
  }, [data]);

  // useCallback: stable function reference for child components
  const handleSelect = useCallback((id) => {
    setSelected(prev => new Set([...prev, id]));
  }, []); // Empty deps = stable reference forever

  return <ExpensiveList items={processedData} onSelect={handleSelect} />;
}

// React Server Components: zero JavaScript sent to client
// app/blog/page.tsx — Server Component (default in Next.js App Router)
async function BlogPage() {
  // Runs only on server — no hydration overhead
  const posts = await db.post.findMany({ orderBy: { createdAt: "desc" } });

  return (
    <main>
      {posts.map(post => <BlogCard key={post.id} post={post} />)}
    </main>
  );
}

// ISR: Incremental Static Regeneration
// Statically generate + revalidate in background
export const revalidate = 3600; // Revalidate every hour

// Or per-fetch revalidation:
const data = await fetch("https://api.example.com/data", {
  next: { revalidate: 60 },   // Cache for 60 seconds
});

// On-demand revalidation:
// POST /api/revalidate
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/blog");      // Revalidate specific path
revalidateTag("blog-posts");  // Revalidate by cache tag

13. Tools Comparison: Lighthouse vs WebPageTest vs GTmetrix

No single tool gives a complete picture of performance. Use Lighthouse for developer feedback loops, WebPageTest for detailed technical analysis, and real user monitoring (RUM) for production truth.

ToolTypeBest ForLimitationsCost
LighthouseLab (synthetic)CI/CD, dev feedback, quick auditsSimulated throttling, no real usersFree
PageSpeed InsightsLab + Field (CrUX)See real user CrUX data alongside labOnly Google data, no waterfallFree
WebPageTestLab (advanced)Waterfall analysis, video, real devicesComplex UI, no CI integration built-inFree/Paid
GTmetrixLab (synthetic)Reports, historical trackingLess detail than WebPageTestFree/Paid
Chrome UX ReportField (real users)Real-world Core Web Vitals by URL28-day aggregated, no page detailsFree
Vercel Speed InsightsField (RUM)Real-time monitoring on VercelVercel-only, limited free tierFree/Paid
Datadog RUMField (RUM)Production monitoring at scaleRequires SDK, costs scale with trafficPaid

14. Performance Quick Wins Checklist

If you are starting from scratch, implement these in order of impact. Each item is achievable in under a day and produces measurable results.

Performance Quick Wins — Ordered by Impact:

Images (highest impact):
  [x] Convert images to WebP or AVIF format
  [x] Add srcset + sizes for responsive images
  [x] Set width and height on all img elements
  [x] Add loading="lazy" to below-fold images
  [x] Preload LCP image with fetchpriority="high"
  [x] Serve images from a CDN

JavaScript:
  [x] Enable code splitting (dynamic imports)
  [x] Remove unused dependencies (tree shaking)
  [x] Defer non-critical scripts (<script defer>)
  [x] Analyze bundle with @next/bundle-analyzer

Server:
  [x] Enable Brotli compression (20% smaller than gzip)
  [x] Set proper Cache-Control headers
  [x] Enable HTTP/2 or HTTP/3
  [x] Use a CDN for global edge delivery

CSS & Fonts:
  [x] Inline critical CSS for above-fold content
  [x] Add font-display: swap to @font-face
  [x] Preload critical fonts
  [x] Remove unused CSS with PurgeCSS

Miscellaneous:
  [x] Add preconnect for critical 3rd-party origins
  [x] Minimize DOM size (target < 1500 nodes)
  [x] Reduce third-party scripts
  [x] Set up Lighthouse CI to prevent regression
  [x] Measure real users with web-vitals JS

Frequently Asked Questions

What are Core Web Vitals and why do they matter for SEO?

Core Web Vitals are three user-experience metrics Google uses as ranking signals: LCP (loading speed), INP (responsiveness), and CLS (visual stability). Pages in the "Good" threshold for all three get a ranking boost in the Page Experience system. While content quality remains the primary ranking factor, Core Web Vitals serve as a tiebreaker between pages of similar quality. Good thresholds: LCP < 2.5s, INP < 200ms, CLS < 0.1.

What is the fastest way to improve Largest Contentful Paint (LCP)?

The fastest LCP wins: (1) preload the LCP image with <link rel="preload" fetchpriority="high">, (2) convert images to WebP or AVIF, (3) serve from a CDN, (4) eliminate render-blocking scripts and stylesheets, (5) enable server-side caching to reduce TTFB. A single change — adding fetchpriority="high" to the hero image — often improves LCP by 200–500ms immediately.

How do I fix Cumulative Layout Shift (CLS)?

Fix CLS by: always setting width and height attributes on images and videos, using aspect-ratio CSS for responsive media, reserving space for ads and embeds with min-height, using font-display: swap with size-adjust to minimize font swap shifts, and avoiding dynamically inserting content above existing content. Transform and opacity animations do not cause layout shifts — use them instead of animating top/left/width/height.

What is the difference between browser cache, CDN cache, and service worker cache?

Browser cache: stored on the user's device, controlled by Cache-Control headers — reduces repeat visit load times. CDN cache: stored on geographically distributed edge servers, controlled by s-maxage and CDN settings — reduces TTFB globally. Service worker cache: programmable JavaScript storage in the browser — enables offline support, custom caching logic, and background sync. Best practice: use all three in combination for maximum resilience and speed.

Should I use WebP or AVIF for images?

Use both. AVIF compresses ~50% better than JPEG and ~20% better than WebP, with 96% browser support. WebP compresses ~30% better than JPEG with 97% support. Use the <picture> element to serve AVIF first, then WebP as fallback, then JPEG for maximum browser compatibility. Next.js Image component handles this automatically when you set formats: ["image/avif", "image/webp"] in next.config.js.

What is a good Lighthouse performance score?

A score of 90+ is "Good" (green), 50–89 "Needs Improvement" (orange), below 50 "Poor" (red). However, Lighthouse runs in a simulated throttled environment and can vary 5–10 points between runs. Prioritize real-user field data from Chrome UX Report and web-vitals.js over lab scores. A page can score 95 on Lighthouse but still fail Core Web Vitals in the field if real users have slow devices or connections.

How does code splitting improve JavaScript performance?

Code splitting divides your JavaScript bundle into smaller chunks loaded on demand. Instead of parsing and compiling all JS upfront, users download only what the current page needs. React.lazy() enables component-level splitting, and Next.js automatically splits by route. The goal is an initial JS payload under 150KB compressed. Every additional 100KB of unneeded JavaScript adds ~0.5s to Time to Interactive on mid-range Android devices.

What is stale-while-revalidate and when should I use it?

stale-while-revalidate is a Cache-Control directive that serves cached content immediately while fetching a fresh version in the background. Example: Cache-Control: max-age=60, stale-while-revalidate=86400 — browsers serve cached content for up to 24 hours, revalidating after the 60-second max-age expires. Ideal for blog posts, product listings, and any content that can tolerate being slightly stale. Avoid it for checkout, authentication, and real-time data.

Performance Monitoring Stack Reference

web-vitals.jsReal user metric collection in production
Lighthouse CIAutomated CI/CD performance gating
Chrome DevToolsFlame charts, waterfall, memory profiling
PageSpeed InsightsReal CrUX data + lab audit in one view
WebPageTestAdvanced waterfall, filmstrip, video compare
Chrome UX ReportAggregate field data via BigQuery or API
Vercel AnalyticsReal-time CWV on Vercel deployments
@next/bundle-analyzerVisualize Next.js bundle composition
𝕏 Twitterin LinkedIn
Var dette nyttig?

Hold deg oppdatert

Få ukentlige dev-tips og nye verktøy.

Ingen spam. Avslutt når som helst.

Try These Related Tools

{ }JSON Formatter{ }CSS Minifier / Beautifier

Related Articles

React Hooks komplett guide: useState, useEffect og Custom Hooks

Mestr React Hooks med praktiske eksempler. useState, useEffect, useContext, useReducer, useMemo, useCallback, custom hooks og React 18+ concurrent hooks.

JavaScript Promises og Async/Await: Komplett Guide

Mestre Promises og async/await: opprettelse, kjeding, Promise.all og feilhåndtering.

CI/CD Guide: GitHub Actions, GitLab CI, Docker, and Deployment Pipelines

Master CI/CD pipelines. Covers GitHub Actions workflows, GitLab CI, Docker builds, deployment strategies (blue-green, canary), secrets management, monorepo CI, and pipeline optimization.