Image and Font Optimization
TL;DR
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 Q&A
Q: How do you serve the right image to the right device?
A: <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.
Q: AVIF vs WebP vs JPEG vs PNG — when each?
A:
| 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.
Q: What is fetchpriority?
A: 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).
Q: Native lazy loading.
A: 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".
Q: How do you prevent CLS from images?
A: 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.
Q: LQIP / Blurhash / ThumbHash placeholders.
A: 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.
Q: When to use <img> vs CSS background-image?
A:
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 Q&A
Q: What’s FOIT and FOUT?
A:
- 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.
Q: How do you prevent CLS during font swap?
A: 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.
Q: Preload critical fonts.
A:
<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).
Q: WOFF2 vs WOFF vs TTF — what to ship.
A: 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.
Q: Variable fonts.
A: 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.
Q: Self-host vs Google Fonts CDN?
A:
| 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 / edge cases
- 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.
What a senior is expected to say
- “AVIF with WebP fallback with JPEG fallback via
<picture>.srcset+sizesfor responsive.width+height(or aspect-ratio) on every image to prevent CLS.” - “Hero/LCP image: eager +
fetchpriority='high'. Everything else:loading='lazy'. Don’t lazy-load the LCP image — it’s a regression.” - “Blurhash/ThumbHash for instant preview while real image loads — great for feeds and galleries.”
- “
font-display: swapfor the body font with metric-matched fallback (Fontaine / next/font) — zero CLS on font swap, no FOIT.” - “Preload the one primary font, no more. Self-host fonts for privacy + perf, WOFF2 only.”
- “Variable font when you use 3+ weights; otherwise static weight files.”
Cross-references
- Core Web Vitals (LCP/CLS are most-affected): 01_core_web_vitals.md
- Image gallery design example: ../14_frontend_system_design/03_image_gallery.md
- Resource hints (preload, preconnect): 08_resource_hints.md
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/