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