Streaming SSR Deep Dive: So funktioniert es und Implementierungsleitfaden

Technischer Deep Dive in Streaming SSR – Funktionsweise, Leistungsverbesserungen um 30-50 % bei LCP und Implementierung mit TanStack Start.

Alex Chen
Alex Chen
6. Juni 202610 min read
Auch verfügbar auf:English · 中文

Kurzfassung: Streaming SSR verwandelt das serverseitige Rendering von einem blockierenden „Alles-oder-Nichts"-Prozess in eine progressive Pipeline, die HTML an den Browser sendet, sobald es verfügbar ist. Durch die Nutzung von React 19s renderToReadableStream und strategisch platzierten Suspense-Grenzen lässt sich die Largest Contentful Paint (LCP) um 30-50 % verbessern, ohne SEO zu opfern. Dieser Leitfaden erklärt die internen Mechanismen, die Trade-offs und die Implementierung von Streaming mit TanStack Start.


Einleitung: Von SSR zu Streaming SSR

Server-side Rendering (SSR) galt lange als Goldstandard für schnelle initiale Seitenladezeiten und SEO-freundliche Inhalte. Doch herkömmliches SSR hat einen grundlegenden Nachteil: Es ist synchron. Der Server muss sämtliche Daten abrufen, den gesamten Komponentenbaum rendern und erst dann die fertige HTML-Antwort senden. Der Browser wartet untätig.

Streaming SSR kehrt dieses Paradigma um. Anstatt auf das vollständig gerenderte Seitenlayout zu warten, sendet der Server HTML-Chunks progressiv. Der Browser kann bereits mit dem Parsen beginnen, Ressourcen laden und sogar Teilinhalte darstellen, lange bevor die vollständige Antwort eingetroffen ist.


Funktionsweise von herkömmlichen SSR (Rendern → Warten → Senden)

Der konventionelle SSR-Ablauf:

1. Server empfängt Anfrage
2. Server ruft ALLE benötigten Daten ab (asynchrone Wasserfälle)
3. Server rendert gesamten React-Baum zu HTML-String
4. Server sendet vollständige HTML-Antwort
5. Browser empfängt HTML und beginnt mit dem Parsen

Das kritische Problem: Jede Datendependenz muss aufgelöst sein, bevor auch nur ein einziges Byte HTML den Browser erreicht. Wenn ein Footer Daten von einer langsamen API benötigt, werden Header und Hero-Section als Geiseln gehalten.

Hier eine vereinfachte Version des herkömmlichen blockierenden SSR-Musters:

jsx
// Traditionelles SSR – alles blockiert
async function renderPage(url) {
  const data = await fetchAllData(url);     // blockiert
  const html = ReactDOMServer.renderToString(<App data={data} />); // blockiert
  return sendResponse(html);                 // endlich wird gesendet
}

Dies erzeugt ein Head-of-Line-Blocking. TTFB leidet darunter, weil der Server nicht antworten kann, bis alles bereit ist.


Funktionsweise von Streaming SSR (Rendern → Streamen → Progressiv Verbessern)

Streaming SSR kehrt das Modell um:

1. Server empfängt Anfrage
2. Server beginnt sofort mit dem Rendern
3. Server sendet Shell-HTML (Header, Nav, Skeleton)
4. Sobald Daten aufgelöst sind, streamt der Server gerenderte Komponenten
5. Browser zeichnet Inhalte progressiv auf
6. Hydration findet statt, sobald der vollständige Stream abgeschlossen ist

Der Server braucht nicht alle Daten, um mit dem Senden von HTML zu beginnen. Das Seitengrundgerüst kann sofort gesendet werden. Sobald jede Suspense-Grenze ihre Daten auflöst, wird das gerenderte HTML gestreamt.

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();
}

Der Browser empfängt HTML nahezu sofort, während der Server noch tieferliegende Komponenten rendert.

Hier das Streaming-Äquivalent mit React 19s renderToReadableStream:

Streaming-Primitiven in React 19

renderToReadableStream

Das Arbeitstier des Streaming SSR ist renderToReadableStream. So wird es in einem Server-Request-Handler verwendet:

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();
}

Der Stream wird direkt an die Antwort weitergeleitet, sobald Chunks verfügbar sind. Anders als renderToString, das auf den gesamten Baum wartet, sendet dieser Ansatz HTML progressiv.

Die Rolle von Suspense-Grenzen

Suspense-Grenzen sind der Schlüssel, der Streaming ermöglicht. Jede <Suspense>-Grenze fungiert als unabhängige Rendereinheit. Dieses Layout zeigt, wie Inhalte über und unter dem Fold unterschiedlich behandelt werden können:

jsx
function Page() {
  return (
    <Layout>
      <Header />                    {/* sofort gesendet */}
      <Suspense fallback={<Spinner />}>
        <ArticleContent />          {/* gestreamt, sobald Daten bereit */}
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />                 {/* unabhängig gestreamt */}
      </Suspense>
      <Footer />                    {/* sofort gesendet */}
    </Layout>
  );
}

Jede Suspense-Grenze hat ihren eigenen Zeitplan. Der Browser ersetzt progressiv Fallback-Platzhalter durch echte Inhalte.

Streaming-Implementierung mit TanStack Start

