Skip to main content
fast frontend

SSR, Streaming HTML, and Edge Rendering

8 min read Chapter 28 of 33

SSR, Streaming HTML, and Edge Rendering

The Problem SSR Solves

The e-commerce product listing page ships as a client-side rendered SPA. The browser downloads the HTML shell (2KB), then the JavaScript bundle (148KB gzipped), then executes the JavaScript to render the product grid. Only after JavaScript execution does the browser fetch the product data from the API. The rendering timeline:

t=0ms:      HTML shell received (2KB)
t=0ms:      Browser renders empty shell (blank white page)
t=0ms:      JS bundle download starts (148KB gzipped)
t=380ms:    JS bundle downloaded
t=380ms:    JS parsing and execution begins
t=620ms:    React hydration / initial render starts
t=620ms:    API fetch for products starts
t=940ms:    API response received (9.2KB)
t=960ms:    Product grid renders (First Contentful Paint)
t=960ms:    LCP element (first product image) starts loading
t=1,280ms:  LCP image loaded and rendered (LCP = 1,280ms)

The user sees a blank page for 960ms. The LCP is 1,280ms. JavaScript is a prerequisite for any content to appear.

With server-side rendering, the server generates the complete HTML including the product grid. The browser receives renderable HTML on the first response:

t=0ms:      HTML request sent
t=120ms:    Server fetches products from database (internal call)
t=180ms:    Server renders HTML with product grid (60ms render time)
t=180ms:    HTML response starts streaming (38KB)
t=320ms:    Browser receives enough HTML to render above-the-fold content
t=320ms:    First Contentful Paint (product grid visible)
t=320ms:    LCP image starts loading (URL is in the HTML, no JS needed)
t=520ms:    LCP image loaded (LCP = 520ms)
t=520ms:    JS bundle download starts (in parallel with HTML rendering)
t=900ms:    JS downloaded, parsed, hydration begins
t=1,020ms:  Hydration complete (page is interactive, TTI = 1,020ms)

LCP drops from 1,280ms to 520ms. The user sees content 640ms sooner. The LCP image URL is in the initial HTML response, so the browser discovers and starts downloading it 640ms earlier than in the CSR version.

The trade-off: the server now does rendering work (60ms per request) and the HTML response is larger (38KB vs 2KB). The Time to Interactive is comparable (1,020ms vs 960ms) because hydration cost roughly equals initial client-side render cost.

Streaming HTML

Standard SSR waits for the complete HTML string before sending the response. The server fetches data, renders the full page, then sends. If one data source is slow (the recommendation engine takes 400ms), the entire page is delayed by 400ms.

Streaming HTML sends the HTML as it is generated. The browser receives and renders the product grid while the server is still waiting for recommendations:

import { renderToPipeableStream } from 'react-dom/server';
import type { Request, Response } from 'express';
import { App } from './App';

function handleRequest(req: Request, res: Response): void {
  const { pipe } = renderToPipeableStream(
    <App url={req.url} />,
    {
      bootstrapScripts: ['/static/client.js'],
      onShellReady() {
        // Shell rendered: layout, nav, above-the-fold content
        // Send immediately without waiting for Suspense boundaries
        res.setHeader('Content-Type', 'text/html; charset=utf-8');
        res.statusCode = 200;
        pipe(res);
      },
      onError(error: unknown) {
        console.error('SSR error:', error);
        res.statusCode = 500;
        res.end('<!doctype html><p>Server error</p>');
      },
    }
  );
}

The React component tree uses Suspense boundaries to mark sections that can stream independently:

