Web Performance Optimization: Core Web Vitals and Beyond
Web Performance Optimization: Core Web Vitals and Beyond
Performance isn't just a technical metric -- it directly affects revenue. Amazon found that every 100ms of latency costs 1% in sales. Google uses Core Web Vitals as a ranking signal. Users abandon pages that take more than 3 seconds to load. The good news: most performance wins come from a handful of well-understood techniques.
Core Web Vitals: The Metrics That Matter
Google's Core Web Vitals are the industry standard for measuring user-perceived performance. There are three metrics, and they measure different aspects of the experience:
| Metric | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading speed | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | Responsiveness | < 200ms | 200ms - 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 | 0.1 - 0.25 | > 0.25 |
Measuring Core Web Vitals
// Using the web-vitals library (Google's official library)
import { onLCP, onINP, onCLS } from "web-vitals";
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
// Send to your analytics endpoint
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Lab tools (synthetic testing):
- Lighthouse (Chrome DevTools, CLI, or CI)
- WebPageTest
- PageSpeed Insights
Field tools (real user monitoring):
- Chrome User Experience Report (CrUX)
- web-vitals library
- Your analytics platform (Vercel Analytics, Cloudflare Web Analytics)
Lab data is useful for debugging, but field data is what Google uses for ranking and what reflects actual user experience.
Bundle Analysis: Finding the Fat
Before optimizing anything, measure your JavaScript bundle size. Most performance problems are caused by shipping too much JavaScript.
Bundle Analysis Tools
# For Vite projects
npx vite-bundle-visualizer
# For Webpack projects
npx webpack-bundle-analyzer stats.json
# For Next.js
ANALYZE=true next build # Requires @next/bundle-analyzer
# Generic tool -- works with any bundler
npx source-map-explorer dist/**/*.js
Common Bundle Size Offenders
| Library | Size (minified + gzipped) | Lighter Alternative |
|---|---|---|
| moment.js | 72 KB | date-fns (tree-shakeable) or dayjs (2 KB) |
| lodash (full) | 72 KB | lodash-es (tree-shakeable) or native JS |
| chart.js | 65 KB | lightweight-charts (40 KB) |
| Material UI (full import) | 100+ KB | Individual imports + tree shaking |
| axios | 13 KB | Native fetch (0 KB) |
// BAD -- imports everything
import _ from "lodash";
_.debounce(fn, 300);
// GOOD -- tree-shakeable import
import { debounce } from "lodash-es";
debounce(fn, 300);
// BEST -- native implementation (no dependency)
function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
Tree Shaking Verification
Tree shaking only works with ES modules. Verify your imports are actually being eliminated:
# Check if a dependency supports tree shaking
# Look for "module" or "exports" field in package.json
cat node_modules/lodash-es/package.json | jq '.module, .exports'
# Build and check output size
vite build
ls -la dist/assets/*.js
Lazy Loading and Code Splitting
Don't load code until it's needed. This is the single biggest performance win for most applications.
Route-Based Code Splitting (React)
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// Each route becomes its own chunk
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Component-Level Lazy Loading
import { lazy, Suspense, useState } from "react";
// Heavy components loaded on demand
const MarkdownEditor = lazy(() => import("./components/MarkdownEditor"));
const ChartWidget = lazy(() => import("./components/ChartWidget"));
function Dashboard() {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>Open Editor</button>
{showEditor && (
<Suspense fallback={<div>Loading editor...</div>}>
<MarkdownEditor />
</Suspense>
)}
</div>
);
}
Prefetching for Perceived Performance
// Prefetch a route when the user hovers over a link
function PrefetchLink({ to, children }: { to: string; children: React.ReactNode }) {
const prefetch = () => {
// Dynamic import starts loading the chunk
if (to === "/dashboard") import("./pages/Dashboard");
if (to === "/settings") import("./pages/Settings");
};
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
);
}
Image Lazy Loading
<!-- Native lazy loading -- no JavaScript needed -->
<img src="photo.jpg" alt="Description" loading="lazy" width="800" height="600" />
<!-- With srcset for responsive images -->
<img
srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
src="photo-800.webp"
alt="Description"
loading="lazy"
width="800"
height="600"
/>
Image Optimization
Images are typically the largest assets on a page. Optimizing them is high-impact and relatively straightforward.
Modern Image Formats
| Format | Compression | Browser Support | Best For |
|---|---|---|---|
| WebP | 25-34% smaller than JPEG | 97%+ | Photos, general use |
| AVIF | 50% smaller than JPEG | 92%+ | Photos (where supported) |
| SVG | Vector (scales infinitely) | 99%+ | Icons, logos, illustrations |
| PNG | Lossless | 99%+ | Screenshots, transparency |
Automated Image Optimization
// vite.config.ts with vite-plugin-imagemin
import imagemin from "vite-plugin-imagemin";
export default defineConfig({
plugins: [
imagemin({
gifsicle: { optimizationLevel: 3 },
mozjpeg: { quality: 80 },
pngquant: { quality: [0.8, 0.9] },
webp: { quality: 80 },
avif: { quality: 65 },
}),
],
});
The Picture Element for Format Negotiation
<picture>
<!-- Browser picks the first format it supports -->
<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>
Using Next.js Image Component
import Image from "next/image";
// Automatic optimization, lazy loading, and format selection
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // For above-the-fold images (disables lazy loading)
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
Caching Strategies
Caching is the most effective performance optimization. A cached response has zero latency.
HTTP Cache Headers
# Static assets with content hashing (cache forever)
Cache-Control: public, max-age=31536000, immutable
# Used for: app.a1b2c3.js, style.d4e5f6.css
# HTML pages (always revalidate)
Cache-Control: no-cache
# "no-cache" means "always check with server" (not "don't cache")
# API responses (cache briefly)
Cache-Control: private, max-age=60, stale-while-revalidate=300
# Serve cached version for 60s, then revalidate in background for up to 300s
# Never cache (sensitive data)
Cache-Control: no-store
Service Worker Caching
// sw.js -- Workbox-based service worker
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import {
CacheFirst,
StaleWhileRevalidate,
NetworkFirst,
} from "workbox-strategies";
// Precache app shell
precacheAndRoute(self.__WB_MANIFEST);
// Cache images with cache-first strategy
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// Cache API responses with stale-while-revalidate
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new StaleWhileRevalidate({
cacheName: "api-cache",
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
],
})
);
// Cache pages with network-first strategy
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({
cacheName: "pages",
plugins: [
new ExpirationPlugin({ maxEntries: 25 }),
],
})
);
Caching Strategy Decision Tree
Is the asset immutable (content-hashed filename)?
→ Yes: Cache forever (max-age=31536000, immutable)
→ No:
Is it HTML?
→ Yes: no-cache (always revalidate)
Is it an API response?
→ Is the data sensitive?
→ Yes: no-store
→ No: stale-while-revalidate for read-heavy, no-cache for write-heavy
Is it a font or third-party asset?
→ Yes: Cache for a long time but not immutable (max-age=604800)
Critical Rendering Path Optimization
The critical rendering path is the sequence of steps the browser takes to render the first paint. Optimizing it directly improves LCP.
Eliminate Render-Blocking Resources
<!-- BAD -- blocks rendering -->
<link rel="stylesheet" href="all-styles.css" />
<script src="analytics.js"></script>
<!-- GOOD -- inline critical CSS, defer non-critical -->
<style>
/* Critical CSS: above-the-fold styles only */
body { margin: 0; font-family: system-ui; }
.hero { height: 60vh; display: flex; align-items: center; }
</style>
<!-- Load full CSS asynchronously -->
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="styles.css" /></noscript>
<!-- Defer non-critical scripts -->
<script src="analytics.js" defer></script>
Preconnect and Preload
<head>
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- DNS prefetch for less critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/hero-image.webp" as="image" />
</head>
Font Loading Optimization
/* Use font-display to prevent invisible text */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap; /* Show fallback font immediately, swap when loaded */
font-weight: 100 900;
}
/* Size-adjust to minimize layout shift from font swap */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}
JavaScript Performance
Avoiding Layout Thrashing
// BAD -- reads and writes interleaved (forces multiple reflows)
elements.forEach((el) => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = height * 2 + "px"; // Write (invalidates layout)
});
// GOOD -- batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // All reads
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + "px"; // All writes
});
// BEST -- use requestAnimationFrame for DOM writes
function updateLayout() {
const heights = elements.map((el) => el.offsetHeight);
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + "px";
});
});
}
Virtualization for Long Lists
// Using @tanstack/react-virtual for large lists
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height
overscan: 5, // Render 5 items outside the viewport
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
);
}
Web Workers for Heavy Computation
// worker.ts
self.addEventListener("message", (event) => {
const { data, type } = event.data;
if (type === "PROCESS_CSV") {
// Heavy computation off the main thread
const result = parseAndAnalyzeCSV(data);
self.postMessage({ type: "RESULT", result });
}
});
// main.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
worker.postMessage({ type: "PROCESS_CSV", data: csvString });
worker.addEventListener("message", (event) => {
if (event.data.type === "RESULT") {
setAnalysis(event.data.result);
}
});
Performance Budget
Set budgets and enforce them in CI. Without enforcement, performance degrades over time.
// bundlesize configuration in package.json
{
"bundlesize": [
{ "path": "dist/assets/*.js", "maxSize": "200 kB" },
{ "path": "dist/assets/*.css", "maxSize": "50 kB" }
]
}
# Lighthouse CI in GitHub Actions
name: Performance Budget
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci && npm run build
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
configPath: ./lighthouserc.json
uploadArtifacts: true
// lighthouserc.json
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 1500 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["warn", { "maxNumericValue": 500000 }]
}
}
}
}
Quick Wins Checklist
If you're looking for the highest-impact changes with the least effort:
- Enable compression: Ensure your server sends gzip or Brotli-compressed responses
- Optimize images: Convert to WebP/AVIF, serve responsive sizes, lazy load below-the-fold images
- Remove unused JavaScript: Run your bundle analyzer, cut unnecessary dependencies
- Add proper cache headers: Content-hashed assets cached forever, HTML always revalidated
- Preconnect to critical origins: Fonts, CDNs, API servers
- Set explicit width/height on images: Prevents CLS
- Use
loading="lazy"on below-the-fold images: Native browser support, zero JavaScript - Defer non-critical JavaScript: Use
deferorasyncattributes on script tags - Use a CDN: Put your static assets on a CDN (Cloudflare, CloudFront, Fastly)
- Compress fonts: Use woff2 format, subset to only the characters you need
Summary
Web performance is about making deliberate choices, not applying every optimization blindly. Start by measuring with Core Web Vitals, identify your biggest bottleneck (usually JavaScript bundle size or unoptimized images), fix that first, and set up budgets to prevent regression. The tools are mature, the techniques are well-understood, and the impact on user experience and business metrics is real.