Core Web Vitals, lazy loading, image optimization, and caching strategies I keep coming back to.
Measures the time until the largest visible element (image, heading, text block) finishes rendering. Target: under 2.5 seconds.
Common culprits for a slow LCP: render-blocking CSS/JS, slow server response (TTFB), unoptimized hero images, and client-side rendering delays.
Replaced FID in March 2024. INP measures the latency of all interactions during a page visit and reports the worst (or near-worst) one. Target: under 200 ms.
To improve INP: break up long tasks with setTimeout or scheduler.yield(), reduce main-thread JS, and avoid layout thrashing.
Tracks unexpected layout shifts during the page lifecycle. Target: under 0.1. Always set explicit width and height on images/videos, reserve space for ads/embeds, and avoid injecting content above the fold after load.
Use any of these to audit your vitals:
pagespeed.web.dev) — combines lab + field data from CrUXimport { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
WebP is ~25-30% smaller than JPEG at comparable quality. AVIF pushes that to ~50% but encoding is slower. Use the <picture> element to serve the best format each browser supports:
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero banner"
width="1200" height="600"
loading="lazy">
</picture>
Add loading="lazy" to any image below the fold. Do not lazy-load the LCP image — that hurts your LCP score. For the hero image, use fetchpriority="high" instead:
<!-- Hero / LCP image: load eagerly with high priority -->
<img src="hero.webp" alt="Hero" fetchpriority="high"
width="1200" height="600">
<!-- Below-the-fold images: lazy load -->
<img src="card.webp" alt="Card" loading="lazy"
width="400" height="300">
Let the browser pick the right resolution for the viewport:
<img srcset="photo-400.webp 400w,
photo-800.webp 800w,
photo-1200.webp 1200w"
sizes="(max-width: 600px) 100vw,
(max-width: 1000px) 50vw,
33vw"
src="photo-800.webp"
alt="Responsive photo"
width="1200" height="800">
Always include width and height attributes so the browser can calculate the aspect ratio before the image loads. Combine with CSS:
img {
max-width: 100%;
height: auto;
}
The most important HTTP caching header. Common directives:
max-age=31536000, immutable — for fingerprinted assets (e.g. app.a1b2c3.js). Cache for one year; immutable tells the browser to skip revalidation.no-cache — the browser must revalidate with the server before using the cached copy. Good for HTML pages.no-store — never cache at all. Use for sensitive data.# Nginx example
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
add_header Cache-Control "no-cache";
add_header ETag "";
}
ETags let the server return 304 Not Modified when the resource hasn't changed, saving bandwidth. Most servers generate them automatically. Pair with Cache-Control: no-cache for HTML documents.
A service worker gives you fine-grained control over cache strategies. Common patterns:
// Stale-while-revalidate strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('v1').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;
})
);
});
Put your CDN in front and use content hashing in filenames so you can cache forever. Set short TTLs (or s-maxage) on HTML so the CDN revalidates frequently while static assets stay cached.
# Different TTLs for CDN vs browser
Cache-Control: public, max-age=60, s-maxage=3600
Prevents invisible text (FOIT) by showing a fallback font immediately, then swapping when the web font loads. This is almost always what you want:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
Preload the one or two fonts used above the fold so the browser fetches them early:
<link rel="preload" href="/fonts/inter-var.woff2"
as="font" type="font/woff2" crossorigin>
If you only need Latin characters, subset the font to drop unused glyphs. Tools like glyphhanger or fonttools can reduce a 200 KB font to under 20 KB.
# Subset with fonttools / pyftsubset
pyftsubset Inter.ttf \
--output-file=Inter-latin.woff2 \
--flavor=woff2 \
--layout-features='kern,liga' \
--unicodes=U+0000-007F,U+00A0-00FF
font-display: swap.FOUT is usually the better trade-off. Minimize the swap disruption by choosing a fallback with similar metrics:
/* Adjusted fallback to reduce layout shift on swap */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
Skip web fonts entirely for maximum speed. A modern system font stack:
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
Split your bundle so users only download the code they need. Use dynamic import() to create separate chunks:
// Load a heavy module only when the user needs it
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});
Each route becomes its own chunk, loaded on navigation:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Tree shaking removes unused exports from your final bundle. It works automatically in webpack (production mode) and Rollup/Vite, but only with ES module syntax (import/export).
Tips to make tree shaking effective:
package.json:// package.json
{
"name": "my-lib",
"sideEffects": false
}
// Or mark specific files that DO have side effects:
{
"sideEffects": ["./src/polyfills.js", "*.css"]
}
Use webpack-bundle-analyzer to visualize what's in your bundle and find heavy dependencies:
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // generates an HTML report
openAnalyzer: false,
}),
],
};
For Vite projects, use rollup-plugin-visualizer instead:
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true }),
],
};