Disabling CDN Buffering for Event Streams Permalink to this section

Part of Proxy & CDN Configuration for SSE, under SSE Protocol Fundamentals & Architecture.

A CDN in front of a stream is the last hop anybody thinks to check and the one most likely to be holding the bytes. The origin looks perfect, the proxy is configured correctly, and users still see nothing for thirty seconds and then everything at once.

Symptom & Developer Intent Permalink to this section

  • curl against the origin streams immediately; the same path through the CDN hostname delivers nothing for many seconds, then a burst.
  • onopen fires promptly but the first onmessage is delayed by an interval unrelated to your event rate.
  • The delay correlates with payload volume rather than time โ€” events appear once roughly a fixed number of kilobytes have accumulated.
  • Some users are fine and others are not, depending on which edge location serves them.
  • A cached, stale copy of a stream is occasionally served to a new client, ending immediately.

The intent is to make the edge forward bytes as they arrive and never store the response, without moving the endpoint off the CDN hostname.

Root Cause Analysis Permalink to this section

CDNs buffer for good reasons that all become wrong for streams.

Why an edge holds bytes, feature by feature Table of CDN features that require a complete response body and the effect each has on a stream. Why an edge holds bytes, feature by feature Needs Effect on a stream Caching a complete body stalls or stores a truncation Compression input to build a dictionary batch delivery Minify / rewrite the whole document full buffering Origin timeout a response to complete edge 502 / 504
Every one of these is correct behaviour for a finite response. None of them can work on a body that never ends.

Caching. An edge that caches a response must read it to completion to store it. A response that never completes either blocks forever or is abandoned, and a partially-read stream that does get stored is served to the next client as a truncated, stale body.

Compression. A gzip or Brotli encoder needs input to build its dictionary, so it accumulates bytes before emitting anything. Compression applied at the edge converts even a perfectly unbuffered origin stream into batch delivery, and the tell is a Content-Encoding header on the stream response.

Transformations and inspection. Minifiers, HTML rewriters, image optimisers and security scanners all reassemble the response body to do their work.

Origin response timeouts. Many edges enforce a maximum time to receive a complete origin response โ€” commonly 30 to 100 seconds. A stream exceeds it by definition, producing an edge-generated 502 or 504 that never reaches your origin logs.

The additional complication is default-on behaviour: cache and compression rules configured for the whole zone apply to the stream path unless it is explicitly excluded, and path matching is often broader than intended โ€” a rule on /api/* catches /api/events too.

Step-by-Step Resolution Permalink to this section

Rule ordering decides whether the bypass works Rule evaluation order at the edge showing a narrow stream rule evaluated before a broad API caching rule. Rule ordering decides whether the bypass works put the narrow stream rule above every broader rule that could match Request /api/events SSE rule bypass ยท no compress /api/* rule never reached Origin streams through evaluated first skipped passthrough
A correct bypass rule placed after a broad cache rule does nothing โ€” ordering is the detail that most often defeats an otherwise right configuration.

Step 1 โ€” Establish the origin baseline Permalink to this section

Every subsequent measurement is meaningless without this number.

# Directly to the origin, bypassing the edge.
curl -N -s -o /dev/null -w 'origin first byte: %{time_starttransfer}s\n' \
  --resolve api.example.com:443:203.0.113.10 \
  https://api.example.com/events
# Expect well under 0.5s.

Step 2 โ€” Measure the same request through the edge Permalink to this section

curl -N -s -o /dev/null -w 'edge first byte: %{time_starttransfer}s\n' \
  https://api.example.com/events

A jump from milliseconds to seconds confirms the edge is holding bytes. Capture both numbers โ€” they are the before/after evidence for the change.

Step 3 โ€” Create a bypass rule for the stream path Permalink to this section

Every CDN expresses this differently, but the rule set is identical. Match the stream path as narrowly as possible and turn off everything that requires a complete body.

Rule: "SSE passthrough"
  match:            path starts with /events   (or /api/events, /stream, โ€ฆ)
  cache:            bypass / no-store
  compression:      off
  transformations:  off   (minify, rewrite, image optimisation)
  buffering:        off / streaming enabled
  origin timeout:   >= 300s
  priority:         above any broader /api/* rule

Rule ordering is the detail that most often defeats an otherwise correct configuration: a broad caching rule evaluated first still applies.

Step 4 โ€” Make the origin state its intent in headers Permalink to this section

Even where the edge is configured correctly, the response should say what it is. Some edges honour these directly, and every downstream intermediary sees them.

res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  // no-transform explicitly forbids intermediaries from compressing or altering the body
  'Cache-Control': 'no-cache, no-store, no-transform, must-revalidate',
  'X-Accel-Buffering': 'no',
});
res.flushHeaders();

no-transform is the one people omit, and it is the directive that addresses compression specifically.

Step 5 โ€” Verify the response is neither cached nor compressed Permalink to this section

# Cache status should be BYPASS/MISS/DYNAMIC โ€” never HIT.
curl -sI https://api.example.com/events | grep -iE 'cache|age|x-cache|cf-cache-status'

# There must be no Content-Encoding, even when the client offers gzip.
curl -sI -H 'Accept-Encoding: gzip, br' https://api.example.com/events | grep -i content-encoding
# (no output)

Step 6 โ€” Re-measure and compare against the baseline Permalink to this section

curl -N -s -o /dev/null -w 'edge first byte: %{time_starttransfer}s\n' https://api.example.com/events

The edge number should now be within a few tens of milliseconds of the origin number, plus the network distance to the edge. If it stays high after all the above, the edge product buffers by design on that plan or route โ€” at which point the answer is to serve the stream from a hostname that bypasses the CDN.

Validation & Monitoring Permalink to this section

Test from more than one place. Edge behaviour is per-location, and a rule that propagated to one region may not have reached another.

First-byte time, origin versus edge Bar chart comparing time to first byte at the origin, through the proxy and through the CDN before and after the passthrough rule. First-byte time, origin versus edge time_starttransfer, same URL, measured at each hop Origin 12 ms Through proxy 18 ms Edge, before 4 100 ms Edge, after 46 ms time to first byte
The edge number must land within a few tens of milliseconds of the origin. Anything else means bytes are still being held.
# Compare first-byte time across several edge locations.
for host in api.example.com; do
  for resolver in 1.1.1.1 8.8.8.8 9.9.9.9; do
    ip=$(dig +short @$resolver "$host" | head -1)
    printf '%-15s ' "$ip"
    curl -N -s -o /dev/null -w '%{time_starttransfer}s\n' \
      --resolve "$host:443:$ip" "https://$host/events"
  done
done

For continuous coverage, a synthetic check from outside your network that measures time-to-first-event through the CDN is worth more than any origin-side metric here โ€” the origin cannot see this failure at all. Pair it with the client-side time-to-first-event beacon described in Observability & Metrics for SSE, and alert when the p95 crosses a few seconds.

Watch for regressions after unrelated CDN changes. A new zone-wide compression or security rule silently re-enables buffering on the stream path, and the only signal is the latency metric moving.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

How do I tell whether the CDN or my own proxy is buffering?

Measure time_starttransfer at each hop: origin directly, then through your proxy, then through the CDN hostname. The hop where the number jumps from milliseconds to seconds is the one holding bytes. Use --resolve to target a specific address so you are certain which layer you are testing.

Why is a stream being cached at all when it never completes?

Because a broad cache rule matched the path and the edge attempted to store the response. Some edges abandon the attempt and pass through; others hold bytes until an internal limit, and a few store a truncated body and serve it to the next client โ€” which appears at the browser as a stream that ends immediately after a handful of stale events.

Does Cache-Control: no-store alone fix this?

It handles caching, not compression or transformation, and not every edge honours origin cache headers over its own zone rules. Send no-cache, no-store, no-transform as a statement of intent, and configure the bypass rule at the edge as well โ€” the header is a backstop, not the mechanism.

What if first-byte time stays high no matter what I configure?

That edge product buffers by design on your plan or route, and no configuration will change it. Serve the stream from a hostname that bypasses the CDN โ€” a separate subdomain pointed at the origin or a load balancer โ€” and keep the CDN for everything else. Verify by comparing first-byte time on both hostnames.