Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 33 of 33
Testing & Deployment

Performance Optimization & Core Web Vitals

Performance Optimization & Core Web Vitals

Core Web Vitals are Google's official metrics for measuring user experience. They directly affect SEO rankings and, more importantly, how your app feels to use. This lesson covers what each metric means and the specific Next.js techniques that improve them.

The Three Core Web Vitals

LCP — Largest Contentful Paint: How long until the largest element (usually a hero image or heading) is visible. Target: under 2.5 seconds.

INP — Interaction to Next Paint (replaced FID in 2024): How quickly the page responds to user input. Target: under 200ms.

CLS — Cumulative Layout Shift: How much the page layout jumps around as it loads. Target: under 0.1.

Measuring Your Current Performance

# Install the Lighthouse CLI
npm install -g lighthouse

# Test a production URL
lighthouse https://yourapp.com --output html --view

# Or use Vercel Speed Insights (real user data)
npm install @vercel/speed-insights

Also check:

  • Chrome DevTools → Lighthouse tab
  • PageSpeed Insights (pagespeed.web.dev)
  • Vercel Analytics → Web Vitals dashboard

Improving LCP — Largest Contentful Paint

1. Use next/image for All Images

import Image from "next/image";

// ✅ next/image handles:
// - Automatic WebP/AVIF conversion
// - Responsive srcset generation
// - Lazy loading by default
// - Size optimization

function CourseCover({ src, alt }: { src: string; alt: string }) {
    return (
        <Image
            src={src}
            alt={alt}
            width={1280}
            height={720}
            className="rounded-xl object-cover"
            priority     // Add to hero/LCP images — preloads them
        />
    );
}

// For above-the-fold images (hero, course banner), add priority
<Image src="/hero.jpg" alt="Hero" fill priority />

priority triggers a <link rel="preload"> in the document <head>. The browser fetches the image immediately instead of waiting for the JavaScript to render.

2. Preconnect to External Resources

// src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html>
            <head>
                {/* Preconnect to CDN, fonts, APIs */}
                <link rel="preconnect" href="https://fonts.googleapis.com" />
                <link rel="preconnect" href="https://images.ctfassets.net" />
            </head>
            <body>{children}</body>
        </html>
    );
}

3. Self-Host Fonts (Next.js Font Optimization)

// next/font downloads fonts at build time, zero network request at runtime
import { Inter } from "next/font/google";

const inter = Inter({
    subsets: ["latin"],
    display: "swap",   // Text visible immediately (swap)
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html className={inter.className}>
            <body>{children}</body>
        </html>
    );
}

Google Fonts loaded via next/font are embedded in your CSS — no separate HTTP request, no flash of invisible text.

Improving INP — Interaction to Next Paint

1. Reduce JavaScript Bundle Size

// Lazy load components not needed immediately
import { lazy, Suspense } from "react";

const HeavyEditor = lazy(() => import("./HeavyEditor"));
const ChartDashboard = lazy(() => import("./ChartDashboard"));

function CoursePage() {
    const [showEditor, setShowEditor] = useState(false);
    
    return (
        <div>
            <button onClick={() => setShowEditor(true)}>Edit Course</button>
            
            {showEditor && (
                <Suspense fallback={<div>Loading editor...</div>}>
                    <HeavyEditor />
                </Suspense>
            )}
        </div>
    );
}

2. Avoid Blocking the Main Thread

// ❌ Synchronous heavy computation blocks UI
function SearchResults({ items }: { items: Item[] }) {
    // This runs synchronously and blocks rendering
    const filtered = expensiveFilter(items);   // 200ms of CPU work
    return <ul>{filtered.map(...)}</ul>;
}

// ✅ useMemo prevents recomputation, startTransition defers non-urgent updates
function SearchResults({ items, query }: { items: Item[]; query: string }) {
    const filtered = useMemo(() => expensiveFilter(items, query), [items, query]);
    return <ul>{filtered.map(...)}</ul>;
}

// ✅ For very expensive operations, use useTransition
function SearchPage() {
    const [query, setQuery] = useState("");
    const [results, setResults] = useState([]);
    const [isPending, startTransition] = useTransition();
    
    function handleSearch(value: string) {
        setQuery(value);   // Update input immediately
        startTransition(() => {
            setResults(heavyFilter(data, value));   // Defer expensive update
        });
    }
}

3. Code Splitting at Route Level

Next.js automatically code-splits at route level — each page loads its own bundle. But you can further split large third-party libraries:

// src/app/admin/analytics/page.tsx — analytics library only loaded for /admin/analytics
import dynamic from "next/dynamic";

const AnalyticsChart = dynamic(() => import("recharts").then(m => m.LineChart), {
    ssr: false,   // Charts often need browser APIs
    loading: () => <ChartSkeleton />,
});

Improving CLS — Cumulative Layout Shift

1. Always Specify Image Dimensions

// ❌ Layout shifts as image loads (browser doesn't know the size)
<img src="/course.jpg" alt="Course" className="w-full" />

// ✅ Dimensions prevent layout shift
<Image src="/course.jpg" alt="Course" width={640} height={360} />

// ✅ Or use aspect-ratio container
<div className="relative w-full aspect-video">
    <Image src="/course.jpg" alt="Course" fill className="object-cover" />
</div>

2. Skeleton Sizes Match Content

// ❌ Skeleton is the wrong size — content shifts layout when it loads
<div className="h-4 w-32 animate-pulse bg-gray-200 rounded" />
// Actual content: a long 3-line paragraph

// ✅ Skeleton matches content dimensions
function TitleSkeleton() {
    return (
        <div className="space-y-2">
            <div className="h-8 w-2/3 animate-pulse bg-gray-200 rounded" />
            <div className="h-4 w-full animate-pulse bg-gray-200 rounded" />
            <div className="h-4 w-4/5 animate-pulse bg-gray-200 rounded" />
        </div>
    );
}

3. Reserve Space for Dynamic Content

// ❌ Toast appears and pushes content down
// ✅ Position toasts as fixed overlays, not in document flow

// ❌ Cookie banner appears at top and shifts everything
// ✅ Use a sticky banner with reserved height, or load it from cookies on server

Analyzing Your Bundle

# Add bundle analyzer
npm install @next/bundle-analyzer

# next.config.ts
import BundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = BundleAnalyzer({
    enabled: process.env.ANALYZE === "true",
});

export default withBundleAnalyzer(config);

# Run
ANALYZE=true npm run build

The analyzer shows a visual treemap of your bundle. Look for:

  • Large libraries that can be lazy-loaded
  • Duplicate packages included by different dependencies
  • Unexpected large imports

The Next.js Build Output

Route (app)              Size     First Load JS
┌ ○ /                    2.4 kB        87.4 kB
├ ○ /about               1.2 kB        86.2 kB
├ ● /blog/[slug]         3.1 kB        88.1 kB
├ λ /dashboard           4.2 kB        89.2 kB
└ λ /api/courses         0 B                0 B

○ Static   ● ISR/SSG   λ Dynamic (server-rendered)
  • Static — served from CDN, fastest
  • ISR — cached but can revalidate
  • λ Dynamic — server-rendered per request

Aim to have public-facing pages be static or ISR. Move dynamic rendering only where truly required (authenticated pages, real-time data).

Congratulations — you've completed the React/Next.js Complete Course. You're now equipped to build production-quality Next.js applications.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!