function ProductPage({ productId }: { productId: string }) {
  return (
    <Layout>
      {/* Shell: renders immediately */}
      <ProductHeader productId={productId} />
      <ProductImages productId={productId} />
      <ProductPrice productId={productId} />

      {/* Streams when data is ready */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={productId} />
      </Suspense>

      {/* Streams independently, does not block reviews */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={productId} />
      </Suspense>
    </Layout>
  );
}

The streaming timeline:

t=0ms:      Server starts processing request
t=40ms:     Product data fetched (fast: database query)
t=60ms:     Shell rendered: header, images, price
t=60ms:     HTML streaming begins to browser
t=200ms:    Browser renders shell (FCP = 200ms)
t=200ms:    LCP image starts loading (in the shell HTML)
t=280ms:    Reviews data fetched (slow: external API)
t=280ms:    Reviews HTML chunk streamed to browser
t=300ms:    Browser renders reviews in-place (replaces skeleton)
t=400ms:    LCP image loaded (LCP = 400ms)
t=460ms:    Recommendations data fetched (slowest: ML service)
t=460ms:    Recommendations HTML chunk streamed
t=480ms:    Browser renders recommendations in-place

The shell (header, images, price) renders at 200ms regardless of how slow the reviews or recommendations are. The slow data sources stream in when ready, each replacing their skeleton placeholder with rendered HTML.

Without streaming, the server waits for all three data sources (460ms total) before sending anything. With streaming, FCP is at 200ms (60ms server render + 140ms network).

Selective Hydration

Full hydration attaches event handlers to the entire server-rendered HTML tree. For the product detail page, this includes:

  • The navigation menu (click handlers, dropdown logic)
  • The image carousel (touch handlers, animation state)
  • The price display (no interactivity, static text)
  • The reviews section (pagination, rating filters)
  • The recommendations carousel (scroll handlers)

The price display and product description are static. Hydrating them attaches event handlers that will never fire and initializes React state that will never update. For a page with 2,800 DOM nodes, full hydration takes 120ms.

Selective hydration skips static regions:

import { lazy, Suspense } from "react";

// Interactive components: hydrated
const ImageCarousel = lazy(() => import("./ImageCarousel"));
const ReviewsSection = lazy(() => import("./ReviewsSection"));
const RecommendationsCarousel = lazy(() => import("./RecommendationsCarousel"));

function ProductDetailPage({ product }: { product: Product }) {
  return (
    <main>
      {/* Static: rendered as HTML, no hydration cost */}
      <h1>{product.name}</h1>
      <p className="text-2xl font-bold">{formatPrice(product.price)}</p>
      <div
        className="prose"
        dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
      />

      {/* Interactive: selectively hydrated */}
      <Suspense fallback={null}>
        <ImageCarousel images={product.images} />
      </Suspense>

      <Suspense fallback={null}>
        <ReviewsSection productId={product.id} />
      </Suspense>

      <Suspense fallback={null}>
        <RecommendationsCarousel productId={product.id} />
      </Suspense>
    </main>
  );
}

With React’s selective hydration, Suspense boundaries are hydrated independently and in priority order. If the user clicks on the reviews section before it is hydrated, React prioritizes hydrating that boundary first.

Hydration cost comparison:

StrategyDOM nodes hydratedHydration timeJS evaluated
Full hydration2,800120ms148KB
Selective hydration1,20052ms86KB
Delta-1,600-68ms-62KB

The 68ms hydration reduction directly improves Time to Interactive. Combined with streaming SSR, the product detail page achieves FCP at 200ms, LCP at 400ms, and TTI at 680ms.

Edge Rendering

Edge rendering moves the SSR compute from a central origin server to CDN edge nodes geographically close to users. The theory: eliminating network latency between user and server reduces Time to First Byte (TTFB).

For a user in Paris accessing an origin server in US-East:

MetricOrigin (US-East)Edge (Paris)Delta
Network RTT85ms8ms-77ms
TTFB205ms128ms-77ms
FCP (with streaming)345ms268ms-77ms
LCP545ms468ms-77ms

The improvement is exactly the network RTT delta. Edge rendering does not speed up the rendering itself, it eliminates the distance penalty.

The cost: edge functions run in constrained environments. Cold starts add 50-200ms to the first request after a period of inactivity. If the edge function needs to call the origin database, the data fetch latency from the edge to the origin is the same 85ms the user would have paid directly.

// Edge function: Cloudflare Workers example
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname.startsWith("/product/")) {
      const productId = url.pathname.split("/")[2];

      // Problem: this calls the origin database
      // Edge-to-origin latency = 85ms (same as user-to-origin)
      const product = await fetch(
        `https://api.origin.example.com/products/${productId}`,
      ).then((r) => r.json());

      const html = renderProductPage(product);
      return new Response(html, {
        headers: { "Content-Type": "text/html" },
      });
    }

    return fetch(request);
  },
};

Edge rendering only reduces total latency when the data is also at the edge. This works for:

  • Static or semi-static content (product catalog that updates hourly)
  • Data replicated to edge KV stores
  • Responses that can be cached at the edge with stale-while-revalidate

It does not work for:

  • Real-time data (stock levels, order status)
  • User-specific data (cart, account)
  • Data requiring transactional consistency

For the e-commerce platform: product listing pages benefit from edge rendering (catalog data is semi-static, replicated to edge KV). The checkout page does not benefit (user-specific, transactional). The inventory dashboard does not benefit (real-time stock data from origin database).

The CI performance gate from Chapter 2 measures TTFB from a single CI location. It catches origin rendering regressions but does not measure edge latency improvements. A synthetic monitoring tool (WebPageTest with multiple test locations) is needed to validate edge rendering benefits across geographies.