../

CSS, images & fonts

The static assets that decide first paint, LCP and layout stability: render-blocking CSS, images, video, embeds and web fonts. Image markup is covered in depth on Media & embeds; this sheet is the performance view across all asset types.

Critical & render-blocking CSS

All CSS in <head> blocks rendering until it's downloaded and parsed (see Render-blocking vs parser-blocking).

TechniqueHowWhen
Keep CSS smallremove unused rules (Coverage tab), utility CSS (Tailwind purges by default)always
Inline critical CSSthe rules for the first viewport in a <style> in <head>big stylesheets, slow TTFB for CSS
Load the rest non-blocking<link rel="preload" as="style" onload="this.rel='stylesheet'">, or media="print" onload="this.media='all'"after inlining critical CSS
Split by media<link media="(min-width: 60em)"> for desktop-only CSSthe non-matching sheet downloads at low priority, doesn't block
Per-route CSSthe framework splits CSS per page/componentCSS-in-JS runtimes add JS cost: prefer zero-runtime (CSS Modules, Tailwind, vanilla-extract)
No @import<link> each file, or bundle@import chains a request behind another

Inlined CSS isn't cached separately; inline only a few KB (roughly what fits in the first 14 KB of HTML with the head), and keep the full stylesheet cached.

What CSS costs at runtime

MattersRarely matters
number of elements restyled per changeselector complexity for normal sites
huge DOMs (> ~1,500 nodes, deep nesting)the * selector in a reset
expensive paints: large box-shadow, filter: blur(), backdrop-filter over big areasordinary gradients and borders
animating layout properties:has() in moderation (engines optimize common cases)
many layers from will-changeCSS variables (cheap; changing one high up restyles its subtree)

Images

LeverDoSaves
FormatAVIF → WebP → JPEG fallback via <picture>, or Accept-based negotiation at the CDN30–50% vs JPEG
Sizesrcset + sizes so phones get phone-sized filesoften 3–5× the bytes on mobile
Compressionquality 50–75 for AVIF/WebP photos; SVG for icons and logoslarge, invisible
Dimensionswidth + height (or aspect-ratio) on every imageCLS
Lazy loadloading="lazy" below the fold onlybandwidth, early contention
Priorityfetchpriority="high" on the LCP image, never lazy on itLCP
Decodingdecoding="async" on non-critical imagesmain-thread jank on big decodes
Deliveryan image CDN or next/image resizes and converts on demandbuild time and effort
FormatBest forSupport
AVIFphotos, gradients; best compressionBaseline (all modern browsers)
WebPphotos with alpha, animationsBaseline
JPEG / PNGfallback; PNG for pixel artuniversal
SVGicons, logos, diagramsuniversal; run through SVGO
JPEG XLhigh-quality photosSafari, and Chrome from 155; not a safe default yet
GIFnothing: use <video> (MP4/WebM) for animationuniversal but huge
The LCP hero image
<img
  src="/img/hero-1200.jpg"
  srcset="/img/hero-600.avif 600w,
          /img/hero-1200.avif 1200w,
          /img/hero-2000.avif 2000w"
  sizes="100vw"
  width="1200" height="675"
  alt="Harbor at dawn"
  fetchpriority="high">

(AVIF in srcset with a JPEG src works only where every browser you target decodes AVIF; otherwise use <picture> with <source type="image/avif">.)

Video & embeds

AssetDo
Background/autoplay videomuted playsinline loop, a poster, small MP4 (H.264/AV1); autoplay overrides preload, so below the fold start it with play() from an IntersectionObserver instead
Click-to-play videopreload="none" (or "metadata") and a poster
Animated GIFconvert to a <video> (5–20× smaller)
YouTube/Vimeo embedsa facade: thumbnail + play button, load the iframe on click; youtube-nocookie.com
Maps, chat, social embedsfacade or loading="lazy" on the <iframe>
Any <iframe>width/height or aspect-ratio to avoid CLS; loading="lazy" below the fold

Web fonts

Fonts block text rendering while they load, then can reflow text when they swap in (CLS).

