Server-Side Rendering Patterns and Performance Trade-offs
SSR architecture patterns — streaming SSR, selective hydration, island architecture and partial prerendering — with real measurements and how to choose.

Server-side rendering isn't a single technique—it's a spectrum of approaches with fundamentally different performance characteristics. Full SSR, streaming SSR, static generation, island architecture, and partial prerendering each optimize for different metrics. Choosing the wrong pattern for your use case doesn't just waste engineering effort—it can make performance worse than client-side rendering.
The decision isn't "SSR or not." It's "which rendering pattern for which part of the page."
Full SSR: The Baseline
Traditional SSR renders the entire page on the server for every request. The browser receives complete HTML, displays it immediately, then hydrates it with JavaScript to make it interactive.
// ❌ 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}
/>
);
}Full SSR gives excellent First Contentful Paint (FCP) but blocks Time to First Byte (TTFB) on the slowest data dependency. For pages with multiple data sources, this delay compounds.
Streaming SSR: Progressive Rendering
Streaming SSR sends HTML as it's generated, allowing the browser to start painting before data fetching finishes. React's renderToPipeableStream enables this natively.
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);
}The browser receives the page shell in milliseconds. Each Suspense boundary resolves independently, streaming its content when the data arrives. Slow data sources don't block fast ones.
Selective Hydration
Full hydration downloads and executes JavaScript for the entire page, even for static content that will never be interactive. Selective hydration limits JavaScript execution to components that actually need interactivity.
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}</>;
}Island Architecture
Islands take selective hydration to its logical extreme: the page is static HTML by default, with isolated "islands" of interactivity that hydrate independently.
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);
}
}Islands excel for content-heavy sites where most of the page is static. A blog with an interactive search bar and comment section doesn't need JavaScript for the article content—only the two interactive islands.
Measuring Rendering Performance
Different rendering patterns optimize for different metrics. Measure what matters for your users.
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
};Key Takeaways
Server-side rendering is a spectrum, not a binary choice—full SSR, streaming, selective hydration, and islands each optimize for different performance metrics. Streaming SSR with Suspense boundaries eliminates the TTFB penalty of waiting for slow data sources by sending the page shell immediately and streaming content as it resolves. Selective hydration reduces Time to Interactive by deferring JavaScript execution for components that aren't immediately needed, using visibility and interaction triggers. Island architecture provides the best performance for content-heavy pages by treating interactivity as the exception rather than the rule—static HTML by default, JavaScript only where needed. Measure TTFB, FCP, LCP, TTI, and JavaScript payload size to compare approaches quantitatively rather than guessing. The right pattern depends on your page's ratio of static content to interactive elements—a dashboard needs different rendering than a blog post. Combine patterns within a single application, using streaming SSR for dynamic pages and static generation with islands for content pages.


