01 — Font CLS: kill the swap shift, keep the brand

When the browser finds an @font-face rule it doesn't ship the font in your HTML. It downloads the file, then decides whether to paint your fallback now, your custom font later, or both, in some sequence. Every “decision” is a chance to shift the layout — because a different font is, by definition, a different shape.

font-display is the dial. Most teams ship swap, which is the right answer for FOIT (Flash of Invisible Text) but the wrong answer for CLS — the moment your custom font arrives, every paragraph nudges down by a pixel or two. Multiply by ten paragraphs and you've burned through your CLS budget for a logo.

The five font-display values and what they actually do:

  • auto (no descriptor) — the browser default. The UA picks a strategy; in practice Chrome/Firefox/Safari treat it as block-like (~3s invisible block, then swap whenever the font arrives). Worst FOIT, and the eventual swap still shifts layout.
  • block — ~3s block period, then infinite swap. Same FOIT, eventual swap, large CLS.
  • swap — 0s block, infinite swap. Fallback paints immediately, custom font replaces it whenever it arrives. Large CLS unless metrics match.
  • fallback100ms block, ~3s swap window. If the font misses the window, you stay on fallback for this page view. Smaller CLS, sometimes no swap at all.
  • optional100ms block, then the browser may skip the custom font entirely on slow connections (the font still downloads in the background and caches for the next visit). Zero CLS, but no guaranteed custom font on first paint.

The post-2023 best practice has a different shape. Keep swap (you still want readable text immediately) and fix the metrics so the fallback occupies the same line-box as the custom font. When the swap happens, nothing moves:

CSS
1@font-face {
2 font-family: "Inter Fallback";
3 src: local("Arial");
4 size-adjust: 107.4%;
5 ascent-override: 90%;
6 descent-override: 22%;
7 line-gap-override: 0%;
8}
9
10body { font-family: "Inter", "Inter Fallback", sans-serif; }

Those four descriptors are doing the real work. size-adjust scales the fallback so an m in Arial fills the same width as an m in Inter. The three -override properties pin the baseline and line height. The fallback now occupies the exact same box. The swap becomes invisible.