TechniqueHow
WOFF2 onlyevery current browser supports it; drop WOFF/TTF/EOT
Self-hostsame origin: no extra connection, your cache headers; next/font does this
Subsetonly the characters you use (unicode-range, glyphhanger, pyftsubset)
Variable fontsone file for all weights, instead of 4–6 static files
Fewer families and weights2 families × 2–3 weights is plenty
Preload the critical one or two<link rel="preload" as="font" type="font/woff2" crossorigin>
font-displaysee below
Match the fallback's metricssize-adjust (everywhere), ascent-override, descent-override, line-gap-override (Chromium, Firefox; not Safari)
System font stackzero bytes: font-family: system-ui, sans-serif
font-displayBlock periodSwap periodUse
autobrowser's choice (usually like block)—avoid
blockshort (~3 s) invisible textinfiniteicon fonts only
swapextremely small (≤ 100 ms)infinitebrand/headline fonts; pair with metric overrides to stop CLS
fallbackextremely smallshort (~3 s), then keep the fallbackbody text
optionalextremely smallnone: use it only if it's already therebest for CWV: no swap, no shift; font appears from the next page

The periods are the CSS Fonts spec's recommendations ("extremely small" ≤ 100 ms, "short" ≈ 3 s); each browser picks its exact values.

SVG & icons

  • Inline SVG for a few icons used once; an SVG sprite (<use href="/icons.svg#x">) for many icons across pages (cached once).
  • Icon fonts are worse: extra font request, block text, accessibility issues.
  • Optimize with SVGO (bunx svgo): editors leave metadata that doubles file size.
  • Huge inline SVGs (maps, charts with thousands of paths) bloat HTML and the DOM; use <img src="x.svg"> when you don't need to style the inside.

Common mistakes

MistakeFix
loading="lazy" on the heroeager, with fetchpriority="high"
One 2,400 px image for every screensrcset/sizes or an image CDN
No width/height on imagesalways set them; CSS height: auto keeps it responsive
8 font files from a third-party CDNself-host 1–2 variable WOFF2 files
font-display: block on body textswap with metric overrides, or optional
GIFs for animations<video autoplay muted loop playsinline>
Preloading a font the first view doesn't usepreload only fonts used above the fold
A 300 KB CSS framework for a few componentsutility CSS with purging, or only the parts you use

Recipes

Self-hosted variable font, preloaded

One file, all weights, in a font face that swaps.

head.html
<link rel="preload" href="/fonts/inter-var.woff2"
      as="font" type="font/woff2" crossorigin>
<style>
  @font-face {
    font-family: "Inter";
    src: url("/fonts/inter-var.woff2") format("woff2");
    font-weight: 100 900;
    font-display: swap;
  }
  body {
    font-family: "Inter", "Inter Fallback", sans-serif;
  }
</style>

A metric-matched fallback font

Resize a local font to take up the same space as the web font, so the swap doesn't move text. Tools like Fontaine or next/font compute these numbers.

fallback.css
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

Load non-critical CSS without blocking

Critical rules inline, the rest after first render (with a no-JS fallback).

head.html
<style>/* critical, above-the-fold rules */</style>
<link rel="preload" href="/css/site.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">
<noscript>
  <link rel="stylesheet" href="/css/site.css">
</noscript>

Under a strict nonce-based CSP, inline onload= attributes are blocked. Load the stylesheet from a nonced script instead, or allow the handler's hash with 'unsafe-hashes'.

GIF → video

Same look, a fraction of the bytes.

Convert with ffmpeg
ffmpeg -i anim.gif -movflags +faststart -pix_fmt yuv420p \
  -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" anim.mp4
Use it
<video autoplay muted loop playsinline
       width="480" height="270" poster="/anim.jpg">
  <source src="/anim.mp4" type="video/mp4">
</video>

YouTube facade

A self-hosted thumbnail that becomes the player on click: nothing from YouTube (or Google's i.ytimg.com image host) loads until then.

yt-facade.html
<button class="yt" data-id="dQw4w9WgXcQ"
        style="aspect-ratio: 16/9; width: 100%">
  <img src="/img/video-thumb.jpg"
       alt="Play: video title" loading="lazy">
</button>
<script type="module">
  document.querySelector(".yt").addEventListener("click",
    (e) => {
      const btn = e.currentTarget;
      const f = document.createElement("iframe");
      f.src = `https://www.youtube-nocookie.com/embed/${
        btn.dataset.id}?autoplay=1`;
      f.allow = "autoplay; encrypted-media";
      f.allowFullscreen = true;
      f.title = "Video title";
      f.style.cssText =
        "width:100%;aspect-ratio:16/9;border:0";
      btn.replaceWith(f);
    }, { once: true });
</script>

References