TanStack Start integriert Streaming SSR als erstklassiges Feature mit einem einfachen Konfigurationsflag. Hier die Server-Einrichtung, die Streaming aktiviert:

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,          // standardmäßig aktiviert
  suspense: {
    shellTimeout: 5_000,
    emitLoadingStates: true,
  },
});

Routing-Level-Datenladen mit Streaming

Das defer-Utility wird für nicht-kritische Daten verwendet, die nach dem initialen Shell-Rendering gestreamt werden können. Dieses Beispiel zeigt eine Blogpost-Seite, bei der der Artikelinhalt sofort lädt, während Empfehlungen und Kommentare gestreamt werden:

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>
  );
}

); }


Das `defer`-Utility weist den Router an: „Rendere jetzt alles, was möglich ist, und streame diese Daten, wenn sie bereit sind." Die `<Await>`-Komponente ist ein Suspense-bewusster Wrapper, der den Fallback rendert, bis das deferred Promise aufgelöst ist.

## Suspense-Strategien

### Regel 1: Die Shell-Grenze

Alle Inhalte unter dem Fold in mindestens eine Suspense-Grenze einwickeln, damit das Seitengrundgerüst sofort gerendert wird:

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

Regel 2: Unabhängige Daten = Unabhängige Grenze

Wenn zwei Komponenten von unterschiedlichen Endpunkten abrufen, verwenden Sie separate Suspense-Grenzen, damit eine langsame API nicht die andere blockiert:

jsx
{/* SCHLECHT – eine langsame API blockiert beide */}
<Suspense fallback={<Spinner />}>
  <UserProfile />
  <ActivityFeed />
</Suspense>

{/* GUT – unabhängiges Streaming */}
<Suspense fallback={<ProfileSkeleton />}><UserProfile /></Suspense>
<Suspense fallback={<FeedSkeleton />}><ActivityFeed /></Suspense>

Regel 3: Priorität für Above-the-Fold-Inhalte

Above-the-Fold-Inhalte sollten außerhalb von Suspense oder in schnell auflösenden Grenzen platziert werden.


Leistungsauswirkungen: TTFB vs. LCP

MetrikTraditionelles SSRStreaming SSRAuswirkung
First Byte~200-800ms~50-100msSchneller
Shell Paint~400-1200ms~100-300msSchneller
LCP~1500-4000ms~800-2500ms30-50 % besser
Time to Interactive~2000-5000ms~1500-3500msSchneller

LCP verbessert sich, weil der Browser kritische Ressourcen lädt, während der Server noch tieferliegende Seitenbereiche rendert.


Fehlerbehandlung beim Streaming

Error Boundaries beim Streaming

Error Boundaries umschließen Suspense-Grenzen, um Fehler elegant zu behandeln, ohne die gesamte Seite zum Absturz zu bringen:

jsx
function SafeSection({ children }) {
  return (
    <ErrorBoundary fallback={<p>Dieser Abschnitt konnte nicht geladen werden.</p>}>
      <Suspense fallback={<SectionSkeleton />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

Shell Timeout

Konfigurieren Sie ein Shell-Timeout in TanStack Start, damit der Server auf eine statische Version zurückfällt, wenn das Laden zu lange dauert:

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

SEO-Überlegungen

Ein hartnäckiger Mythos besagt, dass Streaming SSR die SEO beeinträchtigt. Das ist falsch. Googlebot unterstützt Streaming HTML seit 2020. Googles John Mueller hat ausdrücklich bestätigt, dass Googlebot gestreamtes HTML inkrementell verarbeitet.

Stellen Sie dennoch sicher, dass kritische SEO-Inhalte (Title-Tags, Meta-Descriptions, H1) in der Shell oder früh gestreamten Chunks enthalten sind. So sehen Suchmaschinen die wesentlichen Metadaten sofort:

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>
  );
}

Wann Streaming nicht hilft

  • Kleine Seiten ohne Datenabrufe – traditionelles SSR ist schneller
  • Seiten, auf denen alle Daten in unter 100 ms aufgelöst werden – kaum messbarer Vorteil
  • Stark缓存fähige Seiten, die vom CDN Edge ausgeliefert werden
  • API-Antworten, die bereits schnell sind – Datenbankabfragen unter 50 ms

Streaming entfaltet seine größte Wirkung bei datenreichen, personalisierten oder dynamischen Seiten, bei denen ein Teil der Inhalte schnell und ein anderer langsam ist.


Fazit

Streaming SSR stellt einen grundlegenden Paradigmenwechsel dar. Anstatt die HTML-Antwort als monolithischen Block zu behandeln, wird sie in unabhängige Chunks aufgeteilt, die inkrementell erzeugt und verarbeitet werden können.

Wichtigste Erkenntnisse:

  • Streaming SSR sendet HTML progressiv, nicht als einen Block
  • React 19s renderToReadableStream und Suspense-Grenzen machen dies praktikabel
  • TanStack Start bietet erstklassiges Streaming mit der defer-API
  • LCP verbessert sich um 30-50 % bei datenreichen Seiten
  • SEO wird nicht negativ beeinträchtigt
  • Messen Sie vor der Einführung; Streaming ist keine Allzwecklösung

Weitere Informationen finden Sie im Lighthouse 95+ Optimization Playbook zur Messung der Leistungsauswirkungen von Streaming. Erfahren Sie, wie Sie Streaming SSR mit Full-Stack SSR on Cloudflare Workers bereitstellen, und der TanStack Start Deployment Guide führt durch die Produktions-Deploying-Konfiguration.