Skip to main content
fast frontend

Edge Rendering and Static Generation Hybrid

8 min read Chapter 30 of 33

Edge Rendering and Static Generation Hybrid

The Symptom

The e-commerce platform renders all pages with origin SSR. The about page, the FAQ page, and the blog posts are server-rendered on every request. These pages have static content that changes when a developer pushes a code update, not when a user makes a request. The origin server handles 12,000 SSR requests per minute for pages that produce identical HTML every time.

The Cause

The rendering strategy is uniform: every page uses the same SSR path regardless of how dynamic its content is. Static pages (about, FAQ, blog) do not need per-request rendering. Product listing pages change when the catalog updates (once per hour). Only checkout and cart pages need per-request rendering with real-time data.

Three rendering tiers exist, but a single rendering path handles all of them:

TierContent FreshnessExamplesCorrect Strategy
StaticDeploy-timeAbout, FAQ, blogBuild-time generation
Semi-staticMinutes to hoursProduct listings, categoriesEdge + stale-while-revalidate
DynamicPer-requestCart, checkout, inventory dashboardOrigin SSR

The Baseline

All pages rendered at origin:

Page TypeRequests/minServer render timeTTFB (p50)TTFB (p95)
Static (about, FAQ)80015ms120ms180ms
Product listing6,40060ms165ms280ms
Product detail4,20045ms150ms240ms
Checkout60080ms185ms320ms
Total12,000N/AN/AN/A

Origin CPU utilization: 68% from SSR. 7,200 of 12,000 requests per minute produce identical HTML.

The Fix

Static Generation at Build Time

Pages with deploy-time content are pre-rendered during the build:

// Build script: generate static HTML for static pages
import { renderToString } from 'react-dom/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';

interface StaticRoute {
  path: string;
  component: () => JSX.Element;
}

const STATIC_ROUTES: StaticRoute[] = [
  { path: '/about', component: AboutPage },
  { path: '/faq', component: FAQPage },
  { path: '/privacy', component: PrivacyPage },
  { path: '/terms', component: TermsPage },
];

async function generateStaticPages(outDir: string): Promise<void> {
  for (const route of STATIC_ROUTES) {
    const html = renderToString(<route.component />);
    const fullHtml = wrapInShell(html, route.path);

    const dir = join(outDir, route.path);
    await mkdir(dir, { recursive: true });
    await writeFile(join(dir, 'index.html'), fullHtml);
  }
}

Static pages are served directly from the CDN with no server involvement. TTFB is the CDN edge latency (8-15ms). No origin CPU is consumed. The pages are regenerated only when the build runs.

Incremental Static Regeneration for Semi-Static Pages

Product listing pages change when the catalog updates. Instead of rebuilding all 200 category pages on every catalog change, pages regenerate on demand with a staleness window:

// Edge function: ISR with stale-while-revalidate
interface CachedPage {
  html: string;
  generatedAt: number;
  etag: string;
}

const STALE_AFTER_MS = 60 * 60 * 1000; // 1 hour

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const cacheKey = `page:${url.pathname}`;

    // Check edge KV for cached page
    const cached = await env.PAGES_KV.get<CachedPage>(cacheKey, "json");

    if (cached) {
      const age = Date.now() - cached.generatedAt;

      if (age < STALE_AFTER_MS) {
        // Fresh: serve directly
        return new Response(cached.html, {
          headers: {
            "Content-Type": "text/html; charset=utf-8",
            "Cache-Control": "public, max-age=3600",
            "X-Render-Source": "edge-kv-fresh",
          },
        });
      }

      // Stale: serve stale, revalidate in background
      const staleResponse = new Response(cached.html, {
        headers: {
          "Content-Type": "text/html; charset=utf-8",
          "Cache-Control": "public, max-age=60",
          "X-Render-Source": "edge-kv-stale",
        },
      });

      // Background revalidation (does not block response)
      const revalidationContext = env.waitUntil ?? globalThis.waitUntil;
      if (revalidationContext) {
        revalidationContext(revalidatePage(env, cacheKey, url.pathname));
      }

      return staleResponse;
    }

    // Cache miss: render at origin, cache result
    return renderAndCache(env, cacheKey, url.pathname);
  },
};

async function revalidatePage(
  env: Env,
  cacheKey: string,
  pathname: string,
): Promise<void> {
  const originResponse = await fetch(`${env.ORIGIN_URL}${pathname}?ssr=true`);
  const html = await originResponse.text();

  await env.PAGES_KV.put(
    cacheKey,
    JSON.stringify({
      html,
      generatedAt: Date.now(),
      etag: generateEtag(html),
    }),
  );
}

