Optimization & Performance

Core Web Vitals, Performance Patterns & Production Best Practices

Caterpillar logo

Core Web Vitals

Why Performance Matters

Core Web Vitals are Google's metrics for measuring real-world user experience. They directly impact search rankings, user engagement, and conversion rates. Every optimization technique in this module targets one or more of these metrics.

Core Idea: Performance is not a nice-to-have. Users abandon pages that load slowly, and search engines penalize them. These three metrics define what "fast" means.
MetricMeasuresGoodNext.js Helps With
LCPLargest Contentful Paint — loading speed< 2.5spriority on hero image, next/font, SSR/streaming
INPInteraction to Next Paint — responsiveness< 200msServer Components, useTransition, less client JS
CLSCumulative Layout Shift — visual stability< 0.1next/image dimensions, next/font, skeleton loaders
// Hero image: priority + responsive sizes
<Image
  src={hero}
  alt="Hero"
  priority           // Preloads the image
  sizes="100vw"      // Correct size for viewport
  placeholder="blur" // Instant visual while loading
/>

// Font: self-hosted, no network waterfall
const inter = Inter({ subsets: ['latin'] })

// SSR/Streaming: content arrives fast
<Suspense fallback={<HeroSkeleton />}>
  <HeroContent />
</Suspense>
Best Practice: Measure in production with real user metrics using Vercel Analytics or useReportWebVitals, not just lab scores from Lighthouse.

Image Optimization

Automatic Optimization with next/image

Images are often the largest assets on web pages. The next/image component provides automatic format conversion (WebP/AVIF), responsive sizing, and lazy loading out of the box.

Rule: Always use <Image> from next/image instead of native <img> tags. It optimizes images automatically and prevents layout shifts.
import Image from 'next/image'
import heroImg from '@/public/hero.jpg' // Import = auto dimensions

export function Hero() {
  return (
    <Image
      src={heroImg}
      alt="Hero showcasing our product"
      placeholder="blur"  // Auto blur placeholder for local images
      priority             // Preload - above the fold (improves LCP)
      sizes="100vw"
    />
  )
}

Key props to know:

  • priority — Preload above-the-fold images (hero, banner). Directly improves LCP
  • sizes — Tell the browser how wide the image will be at each breakpoint. Prevents oversized downloads
  • fill — Fill the parent container when dimensions are unknown. Pair with sizes for correct responsive behavior
  • placeholder="blur" — Show a blur placeholder while loading. Automatic for local images, needs blurDataURL for remote
Quality guide: 75 for thumbnails, 80-85 for hero images, 90+ is usually overkill for web.

Font Optimization

Zero Layout Shift with next/font

next/font self-hosts fonts, eliminates network waterfalls, and prevents layout shift (CLS). It downloads font files at build time and serves them alongside your static assets.

Golden Rule: Never use <link href="fonts.googleapis.com"> or @import url() in CSS. Always use next/font.
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],    // Only load Latin characters
  display: 'swap',       // Show fallback while loading
  variable: '--font-inter', // CSS variable for Tailwind
})

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

Common mistakes to avoid:

  • Creating font instances inside components (creates duplicates on each render)
  • Forgetting subsets: ['latin'] (loads all character sets, massive files)
  • Using static weight imports instead of variable fonts (multiple files vs one)
Best Practice: Export font instances from a shared lib/fonts.ts file. Use CSS variables for maximum flexibility across your styles.

Third-party Script Loading

Optimal Loading Strategies

Third-party scripts (analytics, widgets, ads) can significantly impact your page performance. The next/script component provides strategic loading options to minimize this impact.

Key Concept: Use @next/third-parties for Google services instead of manual script tags — it applies optimized loading strategies automatically.
// app/layout.tsx
import { GoogleAnalytics } from '@next/third-parties/google'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
      <GoogleAnalytics gaId="G-XXXXXXXXXX" />
    </html>
  )
}

// Send events from any component
import { sendGAEvent } from '@next/third-parties/google'

