Edge-Side Rendering: SSR at CDN Points of Presence with React RSC

· Emma Rodriguez · 11 min read

Server-side rendering at a centralized origin adds 50–200ms of network latency for users outside the origin region. Edge-side rendering eliminates this penalty by executing SSR logic at CDN Points of Presence, generating HTML within 5–20ms of the user’s network edge. React Server Components, combined with edge-optimized frameworks like Next.js and Remix, make this practical by splitting rendering into server-only and client-interactive components that respect edge runtime constraints. For background on edge compute foundations, see the Edge Computing and CDN: Beyond Caching guide.

The Performance Case for Edge Rendering

The physics are straightforward. Light in fiber travels roughly 200km per millisecond. A user in Sydney requesting a page rendered in us-east-1 faces a minimum 140ms round trip just for the network hop, before the server spends any time rendering. At an Australian CDN PoP, that network latency drops to under 10ms. The rendering time itself is typically 5–30ms at the edge, producing a total TTFB under 40ms compared to 170–250ms from a centralized origin.

TTFB comparison: origin SSR vs edge-side rendering Time to First Byte: Origin SSR vs Edge Rendering Origin SSR (us-east-1, user in Sydney) Network: ~140ms RTT SSR: 30ms return ~210ms Edge SSR (Sydney PoP, user in Sydney) 5ms SSR: 15ms 5ms ~25ms Edge SSR + Cache (Cache HIT at Sydney PoP) 5ms 1ms 5ms ~11ms Edge rendering reduces TTFB by 85-95% compared to centralized origin SSR Improvement scales with geographic distance between user and origin — highest impact for global audiences
Figure 1: TTFB for a user in Sydney — origin SSR takes ~210ms (mostly network), edge SSR takes ~25ms, and edge cache hit takes ~11ms

React Server Components at the Edge

React Server Components (RSC) split a React application into server-only components that never ship to the browser and client components that hydrate interactively. Server Components execute at render time (on the server or edge), fetch data, and produce a serialized component tree that the client reconstructs into HTML. This model aligns naturally with edge rendering because Server Components can run within the constrained V8 isolate environments that edge runtimes provide.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages
// app/page.tsx — Server Component (runs at edge)
// This component fetches data and renders HTML at the CDN PoP
// No JavaScript is shipped to the browser for this component

import { ProductGrid } from './product-grid'       // Server Component
import { SearchBar } from './search-bar'            // Client Component
import { getProducts } from '@/lib/api'

export const runtime = 'edge'  // Next.js edge runtime directive

export default async function HomePage() {
  // Data fetch happens at the edge PoP — use a nearby region's API
  const products = await getProducts({
    region: process.env.EDGE_REGION,
    limit: 20,
  })

  return (
    <main>
      <h1>Featured Products</h1>
      <SearchBar />               {/* Client: hydrates interactively */}
      <ProductGrid items={products} />  {/* Server: rendered at edge */}
    </main>
  )
}

export const revalidate = 300  // ISR: re-render every 5 minutes

The runtime = 'edge' directive tells Next.js to compile this route for edge runtimes (Cloudflare Workers, Vercel Edge Functions) instead of Node.js. The Server Component (ProductGrid) renders HTML at the PoP and streams it to the browser. The Client Component (SearchBar) ships its JavaScript bundle for client-side interactivity. The revalidate = 300 enables Incremental Static Regeneration, where the edge-rendered result is cached for 5 minutes before re-executing the component. For more on caching mechanics, see Cache-Control Headers: The Definitive Deep Dive.

Streaming SSR: Progressive HTML Delivery

Streaming SSR sends HTML to the browser in chunks as data becomes available, rather than waiting for the entire page to render before sending a single byte. React 18’s renderToReadableStream API produces a stream that edge runtimes can pipe directly to the HTTP response. The browser starts parsing and rendering the HTML shell immediately while data-dependent sections load asynchronously.

// Remix edge streaming example
// app/routes/products.$id.tsx
import { defer } from '@remix-run/cloudflare'
import { Await, useLoaderData } from '@remix-run/react'
import { Suspense } from 'react'

export const config = { runtime: 'edge' }

export async function loader({ params, context }) {
  // Start both fetches immediately, don't await
  const productPromise = context.env.API.fetch(
    `/products/${params.id}`
  ).then(r => r.json())

  const reviewsPromise = context.env.API.fetch(
    `/products/${params.id}/reviews`
  ).then(r => r.json())

  // Return deferred data — shell renders immediately
  return defer({
    product: await productPromise,  // Await: needed for shell
    reviews: reviewsPromise,         // Deferred: streams later
  })
}

