<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Antoine Debes — Writing</title>
    <link>https://antoinedebes.com/writing/</link>
    <atom:link href="https://antoinedebes.com/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Web performance and frontend architecture — Next.js, React, Core Web Vitals.</description>
    <language>en</language>
    <item>
      <title>How This Site Loads in Under One Second (and How You Can Verify It Right Now)</title>
      <link>https://antoinedebes.com/writing/how-this-site-loads-in-under-one-second/</link>
      <guid>https://antoinedebes.com/writing/how-this-site-loads-in-under-one-second/</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <description>An annotated walkthrough of this exact page's load — the budget it was built to, the requests it makes, the things it deliberately refuses to ship, and how to check every claim yourself in DevTools.</description>
      <content:encoded><![CDATA[<p>Every claim in this article is verifiable against the page you are reading it on. Open DevTools, switch to the Network tab, reload, and follow along. That is the whole point: a portfolio that claims performance as a specialty should be its own first case study.</p>
<h2>The budget came before the code</h2>
<p>Before the redesign, this site was a cautionary tale I built myself: a full-viewport canvas glitch animation behind the hero, particle effects on every section title, animated SVG borders around every project card, ~16 MB of raw PNG screenshots, and a <code>* { transition }</code> CSS rule taxing every element on the page. The git history contains a commit called <code>fix mobile lags</code>. It did not fix the mobile lags.</p>
<p>The rebuild started with a budget, not a design:</p>





























<table><thead><tr><th>Metric</th><th>Budget</th></tr></thead><tbody><tr><td>First-load JavaScript (gzipped)</td><td>≤ 120 kB, page chunk ≤ 15 kB</td></tr><tr><td>Images on the home page</td><td>&#x3C; 300 kB total</td></tr><tr><td>Render-blocking requests</td><td>1 — the single stylesheet, nothing else</td></tr><tr><td>Layout shift (CLS)</td><td>0.00</td></tr><tr><td>Lab LCP, throttled mid-tier device</td><td>&#x3C; 1.0 s</td></tr></tbody></table>
<p>The JavaScript number deserves honesty: on Next.js 15 with the App Router, the framework baseline — React, the router, hydration — is roughly 100 kB gzipped before you write a line of your own code. A hand-written static page could be 10 kB; if I ever migrate this site to zero-hydration HTML, that becomes the budget. Until then, the honest budget is "framework floor plus almost nothing", and the enforcement is automated: the build fails in CI if a route's first-load JS grows past the ceiling.</p>
<h2>What actually loads</h2>
<p>Reload this page with the Network tab open and disable cache. You will see, in order:</p>
<ol>
<li><strong>The HTML document.</strong> Statically exported at build time — there is no server rendering on demand, no API calls, no waiting. Everything you read arrived in the first response, which also means the page works with JavaScript disabled.</li>
<li><strong>Two font families.</strong> Geist Sans and Geist Mono, subset to Latin and self-hosted as <code>woff2</code>, with size-adjusted fallback metrics generated at build time. No round-trip to a font CDN, and no layout shift when they arrive. (The full font strategy has <a href="/writing/web-fonts-compression-guide/">its own article</a>.)</li>
<li><strong>One stylesheet.</strong> Tailwind compiles to a single small CSS file containing only the classes this site actually uses.</li>
<li><strong>The JavaScript chunks — all <code>async</code>.</strong> None of them block rendering. The page paints before React hydrates, and the only interactive components on the home page are small: a theme toggle, a scroll-spy for the sidebar navigation, a copy-email button, and a ~3 kB Web Vitals reporter.</li>
<li><strong>Images, lazily.</strong> Project screenshots are 800-pixel WebP files of 15–55 kB each, loaded only as you scroll to them, each with explicit dimensions so nothing shifts.</li>
</ol>
<p>What you will <em>not</em> see is often the more important list.</p>
<h2>What this site refuses to ship</h2>
<p>Every item below was either in the old version of this site or is standard issue on portfolio sites, and each one was cut deliberately:</p>
<ul>
<li><strong>No canvas or WebGL.</strong> The old glitch-effect hero ran a full-viewport canvas loop from the moment the page opened until you closed the tab. Decorative animation on this site is now CSS only, runs on the compositor, and respects <code>prefers-reduced-motion</code>.</li>
<li><strong>No animation library.</strong> The previous build shipped framer-motion — around 40 kB gzipped — to animate a decorative beam and a toggle button. Everything it did is now CSS transitions and scroll-driven animations, which cost zero JavaScript.</li>
<li><strong>No third-party requests at all.</strong> No analytics script, no tag manager, no consent banner (nothing to consent to — there are no cookies), no font CDN, no screenshot service. Open the Network tab's domain column: every request comes from this origin. The only telemetry is a tiny beacon that reports anonymous Core Web Vitals — LCP, INP, CLS — so I can see the field data I write about.</li>
<li><strong>No hero video, no icon font, no CSS framework runtime.</strong> The icons are a handful of inline SVGs.</li>
</ul>
<p>The pattern behind the list: on the modern web, most performance work is subtraction. The fastest request is the one you don't make.</p>
<h2>The three decisions that mattered most</h2>
<p>Everything else was detail; these three choices did the heavy lifting.</p>
<p><strong>Static export over server rendering.</strong> The site is prebuilt to plain HTML at deploy time (<code>output: "export"</code>) and served from a CDN. Time-to-first-byte is a cache hit, not a function invocation. This forecloses server features — and for a portfolio, that constraint is a gift: it makes slow architecture impossible.</p>
<p><strong>Images resized at build time.</strong> Next.js's image optimizer doesn't run under static export, so a small <a href="https://sharp.pixelplumbing.com/">sharp</a> script resizes and re-encodes every screenshot to WebP at the exact display size before the build. The six project screenshots went from 16.2 MB of PNG to 167 kB total — a 99% reduction, and the single biggest fix in this rebuild.</p>
<p><strong>Deleting instead of optimizing.</strong> The old effect stack wasn't slow because it was badly written; it was slow because it existed. No amount of <code>useMemo</code> makes a permanent full-screen canvas loop cheap. Cutting it improved every metric at once — JavaScript size, main-thread time, battery, INP — and, honestly, the design too.</p>
<h2>Lab numbers vs. what you experience</h2>
<p>The numbers above are <em>lab</em> numbers: measured on a throttled connection with a cold cache, the way Lighthouse or WebPageTest would. Your load just now was probably faster — warm CDN, real broadband, maybe a repeat visit served from the browser's back/forward cache in ~0 ms.</p>
<p>Field data — what real visitors experience at the 75th percentile — is the truth that matters, and it's collected here by the Web Vitals beacon mentioned above. As it accumulates, the numbers on the <a href="/perf/">/perf</a> page come from real visits, not my machine. That page also carries the current budget, what the CI enforcement looks like, and a changelog of every performance regression I find in my own site, because publishing your own regressions is more convincing than publishing your wins.</p>
<p>The JavaScript side of this story — where the 68 kB actually came from — is broken down in <a href="/writing/nextjs-bundle-forensics/">Your Next.js Bundle Is Lying to You</a>.</p>
<h2>Reproduce this audit on any site in 15 minutes</h2>
<ol>
<li>Open DevTools → Network. Check "Disable cache", set throttling to "Fast 4G", reload. Count the requests before first paint — anything render-blocking shows up before the first pixel.</li>
<li>Find the LCP element: Performance tab → record a reload → click the LCP marker. Ask: when did the browser <em>learn</em> about this resource? Late discovery, not slow download, is the usual LCP killer.</li>
<li>Sort Network by size. Anything over 100 kB must justify itself.</li>
<li>Filter by domain. Every third-party origin is a negotiation someone should have had.</li>
<li>Run Lighthouse twice and keep the second run (the first is polluted by extension noise more often than people think).</li>
</ol>
<p>If you do this to your own site and find something interesting, <a href="mailto:info@antoinedebes.com">email me</a> — the best future article on this site might be your waterfall.</p>]]></content:encoded>
    </item>
    <item>
      <title>INP Is a React Problem (and React Has the Tools to Fix It)</title>
      <link>https://antoinedebes.com/writing/inp-is-a-react-problem/</link>
      <guid>https://antoinedebes.com/writing/inp-is-a-react-problem/</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <description>Interaction to Next Paint punishes exactly the thing React does by design: large synchronous renders on the main thread. Where the milliseconds go, how to see them in a trace, and the fix ladder from memoization to useTransition to leaving React out of it.</description>
      <content:encoded><![CDATA[<p>Since Interaction to Next Paint became a Core Web Vital, a pattern shows up in field data across the industry: sites pass LCP comfortably and fail INP — and the failing sites are disproportionately React apps. That's not an accident of popularity. INP punishes exactly the thing React does by design: <strong>large synchronous renders on the main thread, triggered by state updates, in response to input.</strong></p>
<p>This isn't a React hit piece. It's a map of where the milliseconds go and the specific tools — most of them shipped by React itself — that get them back.</p>
<h2>What INP actually measures</h2>
<p>INP takes (roughly) the worst interaction of a user's visit and measures <strong>input to next painted frame</strong>, in three phases:</p>
<ol>
<li><strong>Input delay</strong> — the event happened, but the main thread was busy finishing something else. Your handler hasn't even started.</li>
<li><strong>Processing</strong> — your event handlers run. In React: the state update, the re-render of every affected component, the reconciliation, the commit.</li>
<li><strong>Presentation delay</strong> — style, layout, paint, composite for the resulting frame.</li>
</ol>
<p>Under 200 ms is good; over 500 ms is poor. If you want to <em>feel</em> the thresholds instead of reading them, I built a <a href="/lab/">30-second demo on this site's lab page</a> — it blocks the main thread for a duration you choose and shows you the measured gap. 200 ms feels like a hesitation. 1000 ms feels broken.</p>
<p>The trap in the definition: <strong>the click handler you wrote is only phase 2.</strong> Profile just your handler and you'll miss the input delay caused by that analytics script chewing the thread, and the 300 ms render your <code>setState</code> scheduled.</p>
<h2>Why React concentrates the damage</h2>
<p>React's model is: state changed → re-render the affected subtree → commit, <strong>synchronously by default</strong>. Three properties of real codebases turn that into long tasks:</p>
<ul>
<li><strong>Renders cascade wider than intended.</strong> A context value changes and every consumer re-renders; a parent re-creates a callback and memoized children bail out of bailing out. The render tree is bigger than the mental model of it.</li>
<li><strong>The expensive path is the common path.</strong> A controlled input re-renders on every keystroke. Wire it to a 2,000-row filtered list without care, and typing becomes fifteen 250 ms tasks in a row — INP samples every one of them.</li>
<li><strong>It all happens at input time.</strong> Server components, SSR, and streaming moved <em>loading</em> work off the client, but interactions are still, definitionally, client work. INP is the metric your architecture migration didn't fix.</li>
</ul>
<h2>Reading the trace</h2>
<p>One recorded Performance-panel trace of a slow click tells you which phase to attack. The signature of each:</p>
<ul>
<li>A long yellow task <em>before</em> your handler's flame: <strong>input delay</strong> — something else (hydration, a third-party, a previous render) was hogging the thread. Your handler is innocent; the page's background work is guilty.</li>
<li>A wide <code>onClick</code> frame with your code in it: <strong>processing, your logic</strong> — you're doing real computation in the handler. Move it off the interaction (worker, deferral, memoized ahead of time).</li>
<li>A wide "Commit" / render flame <em>after</em> your code returns: <strong>processing, React's render</strong> — the state update redrew too much. This is the usual suspect, and the whole next section.</li>
<li>Purple layout blocks after commit: <strong>presentation</strong> — you invalidated layout for thousands of nodes, or wrote layout-triggering styles from JS.</li>
</ul>
<h2>The fix ladder</h2>
<p>Ordered by effort-to-impact; stop at the first rung that gets you under 200 ms.</p>
<p><strong>1. Render less.</strong> Push state down to the smallest component that needs it; split contexts so consumers subscribe to what they actually read; <code>memo</code> the expensive subtrees the update shouldn't touch. Boring, mechanical, and it's rung one because it's usually the whole fix.</p>
<p><strong>2. Split urgent from deferrable.</strong> This is the rung React built specifically for INP:</p>
<pre><code class="language-tsx">const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
// input renders with `query` — urgent, every keystroke
// the 2,000-row list renders with `deferredQuery` — interruptible, lags harmlessly
</code></pre>
<p><code>useTransition</code> is the same idea for updates you trigger (tab switches, filters): the click paints <em>now</em>, the heavy new tree renders concurrently and can be interrupted by the next input. This is concurrent rendering doing exactly what it was designed for — the frame after input stays cheap, which is the frame INP measures.</p>
<p><strong>3. Yield inside long work.</strong> For genuinely long computation that must run on interaction, break it up so input can interleave: <code>scheduler.yield()</code> (or a <code>setTimeout(0)</code> chunk loop where unsupported) between batches. A 600 ms task becomes twelve 50 ms tasks, and the user's next click slots in between.</p>
<p><strong>4. Leave the main thread.</strong> Search indexes, parsing, diffing — <code>Worker</code> + transferable data. The main thread's only job during an interaction is painting the response to it.</p>
<p><strong>5. Leave React out of it.</strong> Hover states, open/close toggles, animations: CSS handles them at compositor priority with zero render cycles. The fastest re-render is the one that never happens — sometimes the senior fix is admitting the interaction never needed state.</p>
<h2>Measure like you mean it</h2>
<p>Lab clicks on a dev machine won't reproduce what a mid-range Android feels. Two habits close the gap:</p>
<ul>
<li><strong>4–6× CPU throttling</strong> in the Performance panel for every interaction you profile. This site's <a href="/lab/">lab demo</a> at 200 ms feels fine unthrottled — throttle and watch it cross into "poor".</li>
<li><strong>Field attribution.</strong> The <code>web-vitals</code> library's attribution build tells you <em>which element</em> and <em>which phase</em> produced each bad INP sample in production. One week of field attribution beats a month of guessing which interaction to fix. (This site beacons its INP field data — the plain build for now, attribution upgrade queued — <a href="/perf/">details on /perf</a>.)</li>
</ul>
<p>The one-sentence version: INP is where React's costs concentrate, React 18+ shipped the escape hatches, and the trace tells you which one you need. The rest is discipline.</p>]]></content:encoded>
    </item>
    <item>
      <title>Your Next.js Bundle Is Lying to You: A Field Guide to Finding the Real Bloat</title>
      <link>https://antoinedebes.com/writing/nextjs-bundle-forensics/</link>
      <guid>https://antoinedebes.com/writing/nextjs-bundle-forensics/</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <description>How to read Next.js build output correctly, catch barrel-file explosions and 'use client' contagion, and set up CI guardrails — illustrated with the real numbers from cutting this site from 173 kB to 105 kB of first-load JavaScript.</description>
      <content:encoded><![CDATA[<p>This site's JavaScript went from <strong>173 kB to 105 kB</strong> (gzipped, first load) in one working day — not through clever optimization, but through forensics: finding out what was actually in the bundle and why. The commits are public, so this article can use them as a worked example of a process that applies to any Next.js app.</p>
<h2>The only number that matters (and what it hides)</h2>
<p>When <code>next build</code> finishes, it prints a table. The number to read is <strong>First Load JS</strong> — the gzipped JavaScript a new visitor downloads to render a route. Ignore the per-route "Size" column until you understand what's shared.</p>
<pre><code>Route (app)                    Size    First Load JS
┌ ○ /                        1.88 kB       107 kB
+ First Load JS shared by all              102 kB
</code></pre>
<p>Three things this table hides:</p>
<ol>
<li><strong>The floor.</strong> On Next.js 15 with the App Router, the shared baseline — React, the router, hydration runtime — is ~100 kB gzipped before you write a line of code. Your leverage is everything <em>above</em> the floor. When this site's home route reads 107 kB, the honest reading is: framework 102, me 5.</li>
<li><strong>What "shared" means.</strong> A chunk used by every route lands in the shared row. One heavy import in your root <code>layout.tsx</code> taxes <em>every page on the site</em>. The layout is the most expensive place to put code, and the place people audit least.</li>
<li><strong>It's an estimate.</strong> The table is computed at build time. What browsers actually download can differ (the <code>noModule</code> polyfill chunk, for example, appears in the HTML but is never fetched by modern browsers). When I measured this site's export by gzipping the actual chunks referenced in <code>index.html</code>, the real number was 105.4 kB vs the table's 107. Close — but trust the export, not the table, and measure the export in CI.</li>
</ol>
<h2>Forensic tool #1: the bundle analyzer, read correctly</h2>
<pre><code class="language-bash">npm i -D @next/bundle-analyzer
ANALYZE=true next build
</code></pre>
<p>The treemap is where intuitions die. Reading rules:</p>
<ul>
<li>Sort by <strong>gzipped</strong> size. Raw size overweights repetitive code that compresses well.</li>
<li>Look for <em>duplicates</em> first: two versions of the same library (a date library imported directly and via a dependency) is the most common silent 20 kB.</li>
<li>Anything in the shared chunk gets interrogated: <em>why is this on every page?</em></li>
</ul>
<h2>Forensic tool #2: the barrel-file trap</h2>
<p>A "barrel" is an <code>index.ts</code> that re-exports a whole library. Icon libraries are the classic case:</p>
<pre><code class="language-ts">import { ArrowUpRight } from "lucide-react"; // innocent-looking
</code></pre>
<p>Whether this ships one icon or the entire icon set depends on the library's internal structure and your bundler's tree-shaking succeeding — and tree-shaking fails quietly: a single side-effectful module in the chain and you ship 1,400 icons to render three.</p>
<p>Defenses, in order of preference:</p>
<ol>
<li>Next.js ships <code>optimizePackageImports</code> in <code>next.config</code> — it rewrites barrel imports to direct per-module imports at build time for listed packages (several popular ones are covered by default).</li>
<li>Import from the concrete path (<code>lucide-react/dist/esm/icons/arrow-up-right</code>) where the library supports it.</li>
<li>For a handful of icons: <strong>don't import an icon library at all.</strong> This site's icons are ~10 inline SVGs pasted into components. Zero dependency, zero risk, styleable with <code>currentColor</code>.</li>
</ol>
<h2>Forensic tool #3: <code>"use client"</code> contagion</h2>
<p>The App Router's real performance feature is that server components ship <strong>zero</strong> component JavaScript. But client-ness is contagious downward: mark one high-level component <code>"use client"</code> and every module it imports joins the client bundle.</p>
<p>The audit is grep-shaped:</p>
<pre><code class="language-bash">grep -rn '"use client"' src/ | wc -l
</code></pre>
<p>Then, for each hit, one question: <em>does this component hold state or attach event handlers?</em> If not, the directive is contagion damage.</p>
<p>This was the single biggest lever on this site. The old version had client components everywhere because everything animated. After the redesign, the page-specific chunk for the home route is <strong>1.88 kB</strong> — the interactive surface is exactly four small components (theme toggle, scroll-spy, copy-email button, a 2 kB vitals reporter), and everything else renders on the server at build time. The old page chunk was 55 kB. Nothing was "optimized"; the client boundary was just drawn where it belonged.</p>
<h2>Forensic tool #4: know what a dependency costs before you add it</h2>
<p>The 68 kB this site lost came mostly from deleting three things the old version shipped: an animation library (~40 kB gz) that animated one decorative beam, a particle engine for section-title sparkles, and a canvas glitch effect. Each entered the codebase as a one-line <code>npm install</code> that looked free.</p>
<p>The discipline that prevents the relapse:</p>
<ul>
<li>Check <a href="https://bundlephobia.com">bundlephobia</a> or <code>npm pack --dry-run</code> <em>before</em> installing.</li>
<li>Prefer the platform: CSS scroll-driven animations replaced the animation library here at a cost of 0 kB — they run compositor-side, which is also better for INP.</li>
<li><code>next/dynamic</code> is for genuinely-heavy, genuinely-conditional things (an editor, a chart, a modal). It is not absolution for shipping too much; a lazy-loaded 200 kB is still 200 kB when it loads.</li>
</ul>
<h2>The guardrail that makes it stick</h2>
<p>Every number above decays without enforcement. This repo's CI fails any push where the measured export exceeds budget:</p>
<pre><code class="language-js">// scripts/measure-perf.mjs (excerpt) — gzip the chunks the HTML actually references
const scripts = [...html.matchAll(/&#x3C;script src="(\/_next\/[^"]+\.js)"([^>]*)>/g)]
  .filter((m) => !m[2].includes("noModule"))
  .map((m) => m[1]);
let jsKb = 0;
for (const src of new Set(scripts)) jsKb += gzKb(await readFile(join(OUT, src)));
if (jsKb > BUDGET.firstLoadJsKb) process.exit(1);
</code></pre>
<p>Twenty lines, no SaaS, runs in seconds. The <a href="/perf/">live results are public</a>. A budget that fails the build is a decision; a budget in a wiki is a wish.</p>
<h2>The one-hour audit, condensed</h2>
<ol>
<li><code>next build</code> → write down First Load JS per route and the shared row.</li>
<li>Analyzer → find duplicates and the heaviest three modules → justify or kill each.</li>
<li>Grep <code>"use client"</code> → push every directive down to the smallest stateful leaf.</li>
<li>Check barrel imports against <code>optimizePackageImports</code>.</li>
<li>Add the CI gate so you never do this audit again.</li>
</ol>
<p>The pattern behind all five steps is the same one behind most performance work: the bundle doesn't need optimizing so much as it needs <em>auditing</em>. Most of the weight was never load-bearing.</p>]]></content:encoded>
    </item>
    <item>
      <title>Reading a Waterfall Like a Performance Engineer</title>
      <link>https://antoinedebes.com/writing/reading-a-waterfall/</link>
      <guid>https://antoinedebes.com/writing/reading-a-waterfall/</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <description>The network waterfall is the most information-dense chart in web development, and almost nobody teaches how to read it. The vocabulary, the six shapes of a broken waterfall, and how to trace the critical path to LCP.</description>
      <content:encoded><![CDATA[<p>Ask a performance engineer to diagnose a slow site and the first thing they open isn't Lighthouse — it's the network waterfall. It's the most information-dense chart in web development: every request, its timing anatomy, its dependencies, and the story of why the page painted when it did. This is the reading lesson I wish someone had given me years ago.</p>
<p>Open DevTools → Network → "Disable cache" → throttle to Fast 4G → reload. Follow along on any site — or this one.</p>
<h2>The vocabulary: what one bar means</h2>
<p>Every request bar decomposes into segments, and each segment blames a different culprit:</p>
<ul>
<li><strong>Queueing / stalled</strong> — the browser knew about the resource but chose to wait: lower priority, or (on HTTP/1.1) no free connection. <em>Blames: prioritization, protocol.</em></li>
<li><strong>DNS → TCP connect → TLS</strong> — the cost of talking to a new origin for the first time, easily 100–300 ms on mobile. Paid once per origin. <em>Blames: third-party sprawl.</em></li>
<li><strong>Waiting (TTFB)</strong> — request sent, waiting for the first response byte. <em>Blames: the server — or your redirect chain.</em></li>
<li><strong>Content download</strong> — first byte to last. Long bars here are the only segment that actually blames file size.</li>
</ul>
<p>First diagnostic reflex: <strong>is the time in connection setup, waiting, or download?</strong> Engineers who skip this reflex optimize images when the problem is a 600 ms TTFB, or blame the backend when the problem is 4 MB of JavaScript.</p>
<h2>The six shapes of a broken waterfall</h2>
<p>Individual bars matter less than the <em>shape</em> of the whole chart. Six patterns cover most broken sites:</p>
<p><strong>1. The staircase.</strong> Each request starts only after the previous finishes: CSS → font, or JS → JS → fetch. Each step is a full round trip. Classic causes: CSS <code>@import</code> (a stylesheet that downloads another stylesheet), fonts (HTML → CSS → font is an unavoidable two-step you can shorten with preload), and dynamic <code>import()</code> chains. Fix: flatten — inline, preload, or bundle the chain away.</p>
<p><strong>2. The blocking wall.</strong> A vertical line early in the chart that nothing renders before: synchronous scripts in <code>&#x3C;head></code>, or a pile of render-blocking stylesheets. Everything to the left of first paint is the wall. Fix: <code>defer</code>/<code>async</code>/<code>type=module</code> scripts, split non-critical CSS, and interrogate every third-party tag that demands to load first.</p>
<p><strong>3. The late hero.</strong> The LCP image starts downloading at the 2-second mark of a 3-second load. The download was fast; the <em>discovery</em> was slow — it was a CSS <code>background-image</code> (found only after CSS parsed), or behind a lazy-load attribute, or injected by JavaScript. Fix: make the hero an <code>&#x3C;img></code> in the initial HTML with <code>fetchpriority="high"</code>, and never <code>loading="lazy"</code> above the fold. Discovery time, not transfer time, is the #1 LCP killer I see.</p>
<p><strong>4. The gap.</strong> A horizontal stretch where the network is <em>silent</em> — nothing downloading. The network isn't your problem; the main thread is. The browser is parsing a megabyte of JavaScript and hasn't gotten around to requesting the next resource. Fix in the Performance panel, not the Network panel.</p>
<p><strong>5. The third-party cliff.</strong> The chart doubles in row-count at some timestamp: one tag manager fired and injected analytics, pixels, session replay, an A/B tool, and a chat widget, each opening a new origin (see: connection setup, above). Fix: audit which of them anyone still uses; façade the chat widget; load the rest after first interaction.</p>
<p><strong>6. The redirect chain.</strong> The very first bar is 3 short bars: <code>http://site.com</code> → <code>https://site.com</code> → <code>https://www.site.com</code>. Each hop is a round trip <em>before your HTML even starts</em>, and it taxes every single visit. Fix: one canonical origin, one 301, and links that point at the final URL.</p>
<h2>Tracing the critical path</h2>
<p>The professional move is reading the waterfall <em>backwards</em>. Find the LCP element (Performance panel → LCP marker, or PageSpeed Insights names it). Then ask, recursively: <strong>what did this resource wait for?</strong></p>
<blockquote>
<p>LCP image → discovered by CSS (<code>background-image</code>) → CSS waited on a font CDN's preconnect → which waited on the redirect chain.</p>
</blockquote>
<p>That chain is the critical path. Nothing off that path matters for LCP, no matter how big it looks in the chart. A 2 MB analytics bundle that loads after the hero paints is a battery problem and an INP risk — but it's not why LCP is slow, and "optimizing" it won't move the metric anyone's dashboard is red about. Precision about <em>which problem you're solving</em> is most of what separates a perf engineer from a checklist.</p>
<p>Priorities are the subplot here: browsers schedule HTML/CSS/fonts as Highest, visible images high, <code>async</code> scripts low — and you can see the assigned priority in the Network panel's Priority column. When the order looks wrong (analytics before hero image), the fix is usually <code>fetchpriority</code>, <code>preload</code>, or moving the offender out of the head — not more bandwidth.</p>
<h2>Lab, field, and which waterfall to trust</h2>
<p>DevTools shows <em>your</em> machine on <em>your</em> network with your extensions injecting noise. <a href="https://www.webpagetest.org/">WebPageTest</a> gives you a clean, repeatable waterfall on real mid-range hardware with film-strip screenshots — it remains the gold standard for diagnosis. Field data (CrUX, your own RUM) tells you which pages and which percentiles hurt real users, so you know <em>which</em> waterfall to record in the first place. The workflow that works: field data to pick the target, lab waterfall to find the mechanism, field data again to confirm the fix landed.</p>
<h2>Practice on this page</h2>
<p>This site is a deliberately boring waterfall — that's the point of it. Reload with the Network tab open and you'll see the whole story: one HTML document, one stylesheet, two latin font subsets, a handful of <code>async</code> chunks, images that wait politely below the fold, and zero third-party origins. Boring is what fast looks like. <a href="/writing/how-this-site-loads-in-under-one-second/">The full annotated tour is here</a>.</p>
<p>The skill compounds fast: after a dozen deliberate reads, you'll glance at a waterfall and see the diagnosis before you've consciously read a single bar. It's the closest thing performance work has to a superpower — and it's free.</p>]]></content:encoded>
    </item>
    <item>
      <title>Web Fonts Without the Performance Tax: Compression, Subsetting, and Fallbacks That Don't Shift</title>
      <link>https://antoinedebes.com/writing/web-fonts-compression-guide/</link>
      <guid>https://antoinedebes.com/writing/web-fonts-compression-guide/</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <description>A practical deep dive into font performance: why woff2 wins, how subsetting cuts 80% of the bytes, what variable fonts change, and how size-adjust makes the fallback invisible — with this site's own font pipeline as the worked example.</description>
      <content:encoded><![CDATA[<p>Fonts sit in an awkward place in the waterfall: they're discovered late (the browser only requests a font after CSS tells it a visible element needs one), they block the text you came to read, and a careless setup ships megabytes for glyphs nobody renders. They're also one of the most <em>solvable</em> performance problems on the web — the full toolkit fits in one article.</p>
<p>Everything below is applied on this site; the numbers are from its build output, and you can verify them in your Network tab right now.</p>
<h2>Compression: woff2 or nothing</h2>
<p>Font formats are a solved argument. <code>woff2</code> wraps the font tables in Brotli compression and has been supported by every browser that matters for years. Compared to a raw <code>ttf</code>/<code>otf</code>, <code>woff2</code> is typically <strong>60–70% smaller</strong>; compared to the older <code>woff</code> (zlib-based), it still saves ~20–30%.</p>
<p>Practical rules:</p>
<ul>
<li>Ship <code>woff2</code> <strong>only</strong>. A <code>woff</code> fallback in your <code>@font-face</code> <code>src</code> list is dead weight in 2026 — browsers that would use it can live with your fallback stack.</li>
<li>Never ship <code>ttf</code>/<code>otf</code> to production. If a designer hands you one, convert it (<code>fonttools</code> does this in one line).</li>
<li>Don't double-compress: <code>woff2</code> is already Brotli. Serving it with <code>Content-Encoding: br</code> again wastes CPU for ~0 bytes. Check your CDN isn't doing this.</li>
</ul>
<h2>Subsetting: the 80% you never render</h2>
<p>A typical "full" font covers Latin, Latin Extended, Cyrillic, Greek, Vietnamese, and a pile of OpenType features. If your site is English-language, most of those bytes are decoration for the crawler.</p>
<p>Subsetting splits the font by script and lets <code>unicode-range</code> do lazy loading at the glyph level:</p>
<pre><code class="language-css">@font-face {
  font-family: "Geist";
  src: url("/fonts/geist-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, /* … */;
}
</code></pre>
<p>The browser downloads a subset <strong>only if the page actually renders a character in its range</strong>. This site declares eleven subset files for its two families, but a typical visit downloads exactly two: the Latin subsets — <strong>28.6 kB for Geist Sans and 22.6 kB for Geist Mono</strong>. The other nine files exist for the rare page that needs them and cost nothing until then.</p>
<p>Tooling: <code>next/font</code> does this automatically (it's what generated the numbers above). Doing it by hand, <code>pyftsubset</code> from fonttools is the standard:</p>
<pre><code class="language-bash">pyftsubset Geist.ttf \
  --output-file=geist-latin.woff2 \
  --flavor=woff2 \
  --layout-features="kern,liga,calt" \
  --unicodes="U+0000-00FF,U+2013-2014,U+2018-201D,U+2026"
</code></pre>
<p>Two traps worth knowing: aggressive subsetting can silently drop ligatures, tabular figures, or the arrows and quotes your design system actually uses — keep the OpenType features you rely on, and test with real content. And if you support user-generated content, be generous with ranges; tofu boxes (□) in a comment section are a worse bug than 10 extra kilobytes.</p>
<h2>Variable fonts: one file instead of six</h2>
<p>If you use more than two or three weights of a family, a variable font usually wins. Instead of shipping Regular + Medium + SemiBold + Bold as four files, one variable file carries the whole weight axis — typically 40–60% smaller than the sum, one request instead of four, and you get the in-between weights for free.</p>
<p>The flip: if you only use one weight plus a bold, two static subsets are often smaller than one variable file. Measure, don't assume — the answer flips per family.</p>
<h2>Loading strategy: FOUT is a feature</h2>
<p><code>font-display</code> decides what happens between first paint and font arrival:</p>
<ul>
<li><strong><code>swap</code></strong> — show the fallback immediately, swap when ready. Text is readable from the first paint. This is the right default for body text.</li>
<li><strong><code>optional</code></strong> — use the web font only if it's already cached (or arrives near-instantly). The most performance-pure option: repeat visitors get the brand font, first-time visitors on slow networks never wait for it.</li>
<li><strong><code>block</code></strong> — invisible text for up to 3 seconds. Almost never what you want.</li>
</ul>
<p>People avoid <code>swap</code> because the swap itself looks janky: the fallback renders at a different width, and the page reflows when the real font lands. That's not an argument against <code>swap</code> — it's an argument for fixing the fallback. Which brings us to the good part.</p>
<h2><code>size-adjust</code>: the layout shift killer</h2>
<p>The reflow-on-swap problem exists because Arial and your brand font disagree about how wide text is. Modern CSS lets you <em>calibrate the fallback</em> to match the web font's metrics:</p>
<pre><code class="language-css">@font-face {
  font-family: "Geist Fallback";
  src: local("Arial");
  size-adjust: 104.76%;
  ascent-override: 85.83%;
  descent-override: 20.32%;
  line-gap-override: 9.33%;
}
</code></pre>
<p>That <code>104.76%</code> is a real number from this site's generated CSS: Arial scaled up ~5% occupies almost exactly the same space as Geist, so when the swap happens, nothing moves. The mono fallback needs <code>size-adjust: 134.59%</code> — monospace metrics diverge harder, which is why unstyled mono fallbacks jump so visibly on other sites.</p>
<p><code>next/font</code> computes these overrides automatically. Outside Next, <a href="https://github.com/unjs/fontaine">fontaine</a> or Capsize generate them, or you can measure once with a canvas and hardcode the result. However you get there, this single technique takes font-driven CLS to zero without giving up custom type.</p>
<h2>Discovery: when does the browser even ask?</h2>
<p>Compression solves bytes; discovery solves <em>when</em>. The browser requests a font only after it has downloaded CSS, built the render tree, and found a visible element using that face. That chain — HTML → CSS → font — is why fonts are chronically late.</p>
<ul>
<li><strong>Self-host.</strong> A third-party font CDN adds a DNS + TLS handshake to a render-critical resource, and cross-origin caching has been dead since browsers partitioned caches years ago. There is no performance argument left for a font CDN — only licensing ones.</li>
<li><strong>Preload only what's certain.</strong> <code>&#x3C;link rel="preload" as="font"></code> skips the discovery chain, but every preloaded font competes with your CSS and LCP image for early bandwidth. Preload the one or two subsets that render above-the-fold text; preloading all eleven subsets would make this site <em>slower</em>.</li>
<li><strong>Same-origin, immutable caching.</strong> Hashed font filenames + <code>Cache-Control: immutable</code> means repeat visits pay zero font cost.</li>
</ul>
<h2>The checklist</h2>
<ol>
<li><code>woff2</code> only, self-hosted, hashed filenames, immutable cache headers.</li>
<li>Subset by script; let <code>unicode-range</code> lazy-load the exotic ranges.</li>
<li>Variable font if you use 3+ weights; measure if fewer.</li>
<li><code>font-display: swap</code> (or <code>optional</code> if you're hardcore) — never <code>block</code>.</li>
<li>Metric-compatible fallback with <code>size-adjust</code>/<code>ascent-override</code> → zero CLS.</li>
<li>Two families maximum. Every additional face has to justify itself against the best-compressed asset on your page: no font at all.</li>
</ol>
<p>The result on this site: two families, ~51 kB of fonts on a typical visit, zero layout shift, zero third-party requests. Open DevTools → Network → filter "Font" and check my math.</p>]]></content:encoded>
    </item>
  </channel>
</rss>