Maximum Payload Size Limits for SSE Streams Permalink to this section
Part of Understanding the Event Stream Format.
Pushing anything beyond a few kilobytes per SSE event tends to surface one of three failure signatures: the stream silently closes, the client receives a partial event with truncated JSON, or the proxy returns a 502 Bad Gateway with no body. This guide maps every layer where a limit can bite—protocol, proxy, browser, and runtime—and gives you copy-paste fixes for each.
Symptom & Developer Intent Permalink to this section
You are streaming data over text/event-stream and one or more of the following happens:
- Browser console:
EventSource connection closed unexpectedlyor theonerrorhandler fires immediately after a large event. - Network waterfall: The response body is transferred but the
data:field inside it is truncated mid-string (common symptom: broken JSON, missing closing}). - Server/proxy logs:
upstream prematurely closed connection while reading response header from upstream,readv() failed (104: Connection reset by peer), or a413 Request Entity Too Largeif your reverse proxy has a combined response-size guard. - Memory pressure: The browser tab’s heap climbs steeply during the stream, then crashes or triggers a GC pause that appears as a UI freeze.
The intent is usually one of: pushing a large AI-generated response as a single event, sending a snapshot of application state on first connect, or streaming binary-encoded data as Base64. All three patterns share the same root problem.
Root Cause Analysis — Why This Happens at the Protocol Level Permalink to this section
The SSE spec (WHATWG HTML §9.2) places no numeric cap on data: field length. That is the entire specification. Every limit you encounter is imposed by infrastructure, not the wire format.
Where limits are actually enforced Permalink to this section
| Layer | Default limit | Configuration knob |
|---|---|---|
Nginx proxy_buffer_size |
4 KB per buffer | proxy_buffer_size, proxy_buffers |
Nginx proxy_buffers total |
8 × 4 KB = 32 KB | proxy_buffers N size |
| AWS ALB response body | 1 MB per response body for fixed responses | N/A (use pass-through mode) |
| Cloudflare (free/pro) | 100 MB per stream, but 100 ms between flushes | Enterprise: configurable |
Node.js http writable |
No hard limit, but default highWaterMark is 16 KB | new http.ServerResponse({ highWaterMark }) |
Go http.ResponseWriter |
Unlimited, but bufio.Writer wraps at 4 KB |
Call http.Flusher.Flush() explicitly |
Browser EventSource message buffer |
Implementation-defined; Chromium uses 1 MB per message before dispatch | No client-side config |
HAProxy tune.bufsize |
16 KB (compile-time default) | tune.bufsize 131072 in global |
The browser’s EventSource accumulates all data: lines for a single event in memory until it sees the double-newline (\n\n) that marks event dispatch. If a single logical event is very large, the entire payload must fit in that buffer before JavaScript ever sees it. Proxy buffers aggregate chunks before forwarding when proxy_buffering is on (the Nginx default), which turns a streaming response into a blocked, buffered one—the worst possible interaction with SSE.
HTTP/2 and flow control Permalink to this section
On HTTP/2, each stream has a flow-control window (initial default: 65,535 bytes per the RFC 9113 default). If the server pushes data faster than the client ACKs, the sender blocks at the transport layer. For SSE over HTTP/2, this means individual large events can stall mid-write even though the TCP connection is healthy. The browser does not surface this as an error; the stream simply pauses. See Buffer Management & Chunked Transfer Encoding for a full treatment of flush mechanics.
Step-by-Step Resolution Permalink to this section
Step 1 — Disable proxy buffering for the SSE endpoint Permalink to this section
This is the highest-leverage fix. Without it, everything downstream may still work correctly but the proxy will hold events until its buffer fills, making real-time delivery impossible and hiding large-event failures.
# nginx.conf — server block
location /events {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
# Turn off response buffering — required for SSE
proxy_buffering off;
proxy_cache off;
# Keep the connection alive to the upstream
proxy_set_header Connection '';
proxy_read_timeout 3600s; # Match your longest expected stream
proxy_connect_timeout 5s;
# Force the browser to get chunked frames immediately
add_header X-Accel-Buffering no;
add_header Cache-Control no-cache;
}
The X-Accel-Buffering: no header can also be sent from the application itself; Nginx respects it as an override even when the config omits proxy_buffering off.
For HAProxy, increase tune.bufsize and add option http-server-close in the backend section:
# haproxy.cfg
global
tune.bufsize 131072 # 128 KB — raise if events exceed this
backend sse_backend
option http-server-close
timeout tunnel 1h # Allow long-lived SSE connections
server app1 127.0.0.1:3000
Step 2 — Enforce a per-event size cap and chunk large payloads Permalink to this section
Never send a single SSE event larger than 64 KB. Split anything bigger into a sequence of named chunk events, terminated by a chunk-end sentinel. The client buffers chunks in a local array and reassembles on the sentinel.
Server (Node.js / Express):
const CHUNK_SIZE = 32_768; // 32 KB per SSE event — well under proxy defaults
/**
* Streams a large payload as sequential chunk events.
* @param {import('http').ServerResponse} res
* @param {string} jsonPayload - Already serialised JSON string
* @param {string} baseEventId - Monotonic base ID (e.g. "snapshot-42")
*/
function streamChunked(res, jsonPayload, baseEventId) {
const total = Math.ceil(jsonPayload.length / CHUNK_SIZE);
for (let i = 0; i < total; i++) {
const slice = jsonPayload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
// id: allows Last-Event-ID resumption at chunk boundary
res.write(`id: ${baseEventId}-${i}\nevent: chunk\ndata: ${slice}\n\n`);
}
// Sentinel: client knows how many chunks to expect
res.write(`id: ${baseEventId}-end\nevent: chunk-end\ndata: ${total}\n\n`);
}
// Usage in an Express route:
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const big = JSON.stringify(getLargeDataset()); // your data source
streamChunked(res, big, `snap-${Date.now()}`);
});
Client-side reassembly:
const buffer = new Map(); // keyed by baseEventId prefix
const source = new EventSource('/events');
source.addEventListener('chunk', (e) => {
// Event ID format: "<base>-<index>" — extract base
const base = e.lastEventId.replace(/-\d+$/, '');
if (!buffer.has(base)) buffer.set(base, []);
buffer.get(base).push(e.data);
});
source.addEventListener('chunk-end', (e) => {
const base = e.lastEventId.replace(/-end$/, '');
const expected = parseInt(e.data, 10);
const parts = buffer.get(base) ?? [];
if (parts.length !== expected) {
console.warn(`Chunk count mismatch: got ${parts.length}, expected ${expected}`);
buffer.delete(base);
return;
}
const full = JSON.parse(parts.join(''));
handleSnapshot(full);
buffer.delete(base);
});
source.onerror = () => {
// Clear partial buffers on reconnect to avoid stale data
buffer.clear();
};
This pattern integrates naturally with the Event ID & Retry Mechanism Design pattern: a reconnecting client sends Last-Event-ID pointing at the last received chunk, so the server can resume mid-sequence rather than restarting the entire payload.
Step 3 — Apply application-layer compression before serialising Permalink to this section
Compress at the application level, not at the proxy. Proxy-level gzip forces buffering; application-level compression lets you compress once, then stream bytes. For JSON payloads, fflate (browser-compatible) or Node’s zlib can reduce size by 60–85%.
import { gzipSync, gunzipSync } from 'node:zlib'; // Node 18+
// Server: compress, then base64-encode for safe SSE transport
function compressedPayload(obj) {
const json = JSON.stringify(obj);
const compressed = gzipSync(Buffer.from(json, 'utf8'));
return compressed.toString('base64');
}
// In the route handler:
const encoded = compressedPayload(largeObject);
res.write(`event: snapshot\ndata: ${encoded}\n\n`);
// Client: decode, then decompress
import { gunzip } from 'fflate'; // npm i fflate
source.addEventListener('snapshot', (e) => {
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));
gunzip(bytes, (_err, decompressed) => {
const obj = JSON.parse(new TextDecoder().decode(decompressed));
handleSnapshot(obj);
});
});
For Go streaming patterns, use compress/gzip with a bytes.Buffer, then base64.StdEncoding.EncodeToString.
Step 4 — Tune server-side flush behaviour Permalink to this section
Large writes that are never explicitly flushed sit in the OS socket buffer even with proxy buffering disabled. Each framework has its own flush API.
// Go — net/http: call Flush() after every write
func sseHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
for _, chunk := range splitPayload(largeData, 32*1024) {
fmt.Fprintf(w, "event: chunk\ndata: %s\n\n", chunk)
flusher.Flush() // push to kernel immediately
}
fmt.Fprintf(w, "event: chunk-end\ndata: %d\n\n", totalChunks)
flusher.Flush()
}
# Python / FastAPI with sse-starlette
from sse_starlette.sse import EventSourceResponse
import asyncio, json
async def event_generator(request):
payload = json.dumps(get_large_dataset())
chunk_size = 32_768
chunks = [payload[i:i+chunk_size] for i in range(0, len(payload), chunk_size)]
for idx, chunk in enumerate(chunks):
if await request.is_disconnected():
break
yield {"event": "chunk", "id": f"snap-{idx}", "data": chunk}
await asyncio.sleep(0) # yield to event loop — allows flush
yield {"event": "chunk-end", "data": str(len(chunks))}
app.get("/events")(lambda req: EventSourceResponse(event_generator(req)))
See Streaming SSE Responses with FastAPI and sse-starlette for the full FastAPI setup including CORS and auth headers.
Validation & Monitoring Permalink to this section
curl smoke test Permalink to this section
# Send a stream request, count bytes received, look for chunk-end event
curl -sN -H "Accept: text/event-stream" \
http://localhost:3000/events | grep -c "^event: chunk"
# Should print the expected number of chunk events, e.g. 4 for a 128 KB payload
Add --limit-rate 10K to simulate a slow consumer and confirm backpressure does not break chunking.
DevTools verification Permalink to this section
- Open Network → filter by
EventStream. - Select the
/eventsrequest → EventStream sub-tab. - Each
chunkevent should appear incrementally, not all at once (incremental appearance confirms proxy buffering is off). - Timing tab:
Content Downloadshould show a gradual slope, not a vertical step at the end.
Server-side metrics Permalink to this section
// Prometheus-style counter (prom-client)
const msgSizeHistogram = new Histogram({
name: 'sse_event_size_bytes',
help: 'Size of individual SSE events in bytes',
buckets: [512, 4096, 16384, 65536, 131072, 524288],
});
// Call inside streamChunked before res.write():
msgSizeHistogram.observe(chunk.length);
Alert when the 0.99 quantile exceeds 48 KB (approaching the 64 KB hard target) or when sse_stream_errors_total (connection resets) exceeds 0.5% of active streams.
Verification Checklist Permalink to this section
⚡ Production Directives
- Set
proxy_buffering offandX-Accel-Buffering: noon every SSE endpoint — without this, proxy aggregation makes chunking irrelevant. - Hard-cap individual
data:payloads at 32 KB; use the chunk/chunk-end protocol for anything larger. - Flush after every write at the framework layer (Node
res.flush(), GoFlusher.Flush(), Pythonasyncio.sleep(0)) — OS socket buffers will absorb writes silently otherwise. - Compress JSON at the application layer before encoding; never rely on proxy-level gzip for SSE as it forces buffering.
- Instrument
sse_event_size_bytesas a histogram and alert when p99 exceeds 48 KB to catch regressions before they hit proxy limits.
Frequently Asked Questions Permalink to this section
Does the SSE spec define a maximum payload size?
No. The WHATWG HTML spec (§9.2 "Server-sent events") specifies the wire format—field names, line terminators, dispatch rules—but imposes no numeric cap on data: field length. All limits you encounter are imposed by reverse proxies, CDNs, browser implementations, or OS socket buffers, not the protocol itself.
Can I use multi-line data: fields instead of chunking?
Multi-line data: fields (each line prefixed with data: ) are valid per spec and concatenated with a newline before dispatch. They do not help with size limits—the browser still accumulates all lines in memory before dispatching the event. The same proxy buffer caps apply. Use application-level chunking (separate events) rather than multi-line fields for large payloads. See Formatting Multi-Line data Fields in SSE for the wire-format details.
What happens when a client reconnects mid-chunk-sequence?
The browser sends Last-Event-ID with the ID of the last fully received event. If that ID is a chunk ID (e.g. snap-42-2), the server should resume from chunk index 3 rather than restarting at 0. Store the payload in a short-lived server-side cache (Redis string with a 30 s TTL) keyed by the base event ID and serve the tail on reconnect. The client must also clear its in-memory chunk buffer on onerror before the reconnect fires.
How do I handle payload size limits on Cloudflare Workers?
Cloudflare Workers stream via TransformStream and ReadableStream; there is no proxy buffering in the traditional sense. The binding limit is 128 MB per request body, but the practical concern is the 30 s CPU time limit per request for free plans. For SSE, use a Durable Object or Workers for Platforms to maintain long-lived connections. Chunk large payloads exactly as shown above; the Worker simply enqueues chunks into the TransformStream writable.
Should I use WebSockets instead of SSE for large payloads?
If your use case involves sustained bidirectional messaging with payloads consistently above 64 KB, WebSockets give you explicit binary framing and flow control at the protocol level. For unidirectional server-push of occasional large snapshots interleaved with small events, SSE with application-level chunking is simpler and requires no upgrade handshake. See SSE vs WebSockets: Latency & Cost Decision Matrix for a structured comparison.