Web Performance Optimization Techniques
The page feels fine on your laptop over office Wi-Fi. Then someone opens it on a mid-range Android phone over 4G, stares at a blank screen while a 3 MB hero image and a pile of JavaScript download, and leaves. Search Console starts flagging "Poor URLs" for LCP, and nobody on the team is quite sure where to start.
This post is the order I'd work through it in: what Core Web Vitals actually measure, then images, JavaScript, CSS, caching, network tweaks, and the tools to check whether any of it helped.
- What Core Web Vitals measure and how to collect them
- Image formats, responsive images and lazy loading
- How to load JavaScript and CSS without blocking rendering
- Caching headers and service workers for repeat visits
- Server-side optimizations and CDN configuration
- Tools for measuring in the lab and in production
Understanding Core Web Vitals
Before changing anything, you need to know which number you're trying to move. Google boils page experience down to three metrics: how fast the main content shows up, how quickly the page reacts to input, and how much the layout jumps around. They've been a ranking signal since 2021, though in practice they matter more for your users than for your position in search results.
Core Web Vitals and their "good", "needs improvement" and "poor" thresholds
LCP - Largest Contentful Paint
Time until the largest element (image, video, or text block) in the viewport becomes visible. Target: under 2.5 seconds.
INP - Interaction to Next Paint
Measures how quickly the page responds to user interactions. Replaced FID in March 2024. Target: under 200ms.
CLS - Cumulative Layout Shift
Quantifies unexpected layout shifts during the page lifecycle. Target: under 0.1 for a stable visual experience.
FCP - First Contentful Paint
Time until the first piece of content renders. Not a Core Web Vital, but a slow FCP usually means a slow LCP too. Target: under 1.8 seconds.
Image Optimization
On most content sites, images are the heaviest thing on the page, often by a wide margin. That makes them the first place I look, because fixing them rarely requires touching application code.
Choose the Right Format
Picking an image format by content type
| Format | Best For | Compression | Browser Support |
|---|---|---|---|
| AVIF | Photos, complex images | Often around half the size of JPEG | Chrome, Firefox, Safari 16+ |
| WebP | Photos, graphics | Typically 25-35% smaller than JPEG | All modern browsers |
| SVG | Icons, logos, illustrations | Infinitely scalable | Universal |
| JPEG | Fallback for photos | Good, lossy | Universal |
| PNG | Transparency, screenshots | Larger files | Universal |
Implement Responsive Images
A phone that displays an image at 400px wide has no use for the 2000px original. Give the browser a few sizes and let it pick:
<!-- Using srcset for responsive images -->
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
800px"
alt="Hero image"
loading="lazy"
decoding="async"
/>
<!-- Modern formats with fallback -->
<picture>
<source srcset="image.avif" type="image/avif"/>
<source srcset="image.webp" type="image/webp"/>
<img src="image.jpg" alt="Description" loading="lazy"/>
</picture>
Add loading="lazy" to images below the fold so they only load as they approach the viewport. The mistake I see a lot is slapping it on every image, including the hero. That delays the LCP element, and Lighthouse will call it out with "Largest Contentful Paint image was lazily loaded". For the LCP image, use loading="eager" (or just leave the attribute off) plus fetchpriority="high".
JavaScript Performance
A kilobyte of JavaScript costs more than a kilobyte of image. The image only has to be downloaded and decoded; the script has to be downloaded, parsed, compiled and executed, usually on the main thread, which is also where clicks get handled. That's why heavy bundles hurt INP as much as load time.
Code Splitting
Split your JavaScript bundle into smaller chunks loaded on demand
Tree Shaking
Let the bundler drop unused exports (works best with ES modules and side-effect-free packages)
Defer Non-Critical JS
Use async/defer attributes to prevent render blocking
Loading Strategies
<script src="app.js"></script>
Blocks HTML parsing while downloading and executing. Avoid for non-critical scripts.
<script src="analytics.js" async></script>
Downloads in parallel, executes immediately when ready. Good for independent scripts like analytics.
<script src="app.js" defer></script>
Downloads in parallel, executes after HTML parsing. Best for scripts that need the DOM.
<script type="module" src="app.js"></script>
Module scripts are deferred by default, so you don't need to add defer. Add async if you want one to run as soon as it's ready.
Code Splitting with Dynamic Imports
// Instead of importing everything upfront
// import { heavyModule } from './heavy-module';
// Load only when needed (dynamic import)
async function loadFeature() {
const { heavyModule } = await import('./heavy-module');
heavyModule.init();
}
// React lazy loading example
const Dashboard = React.lazy(() => import('./Dashboard'));
// Route-based splitting (React Router)
const routes = [
{
path: '/dashboard',
element: (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
)
}
];
CSS Performance
The browser won't paint anything until it has downloaded and parsed every stylesheet in the <head>. So the goal is simple: make the CSS needed for the first screen tiny, and get everything else out of the way.
Inline Critical CSS
Embed above-the-fold CSS directly in the HTML <head> to eliminate render-blocking requests for critical content.
Defer Non-Critical CSS
Load below-the-fold styles asynchronously using the media="print" trick or loadCSS library.
Remove Unused CSS
Use PurgeCSS or the Coverage tab in DevTools to find dead styles. Old Bootstrap themes and years of accumulated overrides are the usual culprits. Tailwind v3+ only generates the classes it finds in your templates, but only if the content paths actually cover all your files.
Minify and Compress
Use cssnano for minification and enable Gzip/Brotli compression on your server.
<head>
<!-- Critical CSS inlined -->
<style>
/* Above-the-fold styles only */
body{margin:0;font-family:system-ui}
.header{background:#000;color:#fff;padding:20px}
.hero{min-height:60vh;display:grid;place-items:center}
</style>
<!-- Non-critical CSS loaded async -->
<link rel="preload" href="/css/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="/css/main.css">
</noscript>
</head>
Reading and writing DOM properties in a loop causes the browser to recalculate layout repeatedly. Do all your reads first, then all your writes. In the Performance panel this shows up as purple "Layout" blocks with a "Forced reflow" warning.
Caching Strategies
The fastest request is the one that never leaves the browser. Each layer below catches what the previous one missed, and each miss costs more time.
Multi-layer caching architecture: Browser → CDN → Server → Origin
Cache-Control Headers
# Static assets - cache for 1 year (immutable with hash)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
# HTML pages - revalidate each request
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
etag on;
}
# API responses - short cache with stale-while-revalidate
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
- nginx
add_headerinheritance: if alocationblock has anyadd_headerof its own, it drops everyadd_headerinherited from theserverblock. Your security headers (HSTS, CSP) silently disappear on exactly the routes where you added caching. Check withcurl -Iafter deploying. immutableonly works with hashed filenames: a one-year cache onapp.jsmeans users keep the old file after a deploy. Only use it for files likeapp.3f9a1c.jsthat your build tool renames whenever the content changes.
Service Worker Caching
A service worker sits between the page and the network, so you decide per request whether to answer from cache. It also keeps the site usable offline. Pick one strategy per route; the two handlers below are alternatives, not something to register together:
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/css/main.css',
'/js/app.js',
'/images/logo.svg'
];
// Cache-First Strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});
// Stale-While-Revalidate Strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request);
const fetched = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetched;
})
);
});
Network Optimizations
Most of these are server or hosting settings rather than code changes, so they're cheap to try:
Enable Compression
Use Brotli (preferred) or Gzip for HTML, CSS, JS and JSON. Text compresses very well, so transfer sizes usually drop by more than half.
Preconnect to Origins
Use <link rel="preconnect"> for the two or three third-party origins you hit on every page, so DNS, TCP and TLS are done before the request is needed.
Use a CDN
Serve assets from edge locations close to users. The further your users are from your origin, the bigger the difference.
Enable HTTP/2 or HTTP/3
Many requests share one connection instead of queuing, which makes old tricks like domain sharding and sprite sheets unnecessary.
<head>
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
<!-- DNS prefetch for less critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<!-- Prefetch next page for likely navigation -->
<link rel="prefetch" href="/next-page.html">
</head>
Go easy on preload. Everything you preload competes with everything else for bandwidth, and if a preloaded file isn't used soon after load, Chrome logs "The resource ... was preloaded using link preload but not used within a few seconds". Fonts are the classic trap: leave off crossorigin and the browser downloads the font twice.
Performance Measurement Tools
Lab tools like Lighthouse run on one simulated device and are good for debugging. Field data comes from real visitors and is what Google actually uses. You want both, and when they disagree, trust the field data:
| Tool | Best For | Type |
|---|---|---|
| Lighthouse | Overall performance audits, Core Web Vitals | Lab data |
| Chrome DevTools | Network waterfall, JS profiling, rendering | Lab data |
| WebPageTest | Detailed waterfalls, filmstrip view, comparison | Lab data |
| PageSpeed Insights | Real user data + Lighthouse, Core Web Vitals | Field + Lab |
| Chrome UX Report | Real-world performance data at scale | Field data |
| Web Vitals JS | Real-time monitoring in production | Field data |
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics({ name, delta, id }) {
// Send to your analytics endpoint
navigator.sendBeacon('/analytics', JSON.stringify({
metric: name,
value: delta,
id: id,
page: window.location.pathname
}));
}
// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
Performance Checklist
I go through this list when auditing a site. Most of it can be checked in an afternoon:
Images
- Use modern formats (WebP, AVIF) with fallbacks
- Implement responsive images with srcset
- Add loading="lazy" to below-fold images
- Set explicit width and height attributes
- Compress images without visible quality loss
JavaScript
- Implement code splitting for routes/features
- Use async/defer for non-critical scripts
- Tree shake unused code in production builds
- Minimize third-party scripts
- Avoid long-running JavaScript tasks (>50ms)
CSS
- Inline critical above-the-fold CSS
- Remove unused CSS (PurgeCSS)
- Minify CSS in production
- Avoid CSS @import (use link instead)
- Use efficient selectors
Server & Network
- Enable Brotli/Gzip compression
- Configure proper cache headers
- Use a CDN for static assets
- Enable HTTP/2 or HTTP/3
- Preconnect to critical third-party origins
Where I'd Start
If you only have a day, these give the most back for the least effort:
Convert Images to WebP
Usually noticeably smaller than JPEG at similar visual quality
Add Width/Height to Images
Prevents CLS by reserving space before load
Defer Third-Party Scripts
Load analytics and widgets after critical content
Enable Brotli Compression
Smaller text assets than Gzip, and every modern browser supports it
Mistakes I See Most Often (and When Not to Bother)
Chasing a Lighthouse score of 100. Lighthouse runs one throttled load on one simulated device. If your field data in Search Console or CrUX is already "good" for all three metrics, squeezing out the last few lab points is rarely worth a week of work.
Optimizing before measuring. It's easy to spend days on a service worker when the real problem is an uncompressed 2 MB hero image or a chat widget blocking the main thread. Open the Network and Performance panels first and fix the biggest bar in the waterfall.
Adding a service worker you don't need. For a marketing site or blog, good Cache-Control headers and a CDN get you most of the benefit. A service worker adds a cache you have to version and invalidate, and a buggy one can keep serving stale pages until users clear site data. I'd only reach for one if you need offline support or have an app shell that really benefits.
Code splitting everything. Splitting a small app into dozens of tiny chunks can make things slower, since each chunk is another request and another waterfall step. Split by route and by heavy, rarely used features (editors, charts, maps), not by every component.
Fixing it once and never checking again. Performance regresses quietly: a new tracking script here, an unoptimized upload there. Send the web-vitals data to your analytics and look at it after big releases, or add a Lighthouse CI budget so a pull request fails when the bundle grows past a limit.