Edge Rendering and Static Generation Hybrid
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:
| Tier | Content Freshness | Examples | Correct Strategy |
|---|---|---|---|
| Static | Deploy-time | About, FAQ, blog | Build-time generation |
| Semi-static | Minutes to hours | Product listings, categories | Edge + stale-while-revalidate |
| Dynamic | Per-request | Cart, checkout, inventory dashboard | Origin SSR |
The Baseline
All pages rendered at origin:
| Page Type | Requests/min | Server render time | TTFB (p50) | TTFB (p95) |
|---|---|---|---|---|
| Static (about, FAQ) | 800 | 15ms | 120ms | 180ms |
| Product listing | 6,400 | 60ms | 165ms | 280ms |
| Product detail | 4,200 | 45ms | 150ms | 240ms |
| Checkout | 600 | 80ms | 185ms | 320ms |
| Total | 12,000 | N/A | N/A | N/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 Type | Rendering | TTFB (p50) | TTFB (p95) | Origin CPU |
|---|---|---|---|---|
| Static | CDN (build-time) | 12ms | 18ms | 0% |
| Product listing | Edge KV (ISR) | 15ms | 45ms | Revalidation only |
| Product detail | Edge KV (ISR) | 18ms | 52ms | Revalidation only |
| Checkout | Origin SSR | 185ms | 320ms | 100% 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.