Skip to content

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.

4 min read
Comparison diagram showing different rendering strategies from full SSR to partial hydration with their performance characteristics and trade-offs

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.

tstypescript
// ❌ 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
tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX