Streaming HTML Through CDNs and Proxies

This guide is part of Streaming SSR & Early Flush, within Network & Server Response Optimization. Streaming only helps if the chunks reach the browser as they are written. Between your application and the user there are often several layers that can buffer: compression middleware in the app, a reverse proxy (nginx, Apache, Envoy), a load balancer or ingress controller, a serverless platform's adapter, and the CDN. Any one of them can collect the entire response before forwarding it. The application streams perfectly in local testing and buffers completely in production, and nobody notices because the page still works — it is just slower than it should be.

Fixing streaming in production means testing at each hop, finding the layer that buffers, and changing its configuration for HTML routes.

Where responses get buffered Layers between the application and the browser that commonly buffer streamed responses. Where responses get buffered App compression middleware Buffers to compress efficiently unless flushed Reverse proxy (nginx, Apache) proxy_buffering / fastcgi_buffering on by default Load balancer / ingress Some modes buffer whole responses Serverless adapter May only support buffered responses CDN Usually streams; some features force buffering

Rapid Diagnosis

  • Test at each hop with curl -sN and timestamps: app directly, through the proxy, through the load balancer, through the CDN.
  • Compare TTFB and download time in DevTools: streaming shows early TTFB and a long download; buffering shows late TTFB and a short download.
  • Check compression: does buffering appear only with Accept-Encoding: br?
  • Check platform documentation for streaming support in your hosting environment.

Root Cause Analysis

1. Proxy buffering defaults. nginx buffers proxied and FastCGI responses by default.

2. Compression buffering. Compressors wait for enough data unless flushed.

3. Platform limitations. Some serverless runtimes return responses only when complete.

4. CDN features. HTML rewriting, edge-side includes or certain security features may buffer responses.

Step-by-Step Resolution

1. Locate the buffering hop

bash
# Timestamped chunks at each layer (adjust hosts and ports).
t() { curl -sN --compressed "$1" | while IFS= read -r l; do printf '%s %s\n' "$(date +%T.%3N)" "${l:0:50}"; done | head -5; }
t http://127.0.0.1:3000/product/42           # app
t http://127.0.0.1:8080/product/42           # via nginx
t https://origin.example.com/product/42      # via load balancer
t https://www.example.com/product/42         # via CDN
# trade-off: the first lines appearing at the same time as the last means that
# hop buffered; compare across hops to find the first one that does.

2. Disable proxy buffering for HTML

nginx
location / {
  proxy_pass http://app;
  proxy_buffering off;            # pass chunks through as they arrive
  proxy_http_version 1.1;
  gzip off;                       # let the app or CDN compress, or ensure flushing
}
# For FastCGI (PHP): fastcgi_buffering off;
# trade-off: disabling buffering ties up a proxy connection for the full response
# duration; acceptable for HTML, keep buffering on for slow clients on large files.

Applications can also send X-Accel-Buffering: no to disable nginx buffering per response.

3. Flush compression per chunk

In Node.js with the compression middleware, call res.flush() after each chunk (frameworks with built-in streaming often handle this). For Brotli and gzip streams, use flush modes that emit compressed data immediately.

4. Use streaming-capable platform APIs and CDN settings

Use the platform's streaming response APIs (Web ReadableStream responses on edge platforms, streaming-enabled function configurations). Check CDN features such as HTML rewriting or bot protection challenges that may force buffering, and disable them for streamed routes if necessary.

First chunk arrival at each hop (before fixes) Timeline showing when the first chunk of a streamed response reaches each layer, revealing the buffering hop. First chunk arrival at each hop (before fixes) App head nginx buffered whole response CDN waits for nginx 0ms 200ms 400ms 600ms 800ms 1000ms 1200ms

Verification

Run the timestamped curl test through the full production path: the head should arrive within milliseconds of the app writing it. In the browser, the document request's "Waiting for server response" should be short and "Content Download" longer. LCP for uncached pages should improve correspondingly.

Worked Example: A SaaS Marketing Site on Kubernetes

