Streaming SSR and the TTFB vs LCP Tradeoff
Buffered server rendering holds the whole response until the slowest data query resolves, so the first byte waits for the last row. Streaming SSR flushes the shell immediately and sends data-dependent regions as they become ready — cutting Time to First Byte sharply, but changing what a crawler receives and when. This guide is part of edge rendering and CDN SEO and explains the flush order that keeps a streamed response fully indexable.
Problem Statement
With buffered SSR, a page that depends on one slow API call has a Time to First Byte as high as that call’s latency, because nothing is sent until rendering completes. Streaming fixes the first-byte number by flushing the static shell right away, then filling in suspended regions. But two things change for SEO. First, the order chunks flush now matters: if the document head arrives after the body data, a crawler can parse an incomplete head. Second, TTFB and Largest Contentful Paint stop moving together — a fast first byte can hide a slow meaningful paint if the largest element lives in a late chunk.
Understanding what a crawler actually captures is the crux. Googlebot reads the response body to completion, so a late chunk is not automatically lost; the danger is a chunk so slow that total response time exceeds the fetch budget and the document is truncated. The sequence below shows the flush order you are aiming for.
Step-by-step fix
-
Flush the head and shell in the first chunk. Render the document head — title, meta description, and canonical — plus the static layout before any suspended data. This is what fixes TTFB and guarantees a crawler parses complete metadata.
// React 18 streaming: the shell (head + layout) flushes immediately import { renderToPipeableStream } from "react-dom/server"; const { pipe } = renderToPipeableStream(<Document url={url} />, { bootstrapModules: ["/assets/client.js"], onShellReady() { res.setHeader("content-type", "text/html; charset=utf-8"); pipe(res); // head + shell go out now, before data resolves }, onError(err) { console.error("Stream error:", err); }, }); -
Wrap data-dependent regions in Suspense. Everything that waits on a query goes behind a boundary with a fallback, so it streams in a later chunk without blocking the shell.
<main> <h1>{route.title}</h1> <Suspense fallback={<ListSkeleton />}> <ProductList query={route.query} /> {/* streams in chunk 2 */} </Suspense> </main> -
Keep the LCP element out of the slowest boundary. Identify the largest contentful element — usually a hero image or the first heading — and make sure it is in an early chunk, not buried behind the slowest query. If it must be data-dependent, prioritise that query.
-
Set a hard timeout so a slow chunk cannot truncate the document. Abort and render a fallback rather than letting a stalled query hold the connection past a crawler’s budget.
const { pipe, abort } = renderToPipeableStream(<Document url={url} />, { onShellReady() { pipe(res); }, }); setTimeout(abort, 5000); // never exceed the crawler fetch window -
Confirm the streamed markup hydrates without mismatch. The client must hydrate the same tree the stream produced; a divergence here reintroduces the hydration mismatch problem.
The division that makes this work is what belongs in the shell versus what belongs behind a boundary.
Validation
-
Watch the chunks arrive.
curl -N https://example.com/routestreams the response; the<head>with title and canonical must appear in the first burst, before any list data. -
Measure both metrics, not one. Confirm TTFB with
curl -s -o /dev/null -w 'TTFB %{time_starttransfer}s\n' https://example.com/route, and measure LCP in DevTools or field data — a low TTFB with a high LCP means the largest element is in a late chunk. -
Verify metadata is in chunk one.
curl -sN https://example.com/route | head -c 2000 | grep -o '<title>[^<]*</title>\|rel="canonical"'should match on the first two kilobytes. -
Assert the head precedes body data. In a CI check, ensure the byte offset of the canonical tag is smaller than the offset of the first data-dependent block:
console.assert( html.indexOf('rel="canonical"') < html.indexOf('data-region="products"'), "Metadata must flush before data-dependent content", ); -
Inspect in GSC. URL Inspection’s rendered HTML should contain the full body and correct head, confirming the crawler read every chunk. See the audit workflow.
Common Pitfalls
- Metadata in a late chunk. If title or canonical resolves after body data, a crawler can index a default or empty head. Always flush the head first.
- No stream timeout. A single stalled query can hold the connection until the crawler gives up, truncating the document. Bound every stream with an abort.
- Optimising TTFB while ignoring LCP. A fast first byte with a slow largest paint still fails Core Web Vitals. Track both.
- Putting the LCP image behind the slowest boundary. The hero should be in the shell or an early chunk, not gated on a slow API.
- Streaming without hydration parity. If the client tree differs from the streamed HTML, content flashes away on hydration. Keep them identical.
The trap is measuring only the first byte: two routes can share a fast TTFB yet fail Core Web Vitals if the largest element streams late.
Frequently Asked Questions
Does a crawler wait for a streamed SSR response to finish?
Yes. Googlebot reads the response body to completion before parsing, so content that arrives in a late chunk is still indexed as long as the connection closes within its fetch timeout. The risk is not that late chunks are missed but that a very slow final chunk pushes total response time past the budget, leaving the document truncated.
Why does flushing the shell early improve TTFB but not always LCP?
Time to First Byte measures when the first byte arrives, so flushing the shell immediately lowers it dramatically. LCP measures when the largest content element paints, which often lives in a later data-dependent chunk. If that chunk resolves slowly, the first byte is fast but the meaningful paint is not, so the two metrics can move in opposite directions.
Should meta tags and canonicals be in the first streamed chunk?
Yes. The document head, including title, meta description, and canonical, must flush in the first chunk before any data-dependent body content. If metadata resolves in a late chunk, a crawler may parse the head before it arrives and index the page with a missing or default title.
Related
- Edge Rendering & CDN SEO — the section overview and how streaming compares to cached and edge SSR.
- Incremental & Streaming SSR for SEO — the origin-side deep dive on streaming and partial hydration.
- Reducing Layout Shift During React Hydration — keep CLS low as late chunks fill in.
- Edge-Side Rendering with Cloudflare Workers — where the streamed render runs at the edge.
← Back to Edge Rendering & CDN SEO