Server sent events vs WebSockets: which one do you actually need
WebSockets are the default answer to "we need realtime", and for a large share of cases they are more machinery than the problem requires. Most realtime features are one directional: the server has news, the client displays it. Notifications, live dashboards, progress bars, build logs, streaming AI responses. For those, server sent events do the job over ordinary HTTP with a browser API that reconnects by itself.
The rule of thumb is simple. If the client needs to send messages continuously, use WebSockets. If it only needs to receive them, use SSE.
How they differ
| Server sent events | WebSockets | |
|---|---|---|
| Direction | Server to client only | Both directions |
| Protocol | HTTP | Upgrade to ws:// / wss:// |
| Data | UTF-8 text | Text and binary |
| Reconnection | Automatic, built in | You implement it |
| Message ID and replay | Built in via Last-Event-ID | You implement it |
| Works through HTTP proxies | Yes | Sometimes needs configuration |
| Compression, caching, auth headers | Standard HTTP | Separate handling |
| Browser API | EventSource | WebSocket |
| Connection limit per domain | 6 on HTTP/1.1, ~100 on HTTP/2 | Higher |
Two rows carry most of the decision.
Reconnection. EventSource reconnects on its own when the connection drops, and it tells the server where to resume: if your events carry id: fields, the browser sends the last one it received in a Last-Event-ID header on reconnect, and you can replay from there. With WebSockets, every part of that, detecting the drop, backing off, resuming state, is code you write and test, and it is the part that is usually written badly.
Protocol layer. SSE is a normal HTTP response that never ends. Everything that already works for HTTP keeps working: cookies and Authorization headers, CORS with its usual rules, compression, HTTP/2 multiplexing, and every proxy and load balancer in the path. WebSockets leave HTTP after the upgrade handshake, so corporate proxies, some load balancers, and any layer 7 device that only understands HTTP need explicit configuration, which is covered in WebSockets vs HTTP explained.
The event stream format
SSE is a plain text format, which is part of its appeal: you can read a stream with curl.
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 42
event: price
data: {"symbol":"ACME","price":128.4}
id: 43
event: notice
data: first line
data: second line
: this is a comment used as a heartbeat
The rules: fields are field: value, a blank line terminates an event, data: may repeat and the lines are joined with newlines, event: names a custom event type, id: sets the resume point, and retry: sets the reconnection delay in milliseconds. A line starting with : is a comment, conventionally used as a keepalive to stop idle timeouts from closing the connection.
Client side:
const es = new EventSource('/api/stream')
es.addEventListener('price', (e) => {
const { symbol, price } = JSON.parse(e.data)
render(symbol, price)
})
es.onerror = () => { /* the browser is already retrying */ }
Server side, in any framework that can stream a response:
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder()
for await (const event of events()) {
controller.enqueue(enc.encode(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`))
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
}
no-transform matters: some proxies buffer or rewrite responses, and a buffered event stream arrives all at once at the end, which looks exactly like a broken feature. On nginx the corresponding setting is proxy_buffering off, discussed alongside the other forwarding concerns in what is a reverse proxy.
The known limitations of SSE
No custom headers on EventSource. The browser API accepts only a URL and a withCredentials flag, so bearer token authentication in a header is not available. The options are cookie based authentication, which works well since SSE is plain HTTP, or the fetch based streaming approach if you need headers. Cookie auth is usually the right answer, and the cookie flags apply as normal.
Text only. Binary payloads need base64, which costs a third more bytes.
Connection limits on HTTP/1.1. Six connections per domain, shared with everything else the page loads, so several tabs of the same app can starve each other. On HTTP/2 this effectively disappears, since streams multiplex over one connection. If you support HTTP/1.1 clients, keep it to one stream per tab and fan out internally.
Long lived connections cost server resources. This is true of WebSockets too, but it interacts badly with per request billing on some serverless platforms and with the maximum duration limits they impose. Check the limits before designing around a stream that stays open for hours.
When to use what
Server sent events: notifications, activity feeds, live dashboards and metrics, progress and build logs, collaborative presence indicators, and token by token AI responses, which is what most streaming chat interfaces use.
WebSockets: chat where clients send constantly, multiplayer and collaborative editing, anything with binary frames, and cases where a client to server message must arrive with minimal latency rather than as a separate HTTP request.
Neither: if updates are needed every 30 seconds or less often, ordinary polling is simpler, stateless, cacheable, and it fails gracefully. A persistent connection to deliver one update a minute is a cost with no benefit. The same reasoning, applied to server to server communication, is in webhooks vs polling.
A common and sensible combination is SSE for the server's stream plus normal HTTP requests for the client's actions. The client posts to an endpoint, the server processes it and emits the result on the stream. That keeps every request individually authenticated, logged, retried, and debuggable with ordinary tools, which a WebSocket message is not. You can inspect an event stream directly with the API Tester or with curl -N, and there is no equivalent one liner for a WebSocket frame.