Loading
Improve Core Web Vitals and user experience with lazy loading, code splitting, image optimization, and caching strategies.
Performance is not about making fast sites faster. It is about making slow sites usable. A 100ms delay feels instant. A 1-second delay is noticeable. A 3-second delay loses users. This guide focuses on the optimizations that have the largest measurable impact.
Never optimize without a baseline measurement. You cannot improve what you have not measured, and intuition about performance bottlenecks is almost always wrong.
Core Web Vitals — The three metrics Google uses for ranking and that correlate with user experience:
Run a Lighthouse audit in Chrome DevTools (Lighthouse tab → Analyze page load). It scores performance from 0-100 and gives specific recommendations ranked by impact.
Focus on the highest-impact items first. Lighthouse orders recommendations by estimated savings — a suggestion saving 2 seconds matters more than one saving 50 milliseconds.
Images are typically 50-70% of a page's total weight. This is almost always the single highest-impact optimization.
Use modern formats. WebP is 25-35% smaller than JPEG at equivalent quality. AVIF is even smaller but has less browser support. Serve both with a fallback:
Always set width and height. Without explicit dimensions, the browser does not know how much space to reserve. When the image loads, everything shifts — that is CLS.
Use responsive images. Do not serve a 2400px image to a 375px phone screen.
Lazy load below-the-fold images. Native lazy loading defers images until they are near the viewport:
Never lazy load the hero image or LCP element — that makes your most important content load last.
A single 500KB JavaScript bundle blocks the main thread while the browser parses and executes it. Users see a blank page or an unresponsive UI.
Code splitting breaks the bundle into smaller chunks loaded on demand. In Next.js, every page is automatically a separate chunk. For components, use dynamic imports:
The CodeEditor bundle only downloads when the component is rendered, not on initial page load.
Identify what to split. In your build output, look for large dependencies:
Common candidates for lazy loading: code editors, charting libraries, PDF renderers, syntax highlighters — anything large that is not visible on initial page load.
Caching prevents redundant work. Every repeated computation, network request, or database query is a candidate for caching.
Browser caching with Cache-Control headers:
immutable tells the browser to never revalidate — the filename changes when the content changes. This is the most aggressive and most effective caching strategy for static assets.
Stale-while-revalidate is the most useful pattern for dynamic content. The browser serves the cached version instantly and fetches an update in the background. The user sees a fast response, and the next request gets fresh data.
CDN caching puts your content on servers worldwide. A user in Tokyo gets served from a Tokyo edge server instead of your origin in Virginia. Configure your CDN (Vercel, Cloudflare, etc.) to cache static assets and ISR pages at the edge.
A CSS file in the <head> blocks rendering until it downloads and parses. A synchronous script tag blocks parsing entirely.
Critical CSS: Inline the CSS needed for above-the-fold content directly in the HTML. The rest loads asynchronously. Frameworks like Next.js handle this automatically with CSS extraction.
Font loading: Fonts are a common source of invisible text or layout shifts.
font-display: swap shows a system font immediately while the custom font downloads. Without it, text is invisible until the font loads — the Flash of Invisible Text (FOIT).
Preload critical resources that the browser discovers late:
Only preload resources that are critical for the initial render. Preloading everything is worse than preloading nothing — it contends for bandwidth.
The critical rendering path is the sequence of steps between receiving HTML and rendering pixels. Every step you shorten or eliminate makes the page appear faster.
Server-side rendering (SSR) sends complete HTML instead of an empty shell that requires JavaScript to render. The user sees content immediately while JavaScript hydrates in the background.
Streaming SSR goes further — it sends HTML chunks as they become available instead of waiting for the entire page to render:
The heading renders instantly. The chart streams in when the database query completes. The user sees a progressively loading page instead of waiting for the slowest query to finish.
Reduce third-party scripts. Analytics, chat widgets, A/B testing — each one adds 50-200KB and competes for the main thread. Audit every third-party script with this question: "Is this worth making the page 200ms slower for every user?" Load non-critical scripts with defer or after user interaction.
Performance optimization is iterative. Measure, identify the biggest bottleneck, fix it, measure again. The first optimization usually has the largest impact. Each subsequent one has diminishing returns. Stop when you hit your targets — over-optimizing has a cost too, and that cost is complexity.
# Run Lighthouse from the command line
npx lighthouse https://your-site.com --output=json --output-path=./report.json
# Or use PageSpeed Insights for real-world data
# https://pagespeed.web.dev/// Next.js Image component handles this automatically
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="(max-width: 768px) 100vw, 1200px"
priority // Only for above-the-fold images
/>;import dynamic from "next/dynamic";
const CodeEditor = dynamic(() => import("@/components/CodeEditor"), {
loading: () => <div className="h-64 animate-pulse rounded-lg bg-white/5" />,
ssr: false, // Skip server rendering for browser-only components
});# Next.js shows bundle analysis with:
ANALYZE=true npm run build
# Or use source-map-explorer
npx source-map-explorer .next/static/chunks/*.js// Immutable assets (hashed filenames like main.a3b2c1.js)
"Cache-Control: public, max-age=31536000, immutable";
// HTML pages (always check for updates)
"Cache-Control: no-cache";
// API responses (cache for 60 seconds, serve stale while revalidating)
"Cache-Control: public, max-age=60, stale-while-revalidate=300";// Next.js App Router streams by default with Suspense boundaries
import { Suspense } from "react";
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<SkeletonChart />}>
<AnalyticsChart /> {/* Streams in when data is ready */}
</Suspense>
</main>
);
}@font-face {
font-family: "DM Sans";
src: url("/fonts/dm-sans.woff2") format("woff2");
font-display: swap; /* Show fallback text immediately */
font-weight: 400;
}<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero image" width="1200" height="600" />
</picture><img src="/photo.webp" alt="Photo" loading="lazy" width="400" height="300" /><link rel="preload" href="/fonts/dm-sans.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero.webp" as="image" />