Server-Sent Events (SSE)
Server-Sent Events is a standard for one-way streaming from server to client over plain HTTP. The server holds the response open and writes `text/event-stream` chunks; the browser's `EventSource` API parses them and fires JavaScript events. It is dramatically simpler than WebSockets when the server only needs to push — no upgrade, no binary framing, automatic reconnection with replay built into the protocol.
Foundational.
How it works
SSE is a thin layer over HTTP. The client opens a normal GET request; the server responds with Content-Type: text/event-stream and then holds the connection open, writing chunks. Each chunk is a UTF-8 text message in a tiny format:
event: token
data: {"text":"Hello"}
id: 42
event: token
data: {"text":" world"}
The browser's EventSource JavaScript API parses this and fires onmessage or named-event callbacks. The server writes; the client reads. No upgrade, no binary framing, no handshake beyond the HTTP request itself.
Three features make SSE production-friendly:
- Auto-reconnect: if the connection drops, the browser reconnects automatically, including the
Last-Event-IDheader so the server can replay missed events. - Named events: an
event:field lets the server multiplex different event types over one stream (e.g.,event: message,event: typing,event: presence). - HTTP-native: works through any proxy, CDN, or load balancer that speaks HTTP. SSE is just a long-lived HTTP response.
The trade-off is that SSE is strictly server-to-client. The client cannot send data back over the same connection — it uses separate HTTP requests for that (POST to send a chat message, GET SSE to receive updates). For many apps this is fine; for true bidirectional real-time, use WebSocket.
The SSE wire format is intentionally minimal. Each event is separated by a blank line; fields are field: value lines:
data: <text>— the payload. Multipledata:lines concatenate (with newlines) into one event.event: <name>— fires a named event listener (eventSource.addEventListener('typing', ...)). Without this,onmessagefires.id: <event-id>— sets the last event ID. On reconnect, the browser sends this as theLast-Event-IDrequest header so the server can replay.retry: <ms>— the server can suggest a reconnection delay.- Lines starting with
:are comments (used as keepalives).
The format is text, not binary. If you need to send binary data, base64-encode it (costs 33% overhead) or use WebSockets. For most server-to-client use cases — JSON payloads, status updates, token streams — text is fine.
Production details that bite:
- Buffering: reverse proxies (nginx, default) may buffer responses, defeating SSE. Set
proxy_buffering offandX-Accel-Buffering: noon responses. - Connection limits: browsers historically limited 6 SSE connections per domain over HTTP/1.1. HTTP/2 multiplexing lifts this to ~100 streams per connection — use HTTP/2 if you need many SSE streams per page.
- Idle timeouts: same problem as WebSockets — NATs and proxies kill idle connections. Send a
:keepalivecomment every 15-30 seconds to keep the stream visibly active. - Load balancing: like WebSockets, SSE is stateful. The LB must not buffer (must support streaming responses), and the server must hold the connection open. Use sticky sessions or a pub/sub backbone to route events to the right server.
The recent killer use case for SSE is streaming LLM token output. When you ask ChatGPT or Claude a question, the response is generated token-by-token; users expect to see each token as soon as it is produced, not wait 10 seconds for the full answer. The transport for this is SSE.
The pattern: the client POSTs the prompt; the server responds with Content-Type: text/event-stream and emits one SSE event per generated token (or per chunk of tokens). The browser renders tokens as they arrive, giving the typing effect. When the model finishes, the server sends a final event: done event and closes the stream.
Why SSE and not WebSocket for this?
- The data flow is one-way (server to client) once the prompt is submitted.
- It runs over plain HTTPS — works through every corporate proxy and firewall.
- Auto-reconnect lets a dropped connection resume without losing context (the server can replay missed tokens via Last-Event-ID).
- It is just HTTP — existing auth, rate limiting, and observability all work without modification.
This pattern (POST a request, stream the response back as SSE) is now the standard for any AI tool, and it has driven a renaissance of SSE adoption — many developers' first encounter with SSE is wiring up an LLM streaming response.
If the server pushes and the client only listens, choose SSE. If both sides push frequently (chat, gaming, collab editing), choose WebSocket. If you need binary efficiency at high frequency, choose WebSocket binary frames. If you only need the server to push occasional updates and bandwidth is not a concern, SSE is simpler, more robust, and more proxy-friendly.
Two operational concerns that decide whether SSE is viable in production: authentication and horizontal scaling.
Authentication is straightforward because SSE runs over plain HTTP. The browser's EventSource API accepts withCredentials for cookies; alternatively, pass a token in the URL query string (/stream?token=...) since EventSource cannot set custom headers. The query-string approach leaks tokens into server logs and proxy logs — prefer cookies for browser clients. For non-browser clients (server-to-server streaming), use fetch() with streaming readers and set any headers you like.
Token expiry during a long-lived SSE stream is a real issue. The stream is established with a token; if the token expires an hour later, the connection stays open but its auth is now stale. Solutions: use long-lived tokens for streams specifically; refresh the token before it expires and re-establish the stream (with Last-Event-ID for replay); or use a refresh-token mechanism that updates the active stream's auth.
Horizontal scaling mirrors the WebSocket problem: each SSE connection is stateful and pinned to one server. To scale to many clients, you need many SSE servers plus a routing layer. The standard pattern: a pub/sub backbone (Redis Pub/Sub, NATS, Kafka) connects all SSE servers. When the backend produces an event for user 42, it publishes to a topic; whichever SSE server holds user 42's connection consumes and writes the event to the stream. The connection registry (which user is on which server) is typically Redis.
A subtle production detail: SSE servers must support graceful shutdown. When a server is being drained (deploy, autoscaler scale-in), it should send a retry: 5000 and a CLOSE event so clients reconnect to a healthy server after a short delay — not silently kill the connection and force a thundering-herd reconnect. Like WebSockets, SSE fleets need staggered deploys and connection draining to be production-grade.
You are building an LLM chat UI where the model's response streams token-by-token to the browser. Which transport is most appropriate, and why?
Pick one answer.
Your SSE stream works in development but in production behind nginx, the client receives all events at once at the end of the stream, not as they are produced. What is the most likely cause and fix?
Pick one answer.
Which is a real limitation of SSE compared to WebSockets?
Pick one answer.
Engineering mental model
Mental model. Think of Server-Sent Events (SSE) as a deliberate boundary in a system. The boundary exists because something becomes harder to manage when everything is done in one place: latency, scale, failure isolation, consistency, cost, or team ownership. The useful question is not “what does Server-Sent Events (SSE) mean?” but “what pressure makes this boundary worth introducing, and what new failure mode does it create?”
Before choosing Server-Sent Events (SSE), name the workload, the critical user path, the dominant bottleneck, the failure you are trying to absorb, and the trade-off you are willing to accept. If you cannot name those five things, the design is probably premature.
message_id = queue.publish({
"type": "server-sent-events",
"key": resource_id
})
# Consumer must be safe to retry.Back-of-the-envelope reasoning
Numerical lens: write down traffic, payload size, read/write ratio, peak multiplier and durability target before choosing a component. The numbers should justify the architecture.
Interactive thought experiment: Server-Sent Events (SSE)
Change the variables below and predict what breaks first in Server-Sent Events (SSE). The production lab can later reuse these same inputs.
Change one variable at a time. Predict the failure mode first, then move the slider and see whether your mental model matches the simplified system response.
If you are stuck on Server-Sent Events (SSE), start by drawing the request path and marking every network hop, stateful component, queue, cache and failure boundary. Then estimate where the system will saturate.
You increase traffic by 10× in a system using Server-Sent Events (SSE). What should you inspect first?
Pick one answer.
Which statement is the safest engineering habit when using Server-Sent Events (SSE)?
Pick one answer.
You have dashboards for traffic, latency, errors and saturation. You can change the architecture, but every change has operational cost.
Production scenario: your system uses Server-Sent Events (SSE), traffic suddenly spikes, and p99 latency doubles. What is your first move?
Interview drill
Answer this without notes: When would you choose Server-Sent Events (SSE), and when would you intentionally avoid it? Mention at least one bottleneck it addresses, one failure mode it introduces, and one alternative. Then quantify the workload you are designing for.
For Server-Sent Events (SSE), separate producer speed from consumer speed. The key design question is what happens when production temporarily exceeds processing capacity: queue it, shed it, slow producers down, or degrade the feature.
Numerical sanity check
A simple queue sanity check: if producers create 8,000 messages/s and consumers process 6,000 messages/s, backlog grows at roughly 2,000 messages/s until the imbalance is corrected.
Do not optimize for a memorized definition. Reason from the workload and failure mode.
A downstream service slows down while Server-Sent Events (SSE) keeps accepting traffic. Would you rather apply backpressure, queue more work, shed load, or degrade? Explain the trade-off.
Pick one answer.
What you gain, what you pay
- +Runs over plain HTTP — works through every proxy, CDN, and firewall that speaks HTTP.
- +Auto-reconnect with Last-Event-ID replay built into the protocol and browser.
- +Named events and small text wire format — simple to debug (curl sees everything).
- +Trivial browser API (EventSource) — no special library needed.
- +Cheaper to operate than WebSocket for one-way streams — no upgrade, no custom framing.
- −Strictly server-to-client — the client uses separate HTTP requests to send data back.
- −Text format only — binary data needs base64 (33% overhead).
- −HTTP/1.1 limits 6 connections per domain — use HTTP/2 if you need many concurrent streams.
- −Reverse proxies often buffer by default — a frequent source of 'it works in dev' bugs.
- −Like WebSocket, stateful — scaling requires pub/sub backbone and connection sharding.
How this breaks in production
- Reverse proxy buffering the response — events arrive in a burst at the end instead of streaming.
- Idle NAT/proxy timeouts killing the stream — mitigated by sending comment keepalives every 15-30s.
- Browser connection limit (6 per domain on HTTP/1.1) — mitigate with HTTP/2 or domain sharding.
- Server memory exhaustion from many concurrent SSE connections — same scaling problem as WebSocket.
- LB not supporting streaming responses — falls back to buffering, breaking the stream.
- Lost Last-Event-ID on reconnect — server replays events the client already saw.
Don't fall into these traps
- •Reaching for WebSocket when SSE would suffice — simpler, more proxy-friendly, auto-reconnect built in.
- •Forgetting to disable proxy buffering in nginx/Cloudflare — 'works in dev, breaks in prod'.
- •Not sending keepalives — stream silently dies on mobile after 30-60s of no events.
- •Assuming the client can send data over the SSE stream — it cannot; use separate HTTP requests.
- •Sending large JSON payloads when the wire format is text — consider binary via WebSocket if bandwidth matters.
- •Not handling replay on reconnect — users see duplicate or missed events after a network blip.
Real systems using this
How real systems implement this
- OpenAI ChatGPT — Streams model responses token-by-token over SSE. The browser's EventSource (or fetch with a streaming reader) renders tokens as they arrive.
- Vercel deploy logs — Live build and deploy logs stream over SSE to the dashboard, so users watch their build progress in real time without WebSocket overhead.
- GitHub Actions live logs — Streams CI/CD log lines over an HTTP streaming response (SSE-style) so users see log output as it is produced.
Practice saying it out loud
- Q1SSE vs WebSockets vs long polling — when would you choose each? Give a real example of each.
- Q2How would you implement LLM token streaming from a server to a browser? Walk through the protocol choice and the failure modes.
- Q3Your SSE stream works in dev but arrives all at once in production behind nginx. What is wrong?
- Q4How do you scale SSE horizontally across many servers?
- Q5What does the Last-Event-ID header do, and how does the server use it on reconnect?
Further reading & references
Core explanations are original NO CAP material. External references are provided for deeper study and standards.
What next?
Mark as understood once the mental model clicks.
Next recommended
WebSockets — Full-Duplex Real-Time Over TCP