export function BuyButton({ product }) {
  return (
    <button onClick={() => {
      sendGAEvent('event', 'purchase', {
        value: product.price,
      })
    }}>
      Buy Now
    </button>
  )
}
StrategyWhen to UseExample
afterInteractive90% of scripts: analytics, chat, paymentsGoogle Analytics, Intercom
lazyOnloadNon-essential featuresSocial embeds, surveys
beforeInteractiveCritical initialization (root layout only)Polyfills, consent managers
workerHeavy computation off main threadAnalytics processing
Best Practice: Use @next/third-parties for Google services (Analytics, Tag Manager, Maps) instead of manual script tags. Always add an id to inline scripts.

Eliminating Data Waterfalls

The #1 Performance Killer

Data waterfalls occur when requests depend on previous responses, creating a sequential chain. This is the single most impactful performance issue in web applications.

Critical: A page with 3 sequential 200ms fetches takes 600ms. With Promise.all, it takes 200ms — 3x faster.
export default async function DashboardPage() {
  // Each fetch waits for the previous one
  const user = await getUser()        // 200ms
  const posts = await getPosts()      // 200ms (waits for user)
  const comments = await getComments() // 200ms (waits for posts)
  // Total: ~600ms

  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentList comments={comments} />
    </div>
  )
}

Three strategies to eliminate waterfalls:

  • Promise.all(): Parallelize independent fetches in the same component
  • Suspense streaming: Each component fetches its own data, content appears progressively
  • Preload pattern: Use React.cache() with fire-and-forget calls to start fetches early
Important: Streaming only works with dynamic rendering. Static pages (including ISR and pages with "use cache") are pre-generated as complete HTML — Suspense fallbacks will never show to users.

Link Prefetching Behavior

Controlling Navigation Performance

The prefetch prop on <Link> has three distinct behaviors in App Router. Understanding them is key to optimizing navigation performance.

Key Concept: Prefetching loads route data in the background before the user clicks. The right strategy depends on how likely and how expensive the navigation is.
Breaking Change from Pages Router: prefetch={false} no longer prefetches on hover in App Router. In Pages Router it still prefetched on hover — now it completely disables prefetching.
ValueBehaviorWhen to Use
undefined (default)Only prefetches static routesDefault — good for mixed static/dynamic routes
truePrefetches both static and dynamic routesCritical navigation paths where speed matters most
falseCompletely disables prefetching (including hover)Rarely visited links, expensive dynamic pages
import Link from 'next/link'

// Default: only prefetches if route is static
<Link href="/about">About</Link>

// Force prefetch for critical dynamic route
<Link href="/dashboard" prefetch={true}>
  Dashboard
</Link>

// Disable prefetch entirely for expensive page
<Link href="/admin/reports" prefetch={false}>
  Reports
</Link>
Best Practice: Use prefetch={true} for critical navigation paths (e.g., main nav items) and leave the default for everything else.

Key-Based Suspense

Re-triggering Loading States on Search Param Changes

When navigating on the same page with only search parameter changes, Suspense boundaries do not re-trigger their loading states by default. The UI appears frozen during data refetching.

Key Concept: React uses the key prop to determine component identity. Changing the key forces React to unmount the old instance and mount a new one, re-triggering Suspense.
// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  const { q } = await searchParams

  return (
    <Suspense fallback={<LoadingSpinner />}>
      {/* Suspense does NOT re-trigger when ?q changes */}
      <SearchResults query={q} />
    </Suspense>
  )
}

How it works:

  • The key prop change makes React treat the Suspense boundary as a new instance
  • React unmounts the old content and shows the fallback while the new data loads
  • This works for any same-page navigation where only search params or dynamic data changes
Best Practice: Always add a key prop containing the changing search parameter to Suspense boundaries on search/filter pages.

Bundle Size Optimization

Ship Less JavaScript to the Client

Every kilobyte of JavaScript sent to the client must be downloaded, parsed, and executed. Reducing bundle size directly improves Time to Interactive (TTI) and Interaction to Next Paint (INP).

Golden Rule: Ship as little JavaScript to the client as possible. Use Server Components by default, Client Components only when needed.
// This pulls in ALL exports from the barrel file
// even if you only use Button
import { Button } from '@/components'

