Skip to content

Lesson 7 of 9 · 26m

Rendering Paths: From HTML Bytes to First Pixels

What blocks the first paint, how CSR, SSR, SSG, and streaming change the LCP picture, why hydration is a hidden tax, and how fonts fit in.

When a new hire asks me why a page with a 200 ms TTFB and a tiny hero image still has a 3.5 s LCP, the answer is almost always in the gap between "bytes arrived" and "pixels painted": element render delay. Something stood between the browser and the paint. That something is the critical rendering path, and your rendering architecture decides how long it is.

What blocks rendering

The browser won't paint until it has the DOM it needs and all render-blocking CSS in the head. Classic synchronous <script> tags block the parser itself. So the rules are:

<head>
  <!-- small critical CSS inline; the rest loaded normally -->
  <style>/* above-the-fold layout, ~10-15 KB max */</style>
  <link rel="stylesheet" href="/assets/app-41c2e9.css">

  <!-- defer: download in parallel, execute after parse, in order -->
  <script defer src="/assets/index-3f9a1c.js"></script>

  <!-- async: execute whenever it arrives — only for independent scripts -->
  <script async src="https://analytics.northwindgear.com/a.js"></script>
</head>

type="module" scripts are deferred by default. A synchronous third-party script in the head is the most expensive line of HTML you can write — if its origin is slow, your entire page waits on someone else's server.

Rendering architectures, through the LCP lens

ApproachHTML containsLCP riskINP risk
CSR (client-side render)empty shell + scripthigh — content waits for JS download, execute, then data fetchmoderate
SSR (server render per request)full markupTTFB grows with server workhydration can block early taps
SSG (prebuilt at deploy)full markup, CDN-cachedlowesthydration cost still applies
Streaming SSRshell first, slow sections flushed laterlow — head and hero ship before slow datadepends on hydration strategy

CSR stacks the LCP sub-parts serially: HTML → JS → execute → API call → render. Every arrow is a round trip on a phone. For content pages this is almost never the right default.

Hydration is a hidden tax

SSR makes the page look ready, then the framework downloads the JavaScript for every component and re-executes it to attach handlers. During that window the page is visible but taps may be ignored or delayed — a fat source of bad INP on first interaction. Mitigations, from least to most invasive: smaller bundles (lesson 4), lazy or deferred hydration of below-fold components, island architectures (Astro, Fresh) that hydrate only interactive pieces, and React Server Components that never ship JS for server-only components.

Fonts sit on the critical path too

Text using a web font isn't painted until the font loads or the font-display timeout expires. If your LCP element is a heading, the font is part of LCP.

@font-face {
  font-family: "Inter";
  src: url("/assets/inter-var-8b2e.woff2") format("woff2");
  font-display: swap;      /* show fallback now, swap when ready */
}
/* tune the fallback's metrics so the swap doesn't shift layout (CLS) */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }

Preload the one or two fonts used above the fold with <link rel="preload" as="font" type="font/woff2" crossorigin> — the crossorigin attribute is required even on same-origin, or the preload is wasted and the font downloads twice.

Field notes

  • Large inline state blobs (window.__INITIAL_STATE__ at 800 KB) cost parse time on the main thread before hydration even starts. Check the HTML size, not just the JS.
  • font-display: optional eliminates font swap CLS entirely, at the price of some first-visit users seeing the fallback font. For body text it's often the right call.
  • If the server is slow to compute the page, flush the <head> early (streaming) so CSS, font, and hero-image requests start while the server is still thinking.
  • A spinner rendered by JS is not LCP-friendly content. The browser happily records the spinner, or nothing, until the real content arrives.