Website performance affects user experience, conversions, and search rankings. This guide covers the techniques and browser APIs that matter most when making a site fast, along with the tools to measure whether your changes actually helped.
Core Web Vitals
Core Web Vitals are Google's standardized metrics for loading, interactivity, and visual stability. Google's published thresholds are the targets worth designing against.
1. Largest Contentful Paint (LCP)
Target: under 2.5 seconds
LCP measures loading performance — specifically, when the largest content element in the viewport finishes rendering.
<!-- Preload critical resources -->
<link rel="preload" href="/hero-image.jpg" as="image" />
<link rel="preload" href="/critical-font.woff2" as="font" crossorigin />
<!-- Priority hints -->
<img src="/hero.jpg" fetchpriority="high" alt="Hero" />
<!-- Lazy load below-fold images -->
<img src="/footer.jpg" loading="lazy" alt="Footer" />
Optimization techniques:
- Serve static assets from a CDN
- Use modern image formats (WebP, AVIF)
- Remove render-blocking resources
- Render meaningful HTML on the server
- Set long-lived cache headers on immutable assets
2. Interaction to Next Paint (INP)
Target: under 200 milliseconds
First Input Delay (FID) was the original responsiveness metric, but Google retired it as a Core Web Vital in March 2024 in favour of Interaction to Next Paint (INP). FID only measured the delay before an event handler started; INP measures the full latency from interaction to the next rendered frame, across the whole page lifecycle. If your dashboards still track FID, move them to INP.
INP is dominated by long tasks blocking the main thread. Reduce them:
// Code splitting
const Dashboard = lazy(() => import('./Dashboard'));
// Break up long tasks so the browser can respond between chunks
function processLargeArray(array) {
const batchSize = 100;
let index = 0;
function processBatch() {
const batch = array.slice(index, index + batchSize);
batch.forEach(item => processItem(item));
index += batchSize;
if (index < array.length) {
// Scheduler API yields to higher-priority work
if ('scheduler' in window) {
scheduler.postTask(() => processBatch(), { priority: 'background' });
} else {
setTimeout(processBatch, 0);
}
}
}
processBatch();
}
Defer non-critical JavaScript so it never competes with user input:
<script defer src="/analytics.js"></script>
3. Cumulative Layout Shift (CLS)
Target: under 0.1
CLS measures visual stability. Most layout shifts come from content loading without reserved space:
/* Always set dimensions for images */
img {
width: 800px;
height: 600px;
aspect-ratio: 4 / 3;
}
/* Reserve space for dynamic content */
.ad-slot {
min-height: 250px;
}
/* Use font-display for web fonts */
@font-face {
font-family: 'CustomFont';
src: url('/font.woff2');
font-display: swap; /* or optional */
}
The three Core Web Vitals map to three different problems: LCP is a network and rendering problem, INP is a main-thread problem, and CLS is a layout problem. Diagnosing which one you're failing tells you which section of this guide to read first.
Image Optimization
Images are usually the largest bytes on a page and the most common LCP element, which makes them the highest-leverage thing to optimize.
Modern Image Formats
<picture>
<!-- AVIF: best compression, narrower support -->
<source srcset="/image.avif" type="image/avif" />
<!-- WebP: wide support, strong compression -->
<source srcset="/image.webp" type="image/webp" />
<!-- JPEG: fallback -->
<img src="/image.jpg" alt="Description" loading="lazy" decoding="async" />
</picture>
Responsive Images
Serving a 1200px image to a 400px viewport wastes bandwidth. srcset and sizes let the browser pick:
<img
src="/small.jpg"
srcset="
/small.jpg 400w,
/medium.jpg 800w,
/large.jpg 1200w
"
sizes="
(max-width: 400px) 100vw,
(max-width: 800px) 50vw,
33vw
"
alt="Responsive image"
loading="lazy"
/>
Framework-Level Image Optimization
Most frameworks ship an image component that handles formats, sizing, and lazy loading for you. Next.js is a representative example:
import Image from 'next/image';
function ProductImage() {
return (
<Image
src="/product.jpg"
alt="Product"
width={800}
height={600}
quality={85}
priority // For above-fold images — opts out of lazy loading
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
loading="lazy" on an above-the-fold hero image delays the very element LCP measures. Lazy loading is for below-the-fold content only — mark the hero with fetchpriority="high" or the framework equivalent instead.
JavaScript Optimization
Code Splitting
Ship only the JavaScript needed for the current view:
// Route-based splitting
const routes = [
{
path: '/',
component: lazy(() => import('./pages/Home')),
},
{
path: '/dashboard',
component: lazy(() => import('./pages/Dashboard')),
},
];
// Component-based splitting
function App() {
return (
<Suspense fallback={<Spinner />}>
<HeavyComponent />
</Suspense>
);
}
// Dynamic imports — load on interaction
button.addEventListener('click', async () => {
const module = await import('./heavy-feature.js');
module.initFeature();
});
Tree Shaking
// Bad — pulls in the entire library
import _ from 'lodash';
_.debounce(() => {}, 300);
// Good — import only what you need
import debounce from 'lodash/debounce';
debounce(() => {}, 300);
// Often better — small utilities don't need a dependency
const debounce = (fn, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};
Minification and Chunking
// vite.config.js
export default {
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['@headlessui/react', 'lucide-react'],
},
},
},
},
};
Caching Strategies
HTTP Caching
The rule of thumb: cache fingerprinted assets forever, never cache HTML.
// Express.js
app.use((req, res, next) => {
// Static assets — cache for 1 year
if (req.url.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
// HTML — always revalidate
if (req.url.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
}
next();
});
max-age=31536000, immutable tells browsers never to revalidate. That's only safe if the filename changes whenever the content changes (app.a3f9c2.js). Applying it to a stable filename like app.js means users can be stuck on a stale build for a year.
Service Worker Caching
// service-worker.js
const CACHE_NAME = 'v1';
const STATIC_ASSETS = [
'/',
'/styles.css',
'/app.js',
'/logo.png',
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
// Cache hit — return it
if (response) {
return response;
}
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then(response => {
if (!response || response.status !== 200) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
Edge Caching
// Cloudflare Worker
export default {
async fetch(request) {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
if (!response) {
response = await fetch(request);
if (response.ok) {
const headers = new Headers(response.headers);
headers.set('Cache-Control', 'public, max-age=3600');
response = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(cacheKey, response.clone());
}
}
return response;
},
};
Font Optimization
Web fonts are a common source of both LCP and CLS problems: they block text rendering, then shift the layout when they swap in.
Preload Critical Fonts
<link
rel="preload"
href="/fonts/Inter-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Font Display Strategy
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.woff2') format('woff2');
font-display: swap;
font-weight: 400;
font-style: normal;
}
/* Match fallback metrics to reduce shift when the web font swaps in */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
Variable Fonts
A single variable font file can replace separate files for each weight, cutting requests and bytes:
@font-face {
font-family: 'InterVariable';
src: url('/fonts/Inter-Variable.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
CSS Optimization
Critical CSS
Inline the styles needed for above-the-fold content, and load the rest without blocking render:
<style>/* inlined critical CSS */</style>
<link
rel="preload"
href="/styles.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles.css" />
</noscript>
Remove Unused CSS
// postcss.config.js
module.exports = {
plugins: [
require('@fullhuman/postcss-purgecss')({
content: ['./src/**/*.{js,jsx,ts,tsx}'],
safelist: ['active', 'hover'],
}),
],
};
Lazy Loading
Intersection Observer
For anything the native loading="lazy" attribute doesn't cover:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, {
rootMargin: '50px', // Start loading 50px before entering viewport
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
React Lazy Loading
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
);
}
Performance Monitoring
Lab tools like Lighthouse run on one machine under one network profile. Field data — real users on real devices — is what Google actually ranks on. Track both.
Web Vitals Tracking
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
// sendBeacon survives page unload; fetch with keepalive is the fallback
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', { body, method: 'POST', keepalive: true });
}
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Performance Budget
A budget turns performance from an aspiration into a build failure:
// webpack.config.js
module.exports = {
performance: {
maxAssetSize: 250000, // 250KB
maxEntrypointSize: 250000,
hints: 'error',
},
};
Lighthouse CI can assert thresholds on every pull request, so regressions get caught before they ship rather than after.
Performance Checklist
Images
- [ ] Modern formats (WebP, AVIF) with fallbacks
- [ ] Lazy loading below the fold only
- [ ] Responsive
srcset/sizes - [ ] Served via CDN
JavaScript
- [ ] Code splitting by route
- [ ] Tree shaking enabled
- [ ] Minification and compression
- [ ] Non-critical scripts deferred
CSS
- [ ] Critical CSS inlined
- [ ] Unused CSS removed
- [ ] Web fonts optimized
- [ ] CSS containment where applicable
Caching
- [ ] HTTP cache headers configured
- [ ] Fingerprinted assets marked immutable
- [ ] Service worker (if appropriate)
- [ ] CDN configured
Monitoring
- [ ] Core Web Vitals tracked in the field
- [ ] Performance budget enforced in CI
- [ ] Regular audits scheduled
Tools and Resources
Testing
- PageSpeed Insights — lab and field data side by side
- WebPageTest — detailed waterfalls and filmstrips
- Lighthouse CI — assert budgets in CI
Field data
- Chrome User Experience Report — real-user data for public sites
- web-vitals library — collect vitals from your own users
Reference
- Web.dev Performance
- MDN Web Performance
- Chrome DevTools Performance
- HTTP Archive — how the web as a whole is trending
Our Take
Performance work goes wrong when it starts with a list of techniques instead of a measurement. Every optimization here has a cost — complexity, build time, or cache-invalidation risk — and applying all of them to a site that was already fast is wasted effort.
Start by measuring which Core Web Vital you're failing in the field. Fix that one. Measure again. A site that reliably passes LCP, INP, and CLS on real user devices is doing better than one that has implemented every trick in this guide but has never checked.