A SaaS company implemented streaming SSR in its Next.js marketing site. Local tests showed the head arriving in 40ms, but production TTFB remained at 650ms. Hop-by-hop testing found two buffering layers: the nginx ingress controller (default proxy-buffering on) and a custom compression middleware in the Node server that buffered until the response ended. Setting the ingress annotation to disable buffering for the HTML service and removing the custom middleware (letting the CDN compress) brought production TTFB to 80ms. LCP p75 for uncached pages improved by 520ms.

Fixing a buffering hop Decision sequence for removing buffering at the layer that holds the stream. Fixing a buffering hop Does the app itself send chunks? Fix the app first (streaming renderer, flush) yes no Does nginx or the ingress buffer? proxy_buffering off or X-Accel-Buffering no yes no Does buffering appear only when compressed? Flush the compressor or compress at the CDN yes no Does the CDN buffer? Disable buffering features for HTML routes yes no Check the serverless adapter's streaming mode

Monitoring for Regressions

Buffering often returns silently: an infrastructure upgrade resets a default, a new CDN feature is enabled, or a middleware is added. Add a synthetic check that requests a streamed page through the production path and measures the gap between the first and last byte; if the gap collapses to nearly zero while TTFB grows, something started buffering. In RUM, track responseStart and responseEnd of the navigation entry: for streamed pages, the difference should remain substantial.

javascript
const [nav] = performance.getEntriesByType('navigation');
const streamGap = nav.responseEnd - nav.responseStart;   // ~0 when buffered
navigator.sendBeacon('/rum', JSON.stringify({ ttfb: nav.responseStart, streamGap }));
// trade-off: cached pages also have a small gap; compare uncached routes only, or
// segment by a cache-status Server-Timing entry.

Load Balancers and Service Meshes

Cloud load balancers and service meshes sit between the proxy and the application in many deployments. Most HTTP load balancers stream responses, but some modes or features (response inspection, certain health-check integrations, request mirroring) buffer. Service mesh sidecars like Envoy stream by default but can be configured with buffering filters. When the hop-by-hop test shows buffering between the ingress and the app, check these layers' configuration and documentation for response buffering options.

Common Mistakes

  • Testing only locally. Production paths have more hops.
  • Disabling buffering everywhere. Only HTML routes that stream need it.
  • Ignoring compression. Compressed responses buffer while uncompressed tests stream.
  • Assuming the CDN streams. Check its features and documentation.

Edge Cases

HTTP/1.0 clients. Without chunked encoding, responses are sent with connection close; rare in practice.

WAFs and bot protection. Some inspect full responses; configure exceptions or test their effect.

Server-Sent Events and streaming APIs. The same buffering issues apply and the same fixes help.

Edge HTML rewriting. Streaming HTML rewriters exist (process chunks as they pass); non-streaming rewriters force buffering.

FAQ

Why does streaming work locally but not in production?

Usually a proxy, load balancer, compression layer or platform adapter in production buffers responses.

How do I disable nginx buffering for one route?

Set proxy_buffering off in that location block, or send X-Accel-Buffering: no from the application.

Do CDNs support streaming?

Most major CDNs pass streamed responses through. Some features, like full-page HTML rewriting, may buffer. Check your provider's documentation.

Does compression prevent streaming?

Not if the compressor flushes each chunk. Buffering compressors do prevent it.

Is it safe to disable proxy buffering?

For HTML responses, generally yes. Buffering protects backends from slow clients; with a CDN in front, the CDN usually absorbs slow clients.

Does HTTP/2 change anything?

HTTP/2 and HTTP/3 stream frames natively, but intermediaries can still buffer the full body before forwarding.

Do serverless platforms support streaming?

Many now offer streaming response modes or Web stream APIs; some older configurations buffer. Check the platform's streaming documentation.

Can I stream through Cloudflare Workers?

Yes. Returning a Response with a ReadableStream body streams to the client, and HTMLRewriter processes HTML in a streaming fashion.

Does a buffering hop make streaming harmful?

No, just ineffective: the response arrives as if it had been rendered in full. Finding and removing the buffering hop restores the benefit.

Should HTTP/2 be enabled between proxy and app?

It is not required for streaming. HTTP/1.1 with chunked transfer encoding streams fine between internal hops, as long as buffering is disabled.