// The barrel file (components/index.ts) exports everything:
// export { Button } from './Button'
// export { Modal } from './Modal'      <- included in bundle
// export { DataGrid } from './DataGrid' <- included in bundle
// export { Chart } from './Chart'       <- included in bundle
'use client'
import { Chart } from 'chart-library'  // 200kb added to initial bundle

export function Dashboard() {
  const [showChart, setShowChart] = useState(false)

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && <Chart data={data} />}
    </div>
  )
}

Bundle size checklist:

  • Direct imports: Avoid barrel files, import from specific paths
  • Dynamic imports: Code-split heavy components with next/dynamic
  • Analyze bundles: Run next experimental-analyze to inspect module sizes
  • Minimize NEXT_PUBLIC_: Environment variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle
Analyze your bundle: Run npx next experimental-analyze to see exactly what's contributing to your bundle size and identify optimization targets.

Server Component Performance

Maximizing RSC Efficiency

Server Components run only on the server. They can fetch data directly without API round-trips, access backend resources, and send zero JavaScript to the client. But you need to use them correctly.

Key Concept: The boundary between Server and Client Components is a serialization boundary. Only JSON-serializable data can cross it.
import { cache } from 'react'

// Deduplicate across the same request
const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } })
})

// Called in generateMetadata
export async function generateMetadata({ params }) {
  const user = await getUser(params.id) // 1st call: hits DB
  return { title: user.name }
}

// Called in the page - same request, same cache
export default async function UserPage({ params }) {
  const user = await getUser(params.id) // 2nd call: uses cache
  return <UserProfile user={user} />
}

Server Component patterns:

  • React.cache(): Deduplicates identical calls within a single request
  • Minimize serialization: Only pass the data Client Components actually need
  • after(): Defer non-critical work (emails, logs, analytics) to after the response

Client Component Boundaries

Keep Them Small and Focused

The 'use client' directive creates a boundary. Everything imported by a Client Component becomes client-side code. The key is keeping this boundary as small as possible.

Mental Model: Think of 'use client' as a fence. Everything inside the fence ships JavaScript to the browser. Keep the fence around as little code as possible.
'use client'

import { useState } from 'react'
import { BlogPosts } from './BlogPosts'    // Now client-side
import { Sidebar } from './Sidebar'        // Now client-side
import { Footer } from './Footer'          // Now client-side

export function Dashboard() {
  const [open, setOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      <Sidebar />     {/* Can't fetch data on server */}
      <BlogPosts />   {/* Can't fetch data on server */}
      <Footer />      {/* Unnecessarily in client bundle */}
    </div>
  )
}
Composition Pattern: Use the "children" pattern to wrap Server Components with Client Components. The children are rendered on the server and passed as pre-rendered content.

Metadata & SEO

Configuring Page Metadata

Next.js provides powerful metadata APIs for SEO optimization. You can define metadata statically or generate it dynamically based on your content.

Key Concept: Metadata flows from root layout to page, merging at each level. Child metadata overrides parent metadata for the same fields.
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'About Our Company',
  description: 'Learn about our mission and values',
  openGraph: {
    title: 'About Our Company',
    description: 'Learn about our mission and values',
    images: ['/og-about.jpg'],
  },
}

export default function AboutPage() {
  return <h1>About Us</h1>
}

Metadata inheritance and merging:

  • Metadata is merged from root layout down to page
  • Child metadata overrides parent metadata
  • Arrays are replaced, not merged (e.g., keywords)
  • OpenGraph objects are deeply merged
Pro tip: Use React.cache() when the same data is needed for both generateMetadata and the page component to avoid duplicate fetches.

Metadata File Conventions & OG Images

File-Based Metadata & Dynamic OG Generation

Next.js supports file-based metadata conventions. Place special files in your route directories and they're automatically picked up.

Key Concept: File conventions let you define metadata without writing code — just place the right file in the right folder. For dynamic content, use code-based generation.
FilePurposeLocation
favicon.icoBrowser tab iconapp/ root
opengraph-image.pngSocial share imageAny route segment
sitemap.tsSearch engine sitemapapp/ root
robots.tsCrawler instructionsapp/ root
import { ImageResponse } from 'next/og'
import { getBlogPost } from '@/lib/api'