Tools that compute these values for you: Fontaine, Capsize, and the Next.js next/font loader (which inlines them automatically using the font's actual metrics). In the wild, Next emits a fallback face like --font-inter-fallback with size-adjust: 107.4%, ascent-override: 90.2%, descent-override: 22.48%, line-gap-override: 0% — same shape as the snippet above, just generated from the font file at build time.

A few other techniques in the same toolbox:

  • unicode-range descriptors break a font into glyph ranges, downloaded only when needed. The first paint ships Latin-only (28KB) and lazy-loads Cyrillic on demand:
    CSS
    1@font-face {
    2 font-family: "Inter";
    3 src: url("/inter-latin.woff2") format("woff2");
    4 unicode-range: U+0000-00FF, U+0131, U+0152-0153;
    5}
    6@font-face {
    7 font-family: "Inter";
    8 src: url("/inter-cyrillic.woff2") format("woff2");
    9 unicode-range: U+0400-045F;
    10}
  • Variable fonts consolidate weight axes into one file. One 32KB file replaces Regular + Medium + Bold + Italic.
  • <link rel="preload"> in <head> removes the CSS-discovery delay. The crossorigin attribute is not optional — without it the browser would refetch the font when CSS finally references it, defeating the preload entirely. Even same-origin fonts need it because fonts are always fetched with anonymous CORS:
    HTML
    1<link rel="preload"
    2 href="/fonts/Inter-Variable.woff2"
    3 as="font"
    4 type="font/woff2"
    5 crossorigin />

Browser-support note: size-adjust and the metric overrides have been Baseline since 2023 (Chrome/Edge 92+, Firefox 89+, Safari 16.4+). For older Safari, the fallback gracefully degrades to a normal swap — slightly worse CLS, never broken.

The visualization on the right replays the same paragraph load under each font-display value. Watch the CLS counter and the heading position as you flip between strategies.

02 — Video: the decision changes with fold position

Video is the heaviest asset on the web and the most contextual. There is no single right answer — the cost of every strategy depends on where the video sits and whether the user will actually press play.

Three options worth knowing:

Raw <video src="…" autoplay>8.5MB starts downloading before HTML parsing finishes. Bandwidth contention pushes your LCP image back by 23 seconds on a throttled 4G connection. Avoid unless the video is the entire above-fold experience, and even then, pair it with preload="metadata" and a poster.

<video poster="…" preload="metadata"> — the poster JPEG (45KB) paints fast and the metadata fetch (33KB) lets the browser show duration + dimensions. The full stream waits for play(). This is the sane default for self-hosted video the reader is likely to watch.

HTML
1<video
2 poster="hero.jpg"
3 preload="metadata"
4 controls
5 playsinline
6 muted>
7 <source src="hero.av1.mp4" type="video/mp4; codecs=av01" />
8 <source src="hero.h264.mp4" type="video/mp4" />
9</video>

The two <source> rows are an AV1 H.264 fallback. AV1 ships ~30% smaller than H.264 at equivalent quality; Safari 18 added decode in 2024 so it's now Baseline-friendly. The H.264 row covers everything else.

YouTube/Vimeo facade — the embed itself is ~1.2MB across 14 requests, mostly the player JS and CSS. A facade replaces it with a static poster and ~3KB of upgrade JS that swaps in the real IFrame on click. Use lite-youtube-embed for YouTube, lite-vimeo for Vimeo.

HTML
1<lite-youtube
2 videoid="dQw4w9WgXcQ"
3 playlabel="Play product demo"
4></lite-youtube>

The preload attribute on <video> gives you three useful settings:

ValueNetwork on first paintWhen to use
noneZero bytes — only fetched on play()Below-fold video, unlikely to be watched
metadataPoster + ~33 KB of stream metadataLikely-watched video (above the fold)
autoBrowser may pre-buffer the full streamHero video that is the LCP element

The right answer flips on fold position. Above the fold, the reader almost certainly watches — pay the metadata cost so playback is instant. Below the fold, most readers never reach it — the facade gets you to zero until the click is actually made.

Two further levers that pay everywhere:

  • preload="none" disables all preloading. The reader pays a slightly bigger stall on click (full handshake + range request) but the page weight on first paint is just the poster image. Use for video that is unlikely to be watched.
  • HLS / DASH adaptive bitrate (via hls.js or shaka-player) lets the browser pick a quality variant matching connection speed. Worth the extra integration cost when you're hosting > 5 min of content.

Toggle the fold position in the panel on the right to see how the cost-per-strategy comparison flips.

Two caveats worth pinning down. Adaptive bitrate is overkill for short content (< 5 min) or single-bitrate sources — the manifest + segmenter overhead exceeds the savings, and a plain <video preload="metadata"> ships fewer bytes. playsinline + muted together are what unlock iOS autoplay: Mobile Safari only autoplays videos that are both inline (no fullscreen takeover) and muted (no surprise audio). Drop either attribute and iOS will refuse to autoplay; the user has to tap.

03 — Third-party scripts: the audit, not the toggle

Third-party scripts are the #1 cause of production performance regressions, per the HTTP Archive's Web Almanac 2024. The median page ships 15 third-party scripts and spends ~640 ms on third-party JS execution before becoming interactive. None of that work is yours; all of it counts against your INP.

The four loading modes you have to choose between:

HTML
1<!-- Synchronous: parser stalls until the script runs. Default. -->
2<script src="analytics.js"></script>
3
4<!-- async: downloads in parallel, runs whenever it arrives, mid-parse. -->
5<script src="analytics.js" async></script>
6
7<!-- defer: downloads in parallel, runs after DOMContentLoaded in source order. -->
8<script src="analytics.js" defer></script>
9
10<!-- type="module": defer-equivalent by default, also enables import/export. -->
11<script src="analytics.js" type="module"></script>

async and defer look similar but differ in two important ways: execution order (async fires whenever it lands; defer preserves source order) and timing relative to DOMContentLoaded (async fires whenever; defer fires before DOMContentLoaded but after parsing is done). type="module" is defer-by-default and also gives you import/export. For external scripts you don't control, defer is the boring safe answer.

The audit-killer for most teams is Partytown — a tiny library from the Builder.io team that runs third-party scripts inside a Web Worker. The main thread sees a few milliseconds of proxy overhead; the actual analytics/chat/ad code executes off the main thread and never touches your INP.

HTML
1<!-- Boot Partytown -->
2<script>
3 partytown = { forward: ["dataLayer.push", "gtag"] };
4</script>
5<script src="/~partytown/partytown.js"></script>
6
7<!-- The script that used to cost 220ms on main now costs ~8ms -->
8<script type="text/partytown"
9 src="https://www.googletagmanager.com/gtag/js?id=G-…"></script>

The forward array tells Partytown which global functions on the page should be proxied into the Worker. GA4's dataLayer.push and gtag are the canonical examples. Once the script is in the Worker, the page calls gtag('event', …) normally — Partytown transparently forwards it.

The trap. Not every script is safe to move. Anything that mutates the DOM before first paint must stay on the main thread, because the postMessage round-trip to a Worker takes a paint frame. The classic case is an A/B testing SDK (Optimizely, LaunchDarkly's flag SDK, VWO) — if the SDK swaps copy in a Worker, the control variant paints first and then “flashes” to the variant.

Rule of thumb: anything that sends beacons, lazy-boots a UI, or runs timers can move to Partytown. Anything that mutates DOM synchronously before paint stays on main.

A few security knobs you should set on every third-party <script> you don't host yourself:

HTML
1<script
2 src="https://cdn.example.com/widget.js"
3 integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
4 crossorigin="anonymous"
5 referrerpolicy="strict-origin-when-cross-origin"
6></script>
  • integrity (Subresource Integrity / SRI) — the browser checks the downloaded bytes hash against the expected value. If a CDN is compromised and ships malicious bytes, the script doesn't execute.
  • crossorigin="anonymous" — required for SRI to work and prevents cookies leaking on the script fetch.
  • referrerpolicy — controls what URL the third-party server sees. strict-origin-when-cross-origin is the safest default.
  • CSP + Trusted Types — the next layer, but they need a coordinated rollout; the audit above is the prerequisite.

One more modern lever worth knowing about: the Speculation Rules API (Chrome 121+) replaces the old <link rel="prerender">. It lets you describe which links the browser should speculatively prerender (full DOM construction in a hidden tab) or just prefetch (download the route chunk):

HTML
1<script type="speculationrules">
2{
3 "prerender": [{
4 "where": { "href_matches": "/products/*" },
5 "eagerness": "moderate"
6 }]
7}
8</script>

Prerendering can cut click-to-paint latency to near-zero on a matching navigation. It's expensive (each prerendered page is a full browser context), so the where clause and eagerness settings let you tune for the routes most likely to be visited. Falls back gracefully — Safari and Firefox still see the regular link, just without the speedup.

The lab on the right walks you through these scripts one at a time — each with its blocking cost, Partytown-safety verdict, and the trade-offs of the four loading modes. Watch the main-thread blocking gauge as you change the mix.

This is also the lesson that connects directly to perf-cwv. Third-party scripts are the dominant source of INP regressions in production. Cleaning up the audit you build here will move your CWV gauge more than any other change in this module.

FnVd3P

Font CLS — the swap and its shift

Live previewSame paragraph, replayed under each font-display value.
font-display: swap

Performance Matters

Performance Matters. A 200 KB font that arrives 600 ms late will push every paragraph down two pixels — small enough to ignore, large enough to fail CLS.

CLS0.00Phasefallback rendering
swap0ms block, infinite swap. Fallback renders immediately and is replaced as soon as the custom font arrives. The shift on swap is what produces CLS.
Font state
Strategy:"swap"
CLS:"0.00"
Font:"system"
Swapped:false