Proxy & CDN Configuration for SSE Permalink to this section
Part of SSE Protocol Fundamentals & Architecture.
Almost every “SSE does not work in production” incident is an intermediary doing exactly what it was designed to do. Reverse proxies buffer responses to protect slow clients, CDNs cache and compress to save bandwidth, load balancers close idle connections to reclaim capacity — and every one of those behaviours is wrong for a response that never ends. This guide is a configuration reference: the exact directives for nginx, HAProxy, Envoy, Caddy, Apache, AWS ALB and the common CDN edges, what each one does to a stream, and how to prove the setting took effect. The application-level counterpart is Backend Stream Generation & Connection Management; here we assume the application is already correct and the bytes are being lost somewhere between it and the browser.
How It Works Permalink to this section
Three intermediary behaviours matter, and they fail in distinguishable ways.
Response buffering is the default for most proxies: read the upstream response into a buffer, then forward it. For a finite response this improves throughput and frees the upstream worker sooner. For a stream it means events sit in the buffer until it fills — commonly 4 KB or 8 KB — so a user sees nothing for minutes, then a burst of stale events. The fingerprint is onopen firing normally with no onmessage for a long time, then several events arriving at once.
Idle timeouts close connections that have transferred no bytes for some interval. Every layer has one: nginx proxy_read_timeout at 60 seconds, AWS ALB at 60 seconds, most CDNs somewhere between 30 and 100 seconds, and corporate NAT devices at around five minutes with no notification at all. The fingerprint is a stream that dies at a suspiciously round interval, reconnects, and dies again at the same interval.
Content transformation — gzip, Brotli, minification, or a security scanner reassembling the body — requires the intermediary to hold bytes to do its job. Compression is the common case: a gzip encoder buffers input to build its dictionary, so an unflushed compression stage converts a real-time feed into a batch delivery even when proxy_buffering is off.
The mitigations are the same everywhere and always three: disable buffering, raise the idle timeout above the heartbeat interval, and exclude the stream from compression. The heartbeat itself is the belt-and-braces measure — a comment line every 15 seconds resets every idle timer along the path, including the ones you do not control.
# The fingerprint test: does the intermediary hold bytes?
# Run against the origin directly, then through each hop.
time curl -N -sS -H 'Accept: text/event-stream' https://origin.internal/events | head -2
time curl -N -sS -H 'Accept: text/event-stream' https://api.example.com/events | head -2
# First bytes should appear in well under a second in BOTH cases.
Server-Side Implementation Permalink to this section
nginx Permalink to this section
The three directives that matter are proxy_buffering, proxy_read_timeout and the Connection header handling. X-Accel-Buffering: no sent by the application is honoured by nginx and is a useful second line of defence when you do not control the proxy config.
location /events {
proxy_pass http://sse_backend;
# 1. Never buffer the response — the single most important line.
proxy_buffering off;
proxy_cache off;
# 2. Keep the connection open far longer than the heartbeat interval.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 3. Persistent HTTP/1.1 upstream; clear the client's hop-by-hop header.
proxy_http_version 1.1;
proxy_set_header Connection "";
# 4. Never compress a stream.
gzip off;
# 5. Preserve the client address for per-user rate limiting upstream.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
If nginx is compiled with the ngx_http_gzip module enabled globally, gzip off inside the location block is required — a global gzip on otherwise applies. The same is true of any sub_filter or third-party body-rewriting module: each one re-buffers.
HAProxy Permalink to this section
HAProxy streams by default, so the work is in the timeouts. timeout tunnel governs connections after the response has begun and is the one that terminates streams.
defaults
timeout connect 5s
timeout client 60s
timeout server 60s
backend sse_backend
# Applies once the response is streaming: must exceed your longest expected session.
timeout tunnel 1h
# Do not let a quiet stream look like an idle client.
timeout client-fin 30s
option http-keep-alive
no option httpclose
# Never compress the stream.
filter compression
compression algo identity
server app1 10.0.1.10:8080 check
Envoy Permalink to this section
Envoy buffers only when a filter requires it, but its default route timeout of 15 seconds terminates streams promptly. Set it to zero for the streaming route.
routes:
- match:
prefix: "/events"
route:
cluster: sse_backend
timeout: 0s # disable the route timeout entirely
idle_timeout: 3600s # far above the heartbeat interval
Caddy Permalink to this section
Caddy streams correctly out of the box; the one thing to check is that no encode directive applies to the stream path.
api.example.com {
@sse path /events*
reverse_proxy @sse localhost:8080 {
flush_interval -1 # flush immediately, never buffer
transport http {
read_timeout 0 # no idle timeout on the upstream leg
}
}
encode gzip {
match {
not path /events* # exclude the stream from compression
}
}
}
Apache httpd Permalink to this section
<Location /events>
ProxyPass http://127.0.0.1:8080/events flushpackets=on timeout=3600
ProxyPassReverse http://127.0.0.1:8080/events
SetEnv proxy-sendchunked 1
SetEnv no-gzip 1
SetEnvIf Request_URI "^/events" no-gzip=1 dont-vary
</Location>
flushpackets=on is the equivalent of disabling buffering, and it is off by default — which is why Apache deployments so often show the burst-delivery symptom.
AWS Application Load Balancer Permalink to this section
An ALB has exactly one relevant knob and it is not optional: idle_timeout.timeout_seconds, which defaults to 60. It applies to connections with no data transfer in either direction, so a heartbeat comfortably inside the window keeps streams alive; raising the timeout as well gives margin during a producer outage.
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn "$ALB_ARN" \
--attributes Key=idle_timeout.timeout_seconds,Value=4000
Target groups add a second consideration: deregistration delay determines how long an instance keeps serving existing streams during a deploy. Set it above your drain window so streams are closed deliberately by the application rather than cut by the load balancer.
CDN edges Permalink to this section
CDN behaviour varies more than proxy behaviour, but the rules are consistent: bypass the cache for the stream path, disable any transformation, and confirm the edge does not impose its own response timeout.
# Typical edge configuration, expressed as rules:
# path /events*
# cache: bypass (never cache a response that never completes)
# compression: off (a compressor must buffer to work)
# transformations: off (minifiers, rewriters, image proxies)
# origin timeout: >= 300s (above your heartbeat interval)
# buffering: off / streaming mode enabled
Some edges will not stream at all on their default plan or product tier, and no amount of configuration changes that. When first bytes reliably take as long as the whole upstream response, the edge is buffering by design and the answer is to serve the stream from a hostname that bypasses the CDN entirely.
Client-Side Consumption Permalink to this section
Client code does not change, but it should be defensive about a path you do not fully control. Two measures matter: a silence watchdog that reconnects when heartbeats stop even though the socket still reports OPEN, and reporting time-to-first-event so a buffering intermediary shows up in telemetry rather than in support tickets.
// Silence watchdog: a buffering proxy produces an OPEN stream with no bytes.
function connectWithWatchdog(url, onEvent, silenceMs = 45_000) {
let es;
let timer;
const arm = () => {
clearTimeout(timer);
timer = setTimeout(() => {
// readyState may still be OPEN here — that is exactly the bug we detect.
es.close();
connectWithWatchdog(url, onEvent, silenceMs);
}, silenceMs);
};
es = new EventSource(url);
const opened = performance.now();
es.onopen = arm;
es.onmessage = (e) => {
if (!e.timeStamp || performance.now() - opened < 60_000) {
reportMetric('sse_time_to_first_event_ms', performance.now() - opened);
}
arm(); // any byte, including a heartbeat, rearms the timer
onEvent(e);
};
es.onerror = () => arm();
return () => { clearTimeout(timer); es.close(); };
}
Set the watchdog interval to roughly three times the server heartbeat. Anything tighter produces spurious reconnects on slow mobile networks; anything looser leaves users staring at stale data for longer than they will tolerate.
Edge Cases & Network Interference Permalink to this section
- Compression enabled globally. A
gzip onin the http block applies to every location that does not override it. The stream then arrives in compressor-sized batches. Exclude the path explicitly and sendCache-Control: no-transformso downstream intermediaries are told not to re-compress. - The heartbeat is longer than the shortest timeout. Every hop must be measured, not assumed. A 30-second heartbeat behind a CDN with a 25-second origin timeout produces a stream that dies every 25 seconds, forever.
- Corporate TLS-inspecting proxies. These terminate TLS, reassemble the body for scanning, and are frequently unconfigurable. Streams behind them buffer regardless of your settings. The practical mitigation is detection — the silence watchdog above — plus a documented fallback to polling for affected networks.
- HTTP/1.0 upstream.
proxy_http_versiondefaults to 1.0 in nginx, which disables chunked transfer to the upstream and forces buffering. This is the single most common misconfiguration in the wild. - Cache rules that match too broadly. A cache rule on
/api/*catches/api/eventstoo. A cached stream is served to the next user as a finite, stale response — indistinguishable at the client from a server that closed the connection. - Multiple proxies in series. Each hop needs its own configuration, and each hop hides the previous one. Test hop by hop with
curlfrom inside each network segment rather than only from a laptop. - Load balancer health checks on the stream path. A health check that opens the stream and waits for a complete response never gets one, marks the target unhealthy, and takes the node out of rotation. Point health checks at a separate, ordinary endpoint.
Performance & Scale Considerations Permalink to this section
Disabling buffering moves memory pressure from the proxy to the application: nginx with proxy_buffering off holds each response open against the upstream, so upstream worker occupancy rises to match the number of open streams. This is the correct trade for SSE, but it means the upstream must be able to hold connections cheaply — which is why event-loop and goroutine-based servers suit SSE better than thread-per-request models.
Connection counts at the proxy scale linearly with clients. nginx defaults to 512 worker_connections per worker, and each proxied stream consumes two (client and upstream). A node holding 25 000 streams needs at least 50 000 connections across its workers plus headroom, and correspondingly raised descriptor limits — see Tuning File-Descriptor Limits for SSE Connection Pools.
worker_processes auto;
events {
worker_connections 65536; # client + upstream connection per stream, plus headroom
multi_accept on;
}
worker_rlimit_nofile 131072; # must exceed worker_processes × worker_connections × 2
Heartbeat traffic is the recurring cost of keeping intermediaries happy. At 20 000 streams a 15-second heartbeat is roughly a gigabyte of egress per week — negligible against event traffic, but worth measuring before choosing an interval below ten seconds.
Validation & Debugging Permalink to this section
Work outward from the origin, one hop at a time. The question at each hop is the same: does the first byte arrive immediately?
# 1. Origin, from inside the network — establishes the baseline.
curl -N -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' http://10.0.1.10:8080/events
# 2. Through the proxy.
curl -N -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' https://lb.internal/events
# 3. Through the CDN, from outside.
curl -N -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' https://api.example.com/events
# A hop where time_starttransfer jumps from milliseconds to seconds is the buffering hop.
Two more checks catch the remaining causes:
# Is anything compressing the stream? (Expect no Content-Encoding at all.)
curl -sI -H 'Accept-Encoding: gzip' https://api.example.com/events | grep -i content-encoding
# How long does an idle stream actually survive? Run with no events flowing.
curl -N -s https://api.example.com/events > /dev/null &
sleep 120; kill -0 %1 && echo "alive at 120s" || echo "died before 120s"
For a permanent signal, record time-to-first-event and stream duration server-side and alert on the p50 duration collapsing to a round number. A p50 of exactly 60 seconds is a timeout, not a coincidence — and it identifies which hop by matching the number against that hop’s documented default.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why do events arrive in bursts even though I set proxy_buffering off?
Something else in the path is still holding bytes. The usual suspects, in order: gzip enabled globally (a compressor must buffer to build its dictionary), proxy_http_version left at the 1.0 default, a body-rewriting module such as sub_filter, or a second proxy or CDN in front of the one you configured. Test hop by hop with curl -w '%{time_starttransfer}' — the hop where the number jumps is the culprit.
What idle timeout should I actually set?
Set every timeout you control to at least three times your heartbeat interval, and set the heartbeat below the shortest timeout you do not control. With a 15-second heartbeat, 300 seconds is a comfortable proxy setting. Raising timeouts without a heartbeat is not enough, because NAT devices and corporate firewalls drop idle flows silently at intervals you cannot configure.
Can I serve Server-Sent Events through a CDN at all?
Yes, provided the edge supports streaming and you bypass the cache for that path. Cache the rest of the origin normally and mark the stream path as bypass with compression and transformations off. If first-byte time through the edge stays high no matter what you configure, that edge product buffers by design — serve the stream from a hostname that skips the CDN.
Does X-Accel-Buffering: no work on proxies other than nginx?
It is an nginx convention, and nginx honours it. Some CDNs and proxies that were built to be nginx-compatible respect it too, but most ignore it. Treat it as a useful default the application can always send, not as a substitute for configuring each hop.
Should health checks use the streaming endpoint?
No. A health check that opens the stream waits for a response that never completes, times out, and marks the target unhealthy — removing a node that is working perfectly. Expose a plain endpoint that reports stream-subsystem health, such as current connection count and last successful publish, and point the check at that.