export default function ProductPage() {
  const { product, reviews } = useLoaderData()

  return (
    <main>
      {/* Renders immediately — product data is resolved */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>

      {/* Streams in when reviews data resolves */}
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Await resolve={reviews}>
          {(reviewData) => (
            <section>
              {reviewData.map(r => (
                <div key={r.id}>
                  <strong>{r.author}</strong>
                  <p>{r.text}</p>
                </div>
              ))}
            </section>
          )}
        </Await>
      </Suspense>
    </main>
  )
}

The defer pattern in Remix sends the product data immediately (it was awaited) and streams the reviews section after the reviews API responds. The browser receives the page shell — header, product details, and a “Loading reviews” placeholder — within 20ms. Reviews stream in as a subsequent HTML chunk that React replaces the placeholder with, no client-side fetch required. This approach maximizes Largest Contentful Paint (LCP) by showing the critical content immediately.

Edge Runtime Constraints and Workarounds

Edge runtimes are not full server environments. They run in V8 isolates (Cloudflare Workers, Vercel Edge) or Deno isolates with strict resource limits. Understanding these constraints is essential for building edge rendering that works reliably under production load.

No Node.js APIs: Edge runtimes provide Web Standard APIs (fetch, Request, Response, TextEncoder, crypto) but not Node.js builtins (fs, path, child_process, net). Libraries that depend on Node.js APIs fail silently or throw at runtime. Use the edge-runtime compatibility flag in package.json to catch these issues at build time.

CPU time limits: Cloudflare Workers enforces a 50ms CPU time limit per request (paid plan). This sounds restrictive but is measured as actual CPU execution time, not wall clock time. A request that spends 200ms waiting on a fetch response uses 0ms of CPU time during the wait. The practical limit is the rendering computation itself — most React component trees render in 5–15ms of CPU time.

Cold starts: V8 isolates have near-zero cold start times (under 5ms) compared to Lambda’s 100–500ms cold starts. This eliminates the cold start problem that plagues traditional serverless SSR. However, the first request to a PoP after a deployment must compile the JavaScript bundle, which adds 10–30ms. Subsequent requests on the same PoP reuse the compiled isolate.

// next.config.js — Edge-optimized configuration
/** @type {import('next').NextConfig} */
module.exports = {
  experimental: {
    // Reduce bundle size for edge compatibility
    serverComponentsExternalPackages: [
      'sharp',          // Not available at edge — use runtime image API
      'better-sqlite3', // Node.js native module — use D1/KV instead
    ],
    optimizePackageImports: [
      'lucide-react',   // Tree-shake icon library
      'date-fns',       // Tree-shake date utilities
    ],
  },
  // Force all API routes and pages to use edge runtime
  // unless explicitly opted out with runtime = 'nodejs'
  serverRuntimeConfig: {
    defaultRuntime: 'edge',
  },
}

Data Fetching Patterns for Edge Rendering

Edge rendering inverts the typical data access pattern. Instead of the server sitting next to the database in the same region, the rendering logic runs at 200+ edge locations that are far from centralized data stores. Four data access patterns work at the edge:

Replicated KV stores: Cloudflare KV, Vercel Edge Config, and Deno KV replicate data to all edge locations with eventual consistency. Read latency is under 10ms from any PoP. Use these for configuration, feature flags, and frequently accessed reference data. The trade-off is write propagation latency (up to 60 seconds for global consistency).

Regional API gateways: Deploy your API in multiple regions and route edge rendering requests to the nearest API instance. Cloudflare Workers uses env.API.fetch() to reach an API deployed on the same network, Vercel Edge Functions connect to serverless functions in the same region, and Deno Deploy routes to the nearest Deno region automatically.

Edge-side caching: Cache API responses at the edge using the Cache API or a CDN-integrated cache. First request to a PoP fetches from the origin API; subsequent requests serve from edge cache. Combined with stale-while-revalidate, this pattern serves cached data instantly while refreshing in the background. For advanced caching, see Stale-While-Revalidate: Serving Fast While Staying Fresh.

Smart prefetching: During the initial HTML stream, embed data that the client will need for subsequent navigation as inline JSON. This eliminates client-side API calls for the most common user paths, using the edge’s low-latency data access to pre-populate the client cache.

Caching Edge-Rendered Pages

Edge-rendered pages that are identical for all users should be cached at the PoP to avoid re-rendering on every request. The Cache-Control header controls this behavior, and the edge rendering framework determines when to serve cached results vs. re-execute the rendering logic.

// Edge middleware: dynamic cache key based on content type
// middleware.ts (Next.js edge middleware)
import { NextResponse } from 'next/server'

export const config = { matcher: ['/products/:path*', '/blog/:path*'] }

export default function middleware(request) {
  const response = NextResponse.next()

  const pathname = request.nextUrl.pathname

  if (pathname.startsWith('/blog/')) {
    // Blog posts: cache at edge for 1 hour, serve stale for 1 day
    response.headers.set(
      'Cache-Control',
      'public, s-maxage=3600, stale-while-revalidate=86400'
    )
    response.headers.set('CDN-Cache-Control', 'max-age=3600')
  } else if (pathname.startsWith('/products/')) {
    // Product pages: cache for 5 min (prices change frequently)
    response.headers.set(
      'Cache-Control',
      'public, s-maxage=300, stale-while-revalidate=60'
    )
  }

  // Add Vary header for content negotiation
  response.headers.set('Vary', 'Accept-Language, Accept-Encoding')

  return response
}

The CDN-Cache-Control header is a newer standard (supported by Cloudflare, Fastly, and Akamai) that sets CDN-specific caching independently of the browser Cache-Control. This allows edge-rendered pages to be cached for hours at the CDN while instructing the browser to revalidate on every navigation, ensuring users always see fresh content while minimizing edge rendering executions.

Comparing Edge Rendering Approaches

Edge rendering fits specific use cases. For content that rarely changes, static site generation with CDN edge caching is simpler, cheaper, and faster (cache hit = no compute). For highly dynamic personalized pages, origin SSR with CDN pass-through provides full server capabilities. Edge rendering excels in the middle ground: pages that change frequently enough that static generation produces stale content, but can be rendered within edge compute constraints.

The framework choice matters. Next.js App Router with runtime = 'edge' provides the most integrated experience, with automatic RSC streaming, ISR at the edge, and Vercel/Cloudflare deployment targets. Remix on Cloudflare Workers offers tighter integration with Workers-specific features (Durable Objects, D1 database, R2 storage). Astro’s hybrid rendering allows mixing static and server-rendered pages in the same project, with Cloudflare Workers as an SSR target for dynamic pages while static pages deploy to the CDN cache directly.

External References

References & Standards: OWASP CDN Security · HTTP/2 specification