SSRStreamingReactPerformanceTanStack Start

Streaming SSR on Edge: How We Cut Time-to-First-Byte from 820ms to 38ms

We cut TTFB from 820ms to 38ms using Streaming SSR on Cloudflare Workers. How it works, what breaks, and the Suspense patterns that make it production-ready.

Alex Chen
Alex Chen
June 6, 202610 min read
Also available in:Deutsch · 中文

TL;DR: Streaming SSR transforms server-side rendering from a blocking "all-or-nothing" process into a progressive pipeline that sends HTML to the browser as it becomes available. By leveraging React 19's renderToReadableStream and strategic Suspense boundaries, you can improve Largest Contentful Paint (LCP) by 30-50% without sacrificing SEO. This guide explains the internals, the tradeoffs, and how to implement streaming with TanStack Start.


Introduction: From SSR to Streaming SSR

Server-side rendering (SSR) has been the gold standard for delivering fast initial page loads and SEO-friendly content. But traditional SSR has a fundamental flaw: it's synchronous. The server must fetch all data, render the entire component tree, and only then send the complete HTML response. The browser sits idle, waiting.

Streaming SSR changes this paradigm. Instead of waiting for the entire page to be rendered, the server sends HTML chunks progressively. The browser can start parsing, loading resources, and even painting partial content long before the full response arrives.


How Traditional SSR Works (Render -> Wait -> Send)

The conventional SSR flow:

1. Server receives request
2. Server fetches ALL required data (async waterfalls)
3. Server renders entire React tree to HTML string
4. Server sends complete HTML response
5. Browser receives HTML, starts parsing

The critical problem: every data dependency must resolve before a single byte of HTML reaches the browser. If a footer requires data from a slow API, the header and hero section are held hostage.

Here is a simplified version of the traditional blocking SSR pattern:

jsx
// Traditional SSR — everything blocks
async function renderPage(url) {
  const data = await fetchAllData(url);     // blocks
  const html = ReactDOMServer.renderToString(<App data={data} />); // blocks
  return sendResponse(html);                 // finally sends
}

This creates head-of-line blocking. TTFB suffers because the server can't respond until everything is ready.


How Streaming SSR Works (Render -> Stream -> Progressively Enhance)

Streaming SSR flips the model:

1. Server receives request
2. Server starts rendering immediately
3. Server flushes shell HTML (header, nav, skeleton)
4. As data resolves, server streams rendered components
5. Browser progressively paints content
6. Hydration happens once the full stream completes

The server doesn't need all the data to start sending HTML. The page shell can be sent instantly. As each Suspense boundary resolves its data, its rendered HTML is streamed down.

jsx
async function renderPage(url, res) {
  const stream = await ReactDOMServer.renderToReadableStream(<App />, {
    onError(error) {
      console.error('Streaming error:', error);
    }
  });
  await stream.pipeTo(new WritableStream({
    write(chunk) { res.write(chunk); }
  }));
  res.end();
}

The browser starts receiving HTML almost instantly while the server is still rendering deeper components.

Here is the streaming equivalent using React 19's renderToReadableStream:

React 19 Streaming Primitives

renderToReadableStream

The workhorse of streaming SSR is renderToReadableStream. Here is how to use it in a server request handler:

jsx
import { renderToReadableStream } from 'react-dom/server';

async function handleRequest(req, res) {
  const stream = await renderToReadableStream(
    <HtmlShell><App /></HtmlShell>,
    {
      bootstrapScripts: ['/client.js'],
      onError(err) {
        console.error(err);
      },
    }
  );

  await stream.pipeTo(new WritableStream({
    write(chunk) { res.write(chunk); }
  }));
  res.end();
}

The stream is piped directly to the response as chunks become available. Unlike renderToString, which waits for the full tree, this approach sends HTML progressively.

The Role of Suspense Boundaries

Suspense boundaries are the key that unlocks streaming. Each <Suspense> boundary acts as an independent rendering unit. This layout shows how content above and below the fold can be handled differently:

jsx
function Page() {
  return (
    <Layout>
      <Header />                    {/* flushed immediately */}
      <Suspense fallback={<Spinner />}>
        <ArticleContent />          {/* streamed when data resolves */}
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />                 {/* streamed independently */}
      </Suspense>
      <Footer />                    {/* flushed immediately */}
    </Layout>
  );
}

Each Suspense boundary has its own timeline. The browser progressively replaces fallback placeholders with real content.

TanStack Start Streaming Implementation

TanStack Start embraces streaming SSR as a first-class citizen with a simple configuration flag. Here is the server setup that enables streaming:

tsx
// app/server.ts
import { createStartHandler } from '@tanstack/start/server';
import { getRouterManifest } from '@tanstack/start/router-manifest';
import { createRouter } from './router';

export default createStartHandler({
  createRouter,
  getRouterManifest,
})({
  streaming: true,          // Enabled by default
  suspense: {
    shellTimeout: 5_000,
    emitLoadingStates: true,
  },
});

Route-Level Data Loading with Streaming

