Patterns — Optimizing Web Performance
Contents
- LCP: hero image and critical path
- Images: responsive, modern, shift-free
- Fonts
- JavaScript: splitting and on-interaction loading
- Third-party scripts: facades and idle loading
- INP: breaking long tasks
- Measuring
- Gotchas
LCP: hero image and critical path
html
<head>
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" as="image" href="hero-1200.avif"
imagesrcset="hero-800.avif 800w, hero-1200.avif 1200w" imagesizes="100vw">
<style>/* inlined critical CSS: layout + above-the-fold only */</style>
<script src="/app.js" defer></script>
</head>
<body>
<img src="hero-1200.avif"
srcset="hero-800.avif 800w, hero-1200.avif 1200w" sizes="100vw"
width="1200" height="600" fetchpriority="high" alt="…">Images: responsive, modern, shift-free
html
<picture>
<source type="image/avif" srcset="chart-400.avif 400w, chart-800.avif 800w">
<source type="image/webp" srcset="chart-400.webp 400w, chart-800.webp 800w">
<img src="chart-800.jpg" srcset="chart-400.jpg 400w, chart-800.jpg 800w"
sizes="(max-width: 600px) 100vw, 50vw"
width="800" height="500" loading="lazy" decoding="async" alt="…">
</picture>width/height(or CSSaspect-ratio) on everyimg/video/iframe— CLS zero-cost insurance.loading="lazy"only below the fold; the browser handles the rest.
Fonts
html
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-var.woff2" crossorigin>css
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900; /* one variable file replaces 5 static weights */
font-display: swap; /* text visible immediately with fallback */
}
/* Reduce swap-induced shift: size-adjusted fallback */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%; /* match Inter's metrics; tune per family */
}Subset with pyftsubset (fonttools) to the scripts actually used — typically 30–100 KB → under 15 KB.
JavaScript: splitting and on-interaction loading
tsx
// Route-level splitting (React Router / Next.js does this per page by default)
const Settings = lazy(() => import('./Settings'));
// Widget on interaction — nothing loads until the user needs it
button.addEventListener('click', async () => {
const { openChart } = await import('./chart.js');
openChart(data);
}, { once: true });bash
# Find what's actually in the bundle before removing anything
npx source-map-explorer dist/assets/*.jsThird-party scripts: facades and idle loading
html
<!-- Facade: static thumbnail replaces a 500 KB embed until clicked -->
<button class="yt-facade" data-id="VIDEO_ID"
style="background: url('https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg')">▶</button>
<script>
document.querySelector('.yt-facade').addEventListener('click', (e) => {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube.com/embed/${e.currentTarget.dataset.id}?autoplay=1`;
iframe.width = 560; iframe.height = 315; iframe.allow = 'autoplay';
e.currentTarget.replaceWith(iframe);
}, { once: true });
</script>
<!-- Analytics after everything else -->
<script>
addEventListener('load', () => {
requestIdleCallback(() => import('/analytics.js'));
});
</script>INP: breaking long tasks
js
// Yield to the main thread between chunks of work (>50 ms tasks block input)
async function processRows(rows) {
for (const chunk of chunks(rows, 200)) { // 200 rows ≈ stays under 50 ms
renderChunk(chunk);
await (scheduler.yield?.() ?? new Promise(r => setTimeout(r)));
}
}Pure computation (parsing, diffing, search indexing) → Web Worker; the main thread only renders.
Measuring
bash
npx lighthouse https://example.com --preset=perf --form-factor=mobile --viewjs
// Field data from real users
import { onLCP, onINP, onCLS } from 'web-vitals';
[onLCP, onINP, onCLS].forEach(fn => fn(m => navigator.sendBeacon('/vitals', JSON.stringify(m))));Gotchas
- Lazy-loading the LCP image is the single most common self-inflicted LCP
regression — audit every
loading="lazy"above the fold. preloadoveruse starves the network of bandwidth for actual critical resources — preload at most the LCP image and one font.display: nonefonts still download if declared in CSS — subset instead.- CLS from late-loading banners/toolbars: reserve the slot with
min-heighteven when content is conditional. - Debounce vs INP: debouncing helps continuous input, but a slow handler needs chunking (see INP section) — debounce doesn't shorten the task.
- Bundle analyzer lies about tree-shaking until you build in production mode — always analyze the production build.
- CDN cache misses dominate TTFB for global users — check
cf-cache-status/x-cacheheaders before optimizing the payload.