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.

Flush order of a streamed SSR response A timeline showing the head and shell flushing first for a fast first byte, suspended data chunks arriving next, and the largest paint element landing later, with the crawler reading to completion. Response time → Chunk 1 head + shell title, meta, canonical Chunk 2 suspended data list, sidebar Chunk 3 largest element drives LCP TTFB here LCP here crawler reads all chunks to completion before parsing
The first byte lands with chunk one; the largest paint can wait for chunk three — which is why TTFB and LCP diverge under streaming.

Step-by-step fix

  1. 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);
      },
    });
  2. 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>
  3. 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.

  4. 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
  5. 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.

What flushes in the shell versus behind a Suspense boundary The document shell carrying head and primary content flushes immediately, while data-dependent regions wrapped in Suspense stream in a later chunk. Shell — flushes now (chunk 1) head title, meta description, canonical h1 + summary + layout the LCP element lives here Suspense boundary streams in chunk 2 ProductList / Reviews fallback shows until data resolves
Keep the head and the largest paint in the shell; wrap only data-dependent regions in Suspense so they stream without blocking the first byte.

Validation

  • Watch the chunks arrive. curl -N https://example.com/route streams 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.

Four checks that a streamed response stays indexable Each validation command maps to what it proves: streaming the response shows the head arrives first, timing both TTFB and LCP catches a slow paint, grepping the first two kilobytes confirms metadata is early, and a byte-offset assertion proves the canonical precedes body data. Each check, and what it proves about the stream curl -N …/route stream the response body Head arrives in the first burst title and canonical before any list data curl -w time_starttransfer plus LCP in DevTools / field data Both TTFB and LCP measured a fast byte can hide a slow paint head -c 2000 | grep title read only the first 2 KB Metadata lands in the first 2 KB crawler parses a complete head idx(canonical) < idx(data) a CI byte-offset assertion Canonical precedes body data head flushes before suspended regions
Four commands, each proving one property of a safely streamed response: the head flushes first, both metrics are tracked, metadata sits in the opening bytes, and the canonical precedes the data-dependent body.

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.

LCP when the largest element streams late versus early A bar chart comparing Largest Contentful Paint for a hero gated behind the slowest boundary against a hero placed in an early chunk, with the good LCP threshold marked. LCP (ms) good LCP under 2500ms 3400ms hero behind slowest boundary 1600ms hero in an early chunk TTFB stays near 60ms in both — a fast first byte does not guarantee a fast paint
Both flush orders share a low first byte, but only keeping the largest element out of the slowest boundary brings LCP under the threshold.

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.

← Back to Edge Rendering & CDN SEO