Image and Font Optimization
Images and fonts are usually the dominant bytes on a page and the dominant causes of LCP/CLS bugs. The senior toolbox: <picture> + srcset + modern formats (AVIF/WebP) for responsive images, fetchpriority="high" + eager for the LCP image, loading="lazy" for below-fold, width/height/aspect-ratio to prevent CLS. For fonts: font-display: swap or optional, <link rel="preload" as="font"> for critical fonts, size-adjust/ascent-override to match metrics and prevent layout shift during font swap.
Image
How do you serve the right image to the right device?
<picture> + srcset + sizes:
<picture>
<source type="image/avif" srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w" sizes="(max-width: 600px) 100vw, 50vw" />
<source type="image/webp" srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w" sizes="(max-width: 600px) 100vw, 50vw" />
<img src="hero-800.jpg" width="800" height="600" alt="..." />
</picture>What each part does:
<source type="image/avif">— browsers that support AVIF pick this.srcset="...w"— list of candidate URLs with their intrinsic widths.sizes="..."— tells the browser how wide the image will render at each viewport; browser uses this + DPR to pick the rightsrcsetentry.<img>fallback — older browsers that don’t support<picture>use this.width/heighton<img>— sets the aspect ratio, prevents CLS.
AVIF vs WebP vs JPEG vs PNG — when each?
| Format | Best for | Notes |
|---|---|---|
| AVIF | photos, screenshots — smallest at quality | newer, still encode-slow but support is now broad |
| WebP | photos, screenshots — broad support | 25-35% smaller than JPEG at same quality |
| JPEG | photos — fallback | mature, universal support |
| PNG | UI screenshots with text, transparency | lossless, big |
| SVG | icons, logos, illustrations | scales infinitely, tiny |
The pattern: serve AVIF where supported, WebP as fallback, JPEG as final fallback (via <picture>). Use image CDNs (Cloudinary, Imgix, Cloudflare Images, AWS CloudFront + Lambda@Edge) to generate variants automatically rather than checking them into the repo.
What is fetchpriority?
Hints to the browser how to prioritize a fetch:
<!-- The hero image — highest priority -->
<img src="hero.avif" fetchpriority="high" alt="..." />
<!-- A below-fold image — low priority -->
<img src="other.avif" fetchpriority="low" loading="lazy" alt="..." />Default fetchpriority="auto" is fine for most images. high is the cheapest LCP win when the LCP image isn’t loading fast enough — it bumps the priority ahead of other resources the browser would otherwise prioritize (CSS, scripts).
Native lazy loading.
loading="lazy" on <img> and <iframe> defers loading until the element is near the viewport.
<img src="below-fold.avif" loading="lazy" decoding="async" alt="..." width="800" height="600" />decoding="async" decodes off the main thread (avoids blocking other work).
Critical caveat: don’t lazy-load the LCP image. Lazy-loading the very image you’re trying to show fast is a regression. Eager + fetchpriority="high".
How do you prevent CLS from images?
Always set the box dimensions before the image loads. Two ways:
<!-- Width/height attributes — browser calculates aspect ratio -->
<img src="..." width="800" height="600" alt="..." />
<!-- Or CSS aspect-ratio -->
<img src="..." style="aspect-ratio: 4/3; width: 100%;" alt="..." />The width/height attributes are dimensionless values used as a ratio; CSS controls the rendered size. Modern browsers compute the aspect ratio and reserve the box pre-load.
LQIP / Blurhash / ThumbHash placeholders.
Tiny representations of the image that decode instantly to a blurred preview while the real image loads.
function ProgressiveImage({ src, blurhash, ...rest }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: "relative", aspectRatio: rest.width / rest.height }}>
<div style={{ inset: 0, position: "absolute", backgroundImage: blurhashToDataUrl(blurhash) }} />
<img src={src} onLoad={() => setLoaded(true)} style={{ opacity: loaded ? 1 : 0, transition: "opacity .3s" }} {...rest} />
</div>
);
}Blurhash is the original; ThumbHash is smaller (better compression). Both are 20-50 byte strings. Use cases: feeds, galleries, anywhere images appear progressively.
When to use <img> vs CSS background-image?
Use <img> for |
Use background-image for |
|---|---|
| content (semantic, has meaning) | decoration |
anything you need alt for |
purely visual flourish |
| things needing lazy loading | hero banners (sometimes) |
things needing srcset/fetchpriority |
when you specifically want CSS layering |
<img> wins for almost everything practical. background-image is for visual styling that’s not content.
Font
What’s FOIT and FOUT?
- FOIT — Flash Of Invisible Text. Text waits for the web font to load; user sees nothing. Old default.
- FOUT — Flash Of Unstyled Text. Text renders in the fallback font immediately; web font swaps in once loaded. User sees text fast, but content “jumps” on swap.
You control which via font-display:
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
font-display: swap; /* FOUT — show fallback, swap when ready (default modern best practice) */
}font-display values:
auto— browser default (FOIT-like).block— short FOIT then swap (~3s timeout).swap— FOUT (text shows in fallback immediately).fallback— short FOIT, then fallback, then swap if font loads within ~3s.optional— short FOIT, then fallback; font loads only if cached. Most aggressive against CLS.
swap is the typical default. optional is the LCP/CLS-safest but lets users see your site without ever showing the brand font on first visit.
How do you prevent CLS during font swap?
Match the fallback font’s metrics to the web font:
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
font-display: swap;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* adjust to match Inter's x-height */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}Tools like Fontaine or next/font generate these @font-face rules automatically so the fallback matches the web font’s metrics. Result: text wraps the same with or without the web font → minimal CLS.
Preload critical fonts.
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />The browser fetches the font immediately, before discovering it through CSS parsing. Use sparingly — preloading too many fonts wastes bandwidth and competes with the LCP image. One preload for the primary body font is the typical case.
crossorigin is required for font preload (fonts are always CORS-loaded even from same origin).
WOFF2 vs WOFF vs TTF — what to ship.
Just WOFF2. Universally supported on modern browsers, best compression (~30% smaller than WOFF). Older fallbacks (WOFF, TTF) are no longer worth the bytes for >99% of audiences.
Variable fonts.
A single font file containing multiple weights/styles, parameterized at runtime:
@font-face {
font-family: "Inter Var";
src: url("/fonts/Inter-Var.woff2") format("woff2-variations");
font-weight: 100 900; /* whole range in one file */
font-display: swap;
}
.heading {
font-weight: 750; /* any value in range, not just 100/400/700 */
}Trade-off: one file (~150-250 KB) replaces N weight files (~30-50 KB each). Wins if you use 3+ weights, loses if you only use one. For most apps with regular + bold, regular static files win.
Self-host vs Google Fonts CDN?
| Self-hosted | Google Fonts CDN | |
|---|---|---|
| Privacy (GDPR) | yes — no third-party calls | no — sends user IP to Google |
| Performance | best — no extra DNS/TLS | extra connection to fonts.googleapis.com |
| Subset control | full | limited |
| Caching | your CDN | shared (rare hits across sites) |
Modern best practice: self-host. Download the font, subset to the characters you need, serve from your origin/CDN. Tools like Fontsource or next/font automate this.
Gotchas
- Hero image not preloaded — discovered late by the preload scanner. Add
<link rel="preload" as="image">orfetchpriority="high". background-imagedoesn’t fireloadevents the same way<img>does — harder to track.srcsetwithoutsizes— browser falls back to100vw, picks the largest. Always specifysizes.- Decorative SVG icons should be inlined or sprite’d, not 30 separate requests.
@font-faceinside a CSS file delays font discovery until the CSS parses; preload tag helps.- System font stacks (using
font-family: system-ui) are zero-byte and instant — consider for non-brand text (admin tools, internal apps). - Image dimensions wrong (
width=800 height=600when the actual is1600×1200) — sets the wrong aspect ratio; image displays correctly but CLS still happens on load.
Related
Further reading
- web.dev — Choose the right image format: https://web.dev/articles/choose-the-right-image-format
- web.dev —
fetchpriority: https://web.dev/articles/fetch-priority - web.dev — Avoiding layout shifts from web fonts: https://web.dev/articles/optimize-webfont-loading
- Fontaine: https://github.com/unjs/fontaine
- BlurHash: https://blurha.sh/ · ThumbHash: https://evanw.github.io/thumbhash/
Interview angle 6
- “How do you serve images well?” -
<picture>with an AVIF source, a WebP fallback and a JPEG last resort, plussrcsetandsizesso a phone does not download a desktop-width file. Andwidthandheightor anaspect-ratioon every image, or each one shifts the page as it loads. - “What is the exception to lazy loading?” - the LCP image. It should be
eager with
fetchpriority="high", becauseloading="lazy"defers exactly the resource the metric is timing. Lazy-loading the hero is a regression that looks like an optimisation in the diff. - “How do you avoid the blank-image flash in a feed?” - a blurhash or thumbhash placeholder. It is a handful of bytes carried with the payload and decodes instantly, so the cell shows a colour-accurate blur rather than an empty box.
- “How do you load fonts without layout shift?” -
font-display: swapso text is never invisible, plus a metric-matched fallback viasize-adjustor a tool that generates one. The swap then happens without moving anything, which is the partswapalone does not give you. - “How many fonts do you preload?” - one, the primary body face. Every preload competes for the same bandwidth, so preloading four fonts delays the one that matters. Self-host in WOFF2 only, which is both faster and avoids sending your users to a third party.
- “Variable font or static weights?” - variable once you genuinely use three or more weights, since one file beats three. Below that a variable font is usually larger than the static weights you actually need.