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.
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.
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.- 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.
<!-- 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>What blocks rendering, and what only appears to
| Resource | Blocks render? | Note |
|---|---|---|
| Stylesheet in head | Yes | Unavoidable and correct — but split so a page loads its own CSS, not the whole site's |
| Synchronous script in head | Yes, and blocks parsing | Almost never justified; defer or move to the end |
| Script with defer | No | Executes in order after parsing; the sane default |
| Script with async | No, but interrupts | Executes whenever it arrives — fine for independent analytics, bad for anything ordered |
| Font with font-display: swap | No | Text 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.