export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function OGImage({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getBlogPost(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 80,
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          color: 'white',
        }}
      >
        <h1 style={{ fontSize: 64 }}>{post.title}</h1>
        <p style={{ fontSize: 28, opacity: 0.8 }}>{post.excerpt}</p>
      </div>
    ),
    { ...size }
  )
}
Best Practice: Use next/og (not @vercel/og) for OG image generation. Prefer route params over searchParams for cache efficiency.

Re-render Optimization

Keeping Client Components Efficient

When Client Components do need interactivity, optimize their rendering behavior. Unnecessary re-renders waste CPU cycles, cause janky UIs, and directly impact INP scores.

Key Concept: The fastest render is the one that never happens. Derive values during render instead of storing them in state, and separate urgent from non-urgent updates.
// Using useEffect to derive state (extra re-render)
function FilteredList({ items, query }) {
  const [filtered, setFiltered] = useState(items)

  useEffect(() => {
    setFiltered(items.filter(i => i.name.includes(query)))
  }, [items, query])
  // Problem: renders twice - once with stale data, once with filtered

  return <List items={filtered} />
}

// Derive during render - no extra state or effect needed
function FilteredList({ items, query }) {
  const filtered = items.filter(i => i.name.includes(query))
  // Renders once with correct data

  return <List items={filtered} />
}

Re-render optimization rules:

  • Derive state during render: Don't use useState + useEffect for computed values
  • useTransition: Separate urgent updates (input) from non-urgent (search results)
  • Functional setState: Use setState(prev => ...) for stable callbacks
  • Lazy state init: Pass a function to useState for expensive initial values

Production Optimization Checklist

Quick Reference

Use this checklist before shipping to production to ensure your application is fully optimized across all categories.

Approach: Start with Critical items first — they have the highest impact on performance. Then work through High and Medium priority items.
CategoryActionPriority
ComponentsServer Components by default, Client Components only for interactivityCritical
DataEliminate waterfalls with Promise.all or Suspense streamingCritical
BundleDirect imports, dynamic imports for heavy componentsCritical
Imagesnext/image with sizes, priority for LCP imagesHigh
Fontsnext/font with subsets and CSS variablesHigh
MetadatagenerateMetadata + OG images + sitemapHigh
Scriptsnext/script with proper strategy, @next/third-partiesMedium
Navigationprefetch on critical Links, key-based Suspense for search paramsHigh
RenderingDerive state during render, useTransition for async updatesMedium
SerializationPass minimal data across Server/Client boundaryMedium
AnalysisRun next experimental-analyze and monitor Core Web VitalsMedium
0/12 — Getting started. Work through each category systematically.0%
Images
Fonts
Bundle
Data
SEO
Scripts
Monitoring

Measuring & Monitoring

Continuous Performance Tracking

Performance optimization is not a one-time task. You need ongoing monitoring to catch regressions, validate improvements, and understand real-world user experience.

Key Concept: Lab metrics (Lighthouse) tell you about potential issues. Field metrics (real user data) tell you about actual user experience. You need both.
// app/layout.tsx
import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Send to your analytics service
    console.log(metric.name, metric.value)

    switch (metric.name) {
      case 'LCP':
        if (metric.value > 2500) {
          console.warn('LCP exceeds threshold:', metric.value)
        }
        break
      case 'INP':
        if (metric.value > 200) {
          console.warn('INP exceeds threshold:', metric.value)
        }
        break
      case 'CLS':
        if (metric.value > 0.1) {
          console.warn('CLS exceeds threshold:', metric.value)
        }
        break
    }
  })

  return null
}

Monitoring strategy:

  • Development: Use Lighthouse and next experimental-analyze to identify issues early
  • CI/CD: Run Lighthouse CI in pull requests to catch regressions before they ship
  • Production: Use Vercel Analytics or useReportWebVitals to track real user metrics continuously
Remember: Optimize the critical path first, monitor Core Web Vitals in production, analyze bundles regularly, and treat performance as an ongoing practice — not a one-time fix.
1 / 15
Slide 1 of 15