SSR-Muster und ihre Performance-Kompromisse
SSR-Architekturmuster: Streaming SSR, selektive Hydration, Insel-Architektur und partielles Prerendering — mit Messwerten und Entscheidungshilfen.

SSR ist keine einzelne Technik, sondern ein Spektrum von Ansätzen mit grundlegend unterschiedlichen Performance-Eigenschaften. Vollständiges SSR, streaming SSR, statische Generierung, Insel-Architektur und partielles Prerendering optimieren jeweils andere Metriken. Wählst du für deinen Anwendungsfall das falsche Muster, verschwendest du damit nicht nur Entwicklungsaufwand – die Performance kann sogar schlechter ausfallen als bei clientseitigem Rendering.
Die Entscheidung lautet nicht „SSR oder nicht". Sie lautet „welches Rendering-Muster für welchen Teil der Seite".
Vollständiges SSR: die Baseline
Klassisches SSR rendert bei jeder Anfrage die komplette Seite auf dem Server. Der Browser erhält vollständiges HTML, zeigt es sofort an und führt anschließend mittels JavaScript eine Hydration durch, um die Seite interaktiv zu machen.
// ❌ Full SSR with waterfall data fetching
async function renderPage(req: Request): Promise<string> {
// Sequential fetches — each waits for the previous
const user = await fetchUser(req.userId);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts.map((p) => p.id));
// Nothing renders until ALL data is ready
return renderToString(
<Page user={user} posts={posts} comments={comments} />
);
}
// Time to First Byte: sum of all fetch latencies// ✅ Full SSR with parallel data fetching
async function renderPage(req: Request): Promise<string> {
// Parallel fetches — total time = max of individual fetches
const [user, posts, siteConfig] = await Promise.all([
fetchUser(req.userId),
fetchPosts(req.userId),
fetchSiteConfig(),
]);
// Comments depend on posts, but we didn't block the others
const comments = await fetchComments(
posts.map((p) => p.id)
);
return renderToString(
<Page
user={user}
posts={posts}
comments={comments}
config={siteConfig}
/>
);
}Vollständiges SSR liefert ein hervorragendes First Contentful Paint (FCP), blockiert aber die Time to First Byte (TTFB), bis die langsamste Datenabhängigkeit aufgelöst ist. Bei Seiten mit mehreren Datenquellen summiert sich diese Verzögerung.
Streaming SSR: progressives Rendering
Streaming SSR sendet HTML bereits während der Generierung, sodass der Browser mit dem Rendern beginnen kann, bevor das Laden der Daten abgeschlossen ist. Reacts renderToPipeableStream ermöglicht das nativ.
import { renderToPipeableStream } from "react-dom/server";
import { Suspense } from "react";
function App() {
return (
<html>
<head>
<title>Dashboard</title>
</head>
<body>
{/* Shell renders immediately */}
<Header />
<Navigation />
{/* Each Suspense boundary streams independently */}
<Suspense fallback={<PostsSkeleton />}>
<PostsFeed />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</body>
</html>
);
}
function handleRequest(req: Request, res: Response): void {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
// Shell (everything outside Suspense) is ready
res.setHeader("Content-Type", "text/html");
res.statusCode = 200;
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.end("Server error");
console.error("Shell render failed:", error);
},
onError(error) {
console.error("Streaming error:", error);
},
});
// Timeout: abort if rendering takes too long
setTimeout(() => abort(), 10_000);
}Der Browser erhält die Shell der Seite innerhalb von Millisekunden. Jede Suspense-Grenze löst sich unabhängig auf und überträgt ihren Inhalt per Streaming, sobald die Daten eintreffen. Langsame Datenquellen blockieren schnelle nicht.
Selektive Hydration
Bei vollständiger Hydration wird für die gesamte Seite JavaScript heruntergeladen und ausgeführt, selbst für statische Inhalte, die nie interaktiv werden. Selektive Hydration beschränkt die JavaScript-Ausführung auf Komponenten, die tatsächlich Interaktivität benötigen.
interface HydrationStrategy {
type: "eager" | "idle" | "visible" | "interaction" | "none";
priority?: "high" | "low";
}
// Component-level hydration directives
function HydrateOn({
strategy,
children,
}: {
strategy: HydrationStrategy;
children: React.ReactNode;
}) {
if (typeof window === "undefined") {
// Server: render normally
return <>{children}</>;
}
// Client: defer hydration based on strategy
switch (strategy.type) {
case "visible":
return <HydrateOnVisible>{children}</HydrateOnVisible>;
case "idle":
return <HydrateOnIdle>{children}</HydrateOnIdle>;
case "interaction":
return (
<HydrateOnInteraction>{children}</HydrateOnInteraction>
);
case "none":
// Static HTML — never hydrate
return (
<div
dangerouslySetInnerHTML={{
__html: "", // Server HTML preserved
}}
/>
);
default:
return <>{children}</>;
}
}
function HydrateOnVisible({
children,
}: {
children: React.ReactNode;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const [shouldHydrate, setShouldHydrate] = React.useState(false);
React.useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect();
}
},
{ rootMargin: "200px" }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
if (!shouldHydrate) {
return <div ref={ref}>{children}</div>;
}
return <>{children}</>;
}Insel-Architektur
Inseln treiben die selektive Hydration auf die Spitze: Die Seite besteht standardmäßig aus statischem HTML, mit isolierten „Inseln" der Interaktivität, die jeweils unabhängig voneinander ihre eigene Hydration durchführen.
interface IslandDefinition {
component: string;
props: Record<string, unknown>;
hydration: "load" | "idle" | "visible" | "media";
mediaQuery?: string;
}
// Server-side island renderer
function renderIsland(island: IslandDefinition): string {
const propsJson = JSON.stringify(island.props);
const componentHtml = renderComponentToString(
island.component,
island.props
);
return `
<div
data-island="${island.component}"
data-props='${propsJson}'
data-hydrate="${island.hydration}"
${island.mediaQuery ? `data-media="${island.mediaQuery}"` : ""}
>
${componentHtml}
</div>
`;
}
// Client-side island hydration controller
class IslandController {
private hydrated: Set<HTMLElement> = new Set();
init(): void {
const islands = document.querySelectorAll<HTMLElement>(
"[data-island]"
);
for (const el of islands) {
const strategy = el.dataset.hydrate ?? "load";
this.scheduleHydration(el, strategy);
}
}
private scheduleHydration(
el: HTMLElement,
strategy: string
): void {
switch (strategy) {
case "load":
this.hydrate(el);
break;
case "idle":
if ("requestIdleCallback" in window) {
requestIdleCallback(() => this.hydrate(el));
} else {
setTimeout(() => this.hydrate(el), 200);
}
break;
case "visible": {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
this.hydrate(el);
observer.disconnect();
}
});
observer.observe(el);
break;
}
}
}
private async hydrate(el: HTMLElement): Promise<void> {
if (this.hydrated.has(el)) return;
const componentName = el.dataset.island;
if (!componentName) return;
const props = JSON.parse(el.dataset.props ?? "{}");
// Dynamic import — only load JS for this island
const module = await import(
`./islands/${componentName}.js`
);
module.default.hydrate(el, props);
this.hydrated.add(el);
}
}Inseln eignen sich besonders für inhaltsstarke Websites, bei denen der Großteil der Seite statisch ist. Ein Blog mit einer interaktiven Suchleiste und einem Kommentarbereich braucht für den Artikelinhalt selbst kein JavaScript – nur für diese beiden interaktiven Inseln.
Rendering-Performance messen
Unterschiedliche Rendering-Muster optimieren unterschiedliche Metriken. Miss, was für deine Nutzer wirklich zählt.
interface RenderingMetrics {
ttfb: number; // Time to First Byte
fcp: number; // First Contentful Paint
lcp: number; // Largest Contentful Paint
tti: number; // Time to Interactive
tbt: number; // Total Blocking Time
hydrationTime: number; // Time spent hydrating
jsPayload: number; // JavaScript bytes sent
}
// Comparison for a typical content page:
const fullSsr: RenderingMetrics = {
ttfb: 800, // Blocked on slowest data fetch
fcp: 900, // Fast after TTFB
lcp: 950, // Full content in first paint
tti: 2500, // Must hydrate entire page
tbt: 600, // Hydration blocks main thread
hydrationTime: 400,
jsPayload: 250_000,
};
const streamingSsr: RenderingMetrics = {
ttfb: 100, // Shell sent immediately
fcp: 200, // Shell paints fast
lcp: 850, // Main content streams in
tti: 2200, // Still hydrates full page
tbt: 500,
hydrationTime: 350,
jsPayload: 250_000,
};
const islandArchitecture: RenderingMetrics = {
ttfb: 150, // Static shell
fcp: 250, // Fast static render
lcp: 300, // Content is static HTML
tti: 600, // Only islands need JS
tbt: 80, // Minimal JS execution
hydrationTime: 50, // Only interactive islands
jsPayload: 45_000, // Dramatically less JS
};Die wichtigsten Erkenntnisse
SSR ist ein Spektrum, keine binäre Entscheidung – vollständiges SSR, Streaming, selektive Hydration und Inseln optimieren jeweils andere Performance-Metriken. Streaming SSR mit Suspense-Grenzen beseitigt die TTFB-Strafe des Wartens auf langsame Datenquellen, indem die Shell der Seite sofort gesendet und der Inhalt per Streaming nachgeliefert wird, sobald er verfügbar ist. Selektive Hydration verkürzt die Time to Interactive, indem die JavaScript-Ausführung für nicht sofort benötigte Komponenten anhand von Sichtbarkeits- und Interaktions-Triggern verzögert wird. Insel-Architektur liefert die beste Performance für inhaltsstarke Seiten, weil Interaktivität als Ausnahme statt als Regel behandelt wird – standardmäßig statisches HTML, JavaScript nur dort, wo es gebraucht wird. Miss TTFB, FCP, LCP, TTI und die Größe der JavaScript-Payload, um Ansätze quantitativ zu vergleichen, statt zu raten. Das richtige Muster hängt vom Verhältnis von statischem Inhalt zu interaktiven Elementen auf deiner Seite ab – ein Dashboard braucht ein anderes Rendering als ein Blogbeitrag. Kombiniere Patterns innerhalb einer einzigen Anwendung, etwa streaming SSR für dynamische Seiten und statische Generierung mit Inseln für Content-Seiten.