async function renderAndCache(
  env: Env,
  cacheKey: string,
  pathname: string,
): Promise<Response> {
  const originResponse = await fetch(`${env.ORIGIN_URL}${pathname}?ssr=true`);
  const html = await originResponse.text();

  // Cache in edge KV
  await env.PAGES_KV.put(
    cacheKey,
    JSON.stringify({
      html,
      generatedAt: Date.now(),
      etag: generateEtag(html),
    }),
  );

  return new Response(html, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
      "Cache-Control": "public, max-age=3600",
      "X-Render-Source": "origin-rendered",
    },
  });
}

The first request for a category page after a catalog update triggers an origin render. Subsequent requests in the next hour serve the cached HTML from the edge KV store. After an hour, the next request serves stale content and triggers background revalidation.

Cold Start Mitigation

Edge functions have cold start latency. The first request after a period of inactivity pays 50-200ms for the runtime to initialize. For the e-commerce platform with 200 category pages across 30 edge locations, keeping every function warm is not feasible (6,000 warm instances).

Mitigation strategies:

// 1. Cron-based warming: ping high-traffic routes every 5 minutes
// Scheduled worker runs at edge locations
export default {
  async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
    const highTrafficRoutes = [
      "/products/electronics",
      "/products/clothing",
      "/products/home",
      "/",
    ];

    await Promise.all(
      highTrafficRoutes.map((route) =>
        fetch(`${env.EDGE_URL}${route}`, {
          headers: { "X-Warm-Request": "true" },
        }),
      ),
    );
  },
};

// 2. Lightweight edge function: minimize cold start by minimizing code
// Import only what the edge function needs
// Do NOT bundle the full React renderer at the edge
// The edge function serves pre-rendered HTML from KV; it does not render

The critical insight: the edge function should not render HTML. It should serve pre-rendered HTML from KV. Rendering happens at the origin during revalidation. The edge function is a cache-serving layer, not a rendering layer. This keeps the edge function small (< 1KB of logic), reducing cold start to 5-15ms.

Hybrid Routing Configuration

// Router: direct requests to the correct rendering tier
interface RouteConfig {
  pattern: RegExp;
  tier: "static" | "semi-static" | "dynamic";
}

const ROUTES: RouteConfig[] = [
  { pattern: /^\/(about|faq|privacy|terms)\/?$/, tier: "static" },
  { pattern: /^\/products\/[a-z-]+\/?$/, tier: "semi-static" },
  { pattern: /^\/product\/[A-Z0-9-]+\/?$/, tier: "semi-static" },
  { pattern: /^\/(cart|checkout|account)\/?/, tier: "dynamic" },
  { pattern: /^\/dashboard\/?/, tier: "dynamic" },
];

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const route = ROUTES.find((r) => r.pattern.test(url.pathname));

    if (!route || route.tier === "static") {
      // Serve from CDN (static files or 404)
      return env.ASSETS.fetch(request);
    }

    if (route.tier === "semi-static") {
      // Serve from edge KV with ISR
      return handleISR(request, env);
    }

    // Dynamic: proxy to origin SSR
    return fetch(`${env.ORIGIN_URL}${url.pathname}${url.search}`, {
      headers: request.headers,
    });
  },
};

The Proof

After implementing the hybrid rendering strategy:

Page TypeRenderingTTFB (p50)TTFB (p95)Origin CPU
StaticCDN (build-time)12ms18ms0%
Product listingEdge KV (ISR)15ms45msRevalidation only
Product detailEdge KV (ISR)18ms52msRevalidation only
CheckoutOrigin SSR185ms320ms100% of dynamic

Origin SSR requests drop from 12,000/min to 600/min (checkout only). Origin CPU utilization drops from 68% to 8%. Revalidation requests add ~200/min during catalog updates, bringing the peak to 12%.

TTFB for product listing pages: 165ms → 15ms (91% reduction).

The CI performance gate from Chapter 2 measures TTFB for the product listing page. The baseline threshold must be updated to reflect the new rendering architecture: a TTFB regression from 15ms to 165ms would indicate the edge caching layer is bypassed.

The Trade-off

The hybrid architecture has three rendering paths to maintain. A bug in the ISR revalidation logic serves stale data indefinitely. A misconfigured route pattern sends dynamic pages to the static tier, serving checkout pages without user context.

The X-Render-Source response header (visible in DevTools) helps debug which rendering tier served a request. The CI pipeline should verify that test requests to known routes return the expected X-Render-Source value.

Edge KV storage costs: 200 category pages + 8,000 product pages = 8,200 cached HTML pages. At ~40KB per page, total KV storage is 328MB across edge locations. At Cloudflare Workers KV pricing, this is within the free tier. At 30 edge locations with full replication, the storage cost remains negligible compared to the origin CPU savings.