Remix fundamentally changed how we think about server rendering. With React 18 Suspense and the defer() API, Remix can stream HTML to the browser before all data is ready. For users, that means instant shell rendering and progressive hydration. For CDNs, it breaks almost every assumption built over the last decade: that an SSR response is a single, cacheable, atomic string.
If your CDN buffers the entire response before forwarding it, you have negated streaming entirely. If it caches naively, you will serve stale deferred data to the wrong user. This guide covers the correct edge architecture for Remix streaming SSR — how to preserve chunked transfer, cache intelligently, and move deferred loaders to the edge without sacrificing performance or security.
Why Traditional CDN Caching Breaks Streaming SSR
Classic SSR caching is simple: origin renders HTML, CDN stores it with a TTL, subsequent requests are served from cache. This works when rendering is synchronous. Remix streaming is asynchronous by design. Understanding how CDN works at the protocol level explains why this matters.
When a Remix loader returns defer(), the server immediately renders the shell — header, navigation, layout, and any <Suspense> fallbacks — and flushes it. The browser receives <html> and starts downloading CSS, fonts, and client JS while the server is still awaiting promises. As each deferred promise resolves, Remix injects a <script> chunk containing the data and resolves the Suspense boundary on the client.
Three CDN behaviors destroy this flow:
- Full response buffering: Many CDNs and reverse proxies (Nginx by default, some CDN tiers) wait for
Content-Lengthor origin close before forwarding. Streaming requiresTransfer-Encoding: chunkedwith early flush. - Aggressive HTML minification/compression: Features that buffer to rewrite HTML or re-compress Brotli at maximum level will stall the stream. You need pass-through streaming compression.
- Single-key caching: Caching the entire streamed HTML as one object means you cannot revalidate the shell without invalidating slow, expensive deferred data — or worse, you cache personalized deferred data and leak it.
The solution is not to bypass the CDN. It is to reconfigure it for streaming-aware delivery.
How Remix Streaming and Deferred Loading Actually Works
Remix leverages React 18's renderToPipeableStream on the server. The critical APIs are defer() in loaders and <Await> on the component side.
| Metric | Edge Processing | Origin Processing | Improvement |
|---|---|---|---|
| Latency (p50) | 8–15 ms | 120–300 ms | 15–20x faster |
| Cache Hit Ratio | 85–95% | N/A | Reduces origin load |
| TLS Handshake | 1–5 ms (nearby PoP) | 50–150 ms | 10–30x faster |
| Bandwidth Cost | $0.01–0.04/GB | $0.05–0.12/GB | 3–5x cheaper |
| Availability | 99.99% (multi-PoP) | 99.9% (single DC) | 10x fewer outages |
// app/routes/dashboard.tsx - Loader with deferred data import { defer } from "@remix-run/node"; import { Await, useLoaderData } from "@remix-run/react"; import { Suspense } from "react"; export async function loader({ request, context }) { // Critical data - blocks shell render const user = await getUser(request); // Deferred data - streams after shell return defer({ user, // These promises stream as they resolve analytics: getAnalytics(user.id), // 800ms recommendations: getRecommendations(user.id), // 1200ms notifications: getNotifications(user.id), // 400ms }); } export default function Dashboard() { const { user, analytics, recommendations } = useLoaderData<typeof loader>(); return ( <div> <h1>Welcome {user.name}</h1> <Suspense fallback={<AnalyticsSkeleton />}> <Await resolve={analytics}> {(data) => <AnalyticsChart data={data} />} </Await> </Suspense> </div> ); }
On the wire, the response looks like this: first, the shell HTML up to the Suspense fallback is flushed immediately (often within 50-80ms). Then, as each promise resolves, Remix sends an inline script like <script>__remixContext.p(... )</script> that hydrates the boundary. The browser does not need to re-request anything; the data arrives in-stream.
For the CDN, this means Time to First Byte (TTFB) is no longer the full render time. True TTFB is shell flush time. Metrics like serverTiming should differentiate shell;dur=62 vs full;dur=1240 to avoid misleading cache hit ratios.
CDN Architecture for Streaming: Preserve the Stream
To keep Remix streaming intact through the edge, you need a streaming-first CDN path. This is where edge computing diverges from simple pull caching.
The configuration checklist for any CDN (Cloudflare, Fastly, Akamai, CloudFront) is:
- Enable streaming miss: The CDN must forward the origin stream to the client while simultaneously writing to cache, not after. In Fastly this is
beresp.do_stream = true; in Cloudflare, disable Rocket Loader and HTML auto-minify for SSR routes. - Disable buffering headers: Origin must send
X-Accel-Buffering: noandCache-Control: no-cachefor streaming routes, orCDN-Cache-Controlto differentiate edge vs browser. Some CDNs respectno-cacheas do-not-buffer signal. - Use chunked encoding correctly: Do not set
Content-Length. Ensure origin usesTransfer-Encoding: chunkedand the CDN does not re-add length. Verify withcurl -v --rawthat you see< chunkframes. - Keep connections warm: Use tiered caching and origin keep-alive. Cold TLS + origin connect can add 150-300ms, erasing the streaming benefit for the shell.
Deferred Data Loading at the Edge
The real performance win is not just streaming the shell, but moving deferred loaders to the edge. Instead of the Remix origin awaiting a slow API, the edge can resolve deferred promises from cache, KV, or edge-adjacent APIs.
Consider three patterns:
1. Edge-Cached Deferred Loaders
For non-personalized deferred data like product recommendations, trending lists, or CMS content, cache the promise result at the edge with its own key. The Remix loader still calls defer(), but the function hits edge cache first:
// Edge-aware loader helper async function getRecommendationsCached(userId, context) { const cacheKey = `recs:${userId}:v2`; let data = await context.caches.edge.match(cacheKey); if (data) return data; data = await fetchRecommendationsAPI(userId); // Cache deferred chunk separately: 5 min edge, SWR 10 min context.caches.edge.put(cacheKey, data, { headers: { "CDN-Cache-Control": "max-age=300, stale-while-revalidate=600" } }); return data; }
This isolates slow data failures. If the recommendations API is down, the shell still streams and the Suspense fallback can show cached stale data or a graceful empty state.
2. Edge Functions for Personalization
For personalized deferred data that cannot be cached publicly, use edge functions to inline the data without hitting origin. Cloudflare Workers or Fastly Compute can run the Remix server itself at the edge, resolving defer() promises via Durable Objects, D1, or Upstash Redis at <10ms. This is the core of modern edge computing for dynamic content — compute moves to data, not the other way around.
3. Split Caching: Shell vs Chunks
Do not cache the full streamed HTML. Instead:
- Cache the shell for anonymous users with
CDN-Cache-Control: max-age=60, stale-while-revalidate=300andVary: Cookie. - Cache deferred JSON chunks individually via Remix resource routes (
/resources/recommendations) that the client can also fetch client-side if streaming fails. This gives you a fallback for browsers or bots that do not support streaming.
This split model also helps with cost optimization — you avoid re-rendering the entire page when only one deferred loader changes.
Caching Strategies for Streaming Responses
Choosing the right cache directive is critical for security and correctness. A misconfigured Cache-Control can leak user-specific deferred data.
| Strategy | Use Case | Cache Header Example | Streaming Safe? |
|---|---|---|---|
| Shell + SWR | Anonymous shell, fast data | CDN-Cache-Control: max-age=60, stale-while-revalidate=600 |
Yes, with early flush |
| Private Deferred | User-specific recommendations | Cache-Control: private, no-store + Vary: Cookie |
Yes, no edge cache |
| Edge-Private | Personalized but cacheable per user | CDN-Cache-Control: max-age=120, private |
Yes, with edge key |
| Static Deferred | CMS, product lists | CDN-Cache-Control: max-age=300, stale-while-revalidate=3600 |
Yes, separate chunk cache |
| No-Cache Stream | Authenticated dashboard | Cache-Control: no-cache, no-store + X-Accel-Buffering: no |
Yes, bypass |
For authenticated routes, never cache the shell publicly. Use Cache-Control: private and consider CDN-Cache-Control: no-store to ensure the edge does not store it, while still streaming. You can still benefit from edge acceleration via Argo / Dynamic Acceleration (route optimization) even without caching.
Also set Vary correctly. Remix deferred streams often vary on Cookie and Accept-Encoding. If you use Vary: *, you will effectively disable caching. Prefer explicit Vary: Cookie, Accept-Encoding and normalize cookies at the edge (strip analytics cookies) to improve hit rate.
Edge Configuration: Headers, TTLs, and Tiered Caching
A production-ready CDN config for Remix streaming needs four layers:
Origin Headers (Remix Express / Cloudflare Workers)
// entry.server.tsx - Set streaming headers export default function handleRequest(request, status, headers, remixContext) { headers.set("Content-Type", "text/html; charset=utf-8"); headers.set("Transfer-Encoding", "chunked"); headers.set("X-Accel-Buffering", "no"); headers.set("Cache-Control", "no-cache"); // Edge can cache shell briefly, browser should revalidate headers.set("CDN-Cache-Control", "max-age=30, stale-while-revalidate=300"); headers.set("Vary", "Cookie, Accept-Encoding"); return renderToPipeableStream(...); }
CDN Rules
- Bypass cache for streaming routes when authenticated: If
Cookie: session=exists, bypass edge cache but keep streaming and tiered connection pooling. - Enable tiered caching: Shield / tiered cache reduces origin load for shell requests. A shell hit at the shield still streams to the edge PoP with chunked encoding.
- Strip unnecessary cookies for anonymous cache: At the edge, remove
_ga,_fbpbefore cache lookup to increase hit ratio without affecting personalization. - Security headers without buffering: Apply CDN security headers like
Strict-Transport-SecurityandContent-Security-Policyat the edge, not via origin HTML rewriting that buffers.
Test your setup with curl -N (no buffer) and WebPageTest with “First Byte” vs “Start Render” comparison. A correctly streaming Remix app should show Start Render 400-700ms before full load, even on 3G. If Start Render equals fully loaded time, your CDN is buffering.
Monitoring and Performance Tuning
Streaming SSR changes what you monitor. Traditional cache hit ratio is misleading because a streaming miss still delivers a fast shell. Track these instead:
- Shell TTFB (p50/p95): Time to first chunk. Aim for <80ms at edge, <180ms from origin.
- Time to First Suspense Resolve: When the first
<Await>resolves. This is your perceived interactivity metric. - Deferred chunk cache hit rate: Separate from shell. A low hit rate here indicates your edge KV TTL is too short or key cardinality is too high.
- Origin stream errors: Monitor for premature stream close or
ERR_INCOMPLETE_CHUNKED_ENCODING. These often indicate edge timeouts (default 30s may be too low for slow deferred data).
Set edge timeout to at least 60s for streaming routes, but keep deferred promises under 3s with Promise.race timeouts to avoid hanging streams. If a deferred promise rejects, Remix will send an error chunk — ensure your ErrorBoundary handles streamed errors gracefully without breaking the HTML stream.
Finally, consider bot and crawler impact. Search engines now support streaming SSR, but some legacy crawlers buffer. Provide a non-streaming fallback via ?_data resource routes or detect crawler UA at the edge and serve a fully rendered, cached snapshot.
In summary, Remix streaming SSR is not incompatible with CDNs — it simply requires a streaming-aware edge. Preserve chunked transfer, cache shell and deferred data separately, move slow loaders to edge KV, and monitor shell TTFB rather than full page time. Done correctly, you get the best of both worlds: sub-100ms shell delivery from the edge and rich, personalized content that streams in without blocking. For teams evaluating cost and performance tradeoffs, this architecture reduces origin compute by 40-60% while improving Core Web Vitals LCP and INP.
Frequently Asked Questions
Can CDNs cache Remix streaming SSR responses?
Yes, but not as a single static asset. CDNs must use chunked transfer encoding, early flush, and tiered caching. The shell (fast data) can be cached briefly at the edge, while deferred chunks are streamed and optionally cached separately via Cache-Control and Surrogate-Key strategies.
Does defer() in Remix work with edge functions?
Absolutely. Remix defer() is ideal for edge deployment because critical data resolves quickly at the edge while slow data (personalization, recommendations) streams later. Edge runtimes like Cloudflare Workers and Fastly Compute support ReadableStream for this pattern.
How do you prevent buffering from breaking streaming SSR?
Disable buffering at every layer: set X-Accel-Buffering: no, Cache-Control: no-cache for the streaming response, and configure your CDN to enable chunked encoding and streaming miss. Also avoid HTML minification or Brotli re-compression that forces full response buffering.
What is the best TTL for deferred data at the edge?
Use short TTLs (30-60s) with stale-while-revalidate of 300-600s for the shell, and separate cache entries for deferred loaders with longer TTLs (5-15 min) based on data volatility. Personalized deferred data should use private, no-store or edge-private caching with Vary on Cookie/Authorization.