Skip to content
HandbookPublic
Security

The HTML document request

Everything the browser can do depends on the first response. Document size, the SSR payload nobody measures, priority hints, and what to preload.

Written forEngineers optimising the critical path on a server-rendered site
Reading time10 min read
Last reviewed2026-09-04

The document request is the only one the browser makes without being told to. Every stylesheet, script, font and image is discovered inside it, so its size and its contents set the ceiling for everything measured afterwards. A page cannot have a good LCP if the document that references the hero image takes 900ms to arrive and 300ms to parse.

Document size is mostly data you did not need

On server-rendered React sites the HTML is rarely large because of markup. It is large because the data used to render it is serialised into the page a second time so the client can hydrate — and because that payload is frequently the entire API response rather than the fields the page uses.

bash
curl -s https://example.com/work | wc -c            # total document

curl -s https://example.com/work \
  | grep -o '__NEXT_DATA__.*' | wc -c                  # serialised payload

# Anything over ~40KB of payload deserves an explanation.
Measure the split before optimising. The serialised payload is usually the larger half.
  • Project the data to what the page renders. A list screen needs six fields per row, not the forty the ORM returned.
  • Do not serialise what the server already rendered. In the App Router, keep non-interactive parts as server components and no payload is produced for them at all.
  • Watch for the same data serialised twice — once by a page-level fetch and once by a nested component doing its own.
  • Keep the document under about 100KB before compression. Past that, parse time on mid-range devices starts to show in the field data.

Resource discovery: the first 14KB matter most

The browser begins fetching subresources as it parses. Anything referenced early is discovered early — which is why the head is worth treating as a scarce resource rather than a bucket. A hero image referenced only in a lazily-hydrated component is discovered a full round trip later than one referenced in the initial HTML.

html
<!-- The LCP image: fetched at high priority, immediately -->
<link rel="preload" as="image" href="/hero.avif"
      imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
      fetchpriority="high">

<!-- Self-hosted font used above the fold -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/spectral-600.woff2" crossorigin>

<!-- A third-party origin you will certainly hit -->
<link rel="preconnect" href="https://api.example.com" crossorigin>
Preload what the page cannot paint without. Preloading everything is the same as preloading nothing.

What blocks rendering, and what only appears to

ResourceBlocks render?Note
Stylesheet in headYesUnavoidable and correct — but split so a page loads its own CSS, not the whole site's
Synchronous script in headYes, and blocks parsingAlmost never justified; defer or move to the end
Script with deferNoExecutes in order after parsing; the sane default
Script with asyncNo, but interruptsExecutes whenever it arrives — fine for independent analytics, bad for anything ordered
Font with font-display: swapNoText paints in the fallback; see the CLS article for avoiding the reflow

Compression and transport

  • Brotli at a moderate quality for dynamic HTML, maximum quality for static assets built once. Compressing every dynamic response at maximum costs more CPU than it saves in transfer.
  • HTTP/2 or HTTP/3 for connection reuse. On a high-latency mobile connection, avoiding a second handshake is worth more than any byte saving.
  • Do not compress responses under about 1KB — the CPU and the header overhead exceed the benefit.
  • Check that your CDN is compressing at the edge rather than passing through an uncompressed origin response.