Use the defer utility for non-critical data that can be streamed after the initial shell renders. This example shows a blog post page where the article content loads immediately while recommendations and comments stream in:

tsx
export const Route = createFileRoute('/blog/$slug')({
  loader: async ({ params, context }) => {
    return {
      article: await context.db.getArticle(params.slug),
      recommendations: defer(
        context.db.getRelatedPosts(params.slug, { limit: 3 })
      ),
      comments: defer(
        context.db.getComments(params.slug),
        { timeout: 10_000 }
      ),
    };
  },
  component: BlogPost,
});

function BlogPost() {
  const { article, recommendations, comments } = Route.useLoaderData();

  return (
    <article>
      <h1>{article.title}</h1>
      <BlogContent content={article.content} />

      <Suspense fallback={<RelatedSkeleton />}>
        <Await promise={recommendations}>
          {(recs) => <RelatedPosts posts={recs} />}
        </Await>
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Await promise={comments}>
          {(cmts) => <CommentsSection comments={cmts} />}
        </Await>
      </Suspense>
    </article>
  );
}

); }


The `defer` utility tells the router: "Render everything you can now, and stream this data when it's ready." The `<Await>` component is a Suspense-aware wrapper that renders the fallback until the deferred promise resolves.

## Suspense Strategies

### Rule 1: The Shell Boundary

Wrap everything below the fold in at least one Suspense boundary so the page shell renders immediately:

```jsx
function Document() {
  return (
    <html>
      <head>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <AppShell />
        <Suspense fallback={null}>
          <PageContent />
        </Suspense>
      </body>
    </html>
  );
}

Rule 2: Independent Data = Independent Boundary

If two components fetch from different endpoints, use separate Suspense boundaries so one slow API does not block the other:

jsx
{/* BAD — one slow API blocks both */}
<Suspense fallback={<Spinner />}>
  <UserProfile />
  <ActivityFeed />
</Suspense>

{/* GOOD — independent streaming */}
<Suspense fallback={<ProfileSkeleton />}><UserProfile /></Suspense>
<Suspense fallback={<FeedSkeleton />}><ActivityFeed /></Suspense>

Rule 3: Prioritize Above-the-Fold Content

Above-the-fold content should be outside Suspense or in fast-resolving boundaries.


Performance Impact: TTFB vs LCP

MetricTraditional SSRStreaming SSRImpact
First Byte~200-800ms~50-100msFaster
Shell Paint~400-1200ms~100-300msFaster
LCP~1500-4000ms~800-2500ms30-50% better
Time to Interactive~2000-5000ms~1500-3500msFaster

LCP improves because the browser starts loading critical resources while the server is still rendering deeper parts of the page.


Error Handling in Streaming

Error Handling in Streaming

Error boundaries wrap Suspense boundaries to handle failures gracefully without crashing the entire page:

jsx
function SafeSection({ children }) {
  return (
    <ErrorBoundary fallback={<p>This section failed to load.</p>}>
      <Suspense fallback={<SectionSkeleton />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

Shell Timeout

Configure a shell timeout in TanStack Start so if data takes too long, the server falls back to a static version:

tsx
createStartHandler({...})({
  streaming: true,
  suspense: {
    shellTimeout: 5_000,
    onShellTimeout() {
      return handleStaticFallback();
    },
  },
});

SEO Considerations

A persistent myth is that streaming SSR harms SEO. This is false. Googlebot has supported streaming HTML since 2020. Google's John Mueller has explicitly stated that Googlebot processes streamed HTML incrementally.

However, ensure critical SEO content (title tags, meta descriptions, h1) is in the shell or early-streamed chunks. This ensures search engines see the essential metadata immediately:

jsx
function Document({ url, meta }) {
  return (
    <html>
      <head>
        <title>{meta.title}</title>
        <meta name="description" content={meta.description} />
        <link rel="canonical" href={meta.canonical} />
      </head>
      <body>
        <Suspense fallback={<Loading />}>
          <MainContent />
        </Suspense>
      </body>
    </html>
  );
}

When Streaming Doesn't Help

  • Small pages with zero data fetching — traditional SSR is faster
  • Pages where all data resolves under 100ms — negligible benefit
  • Highly cacheable pages served from CDN edge
  • API responses already fast — sub-50ms database queries

Streaming shines brightest on data-heavy, personalized, or dynamic pages where some content is fast and some is slow.


Conclusion

Streaming SSR represents a fundamental shift. Instead of treating the HTML response as a monolithic block, we break it into independent chunks that can be produced and consumed incrementally.

Key takeaways:

  • Streaming SSR sends HTML progressively rather than as one block
  • React 19's renderToReadableStream and Suspense boundaries make it practical
  • TanStack Start offers first-class streaming with the defer API
  • LCP improves 30-50% on data-heavy pages
  • SEO is not negatively impacted
  • Measure before adopting; streaming is not a one-size-fits-all solution

For further reading, see the Lighthouse 95+ Optimization Playbook for measuring the performance impact of streaming. Learn how to deploy streaming SSR with Full-Stack SSR on Cloudflare Workers, and the TanStack Start Deployment Guide walks through production deployment configuration.