What you'll learn
Quick Answer
Server-Sent Events (SSE) is a one-way stream from server to client over an ordinary HTTP response withContent-Type: text/event-stream. The browser'sEventSourcereconnects automatically and can resume from the last event it saw. WebSockets open a two-way, persistent connection over a separate protocol (ws://) after an HTTP upgrade handshake, carrying text or binary in both directions. Use SSE for notifications, feeds, and progress; use WebSockets for chat, collaboration, and anything the client sends frequently.
One Way vs Two Way
SSE is a single long-lived HTTP response that never finishes. The server keeps writing text into it, and the client reads it as a stream. Traffic goes one direction: server to client. To send data the other way, the client makes a normal, separate HTTP request.
WebSockets start as an HTTP request carrying an Upgrade: websocket header. Once the server agrees, that TCP connection stops speaking HTTP and switches to the WebSocket protocol, which is full duplex - either side can send a message at any time, with no request/response pairing.
The practical difference is infrastructure. SSE rides on plain HTTP, so proxies, load balancers, HTTP/2, gzip, and browser dev tools mostly just work, and a firewall that allows HTTPS allows SSE. WebSockets need every hop in the path to understand the upgrade handshake and to tolerate a long-lived connection that no longer carries HTTP semantics - most modern infrastructure does, but older corporate proxies and some API gateways still do not, or they impose short idle timeouts that silently sever the socket.
SSE in Code
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
res.write("id: 1\n");
res.write("event: price\n");
res.write("data: " + JSON.stringify({ value: 101 }) + "\n\n"); // blank line ends the eventThe wire format is deliberately tiny: data: lines carry the payload, an optional event: names the type, an optional id: lets the client track its position, and a blank line marks the end of one message. That is the entire specification. The client is one line - const es = new EventSource("/events") - then es.addEventListener("price", ...) for named events or es.onmessage for unnamed ones. Verified: the stream delivered three events with Content-Type: text/event-stream, each parsed from its data: line.
The reliability you get for free is the valuable part. If the connection drops, EventSource reconnects on its own after a delay you can set with a retry: field. It re-sends the ID of the last event it received as a Last-Event-ID header, so the server can replay only what was missed. Verified: a reconnect carrying Last-Event-ID: 2 received events 3 and 4 only, with no duplicates. Rebuilding that recovery logic by hand on WebSockets is real work.
WebSockets in Code
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "welcome" }));
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
});
});Client: const ws = new WebSocket("wss://host"), then ws.onmessage to receive and ws.send(...) to transmit, at any time in either direction. Verified: the client sent a ping and an echo request and received welcome, pong, and the echo text uppercased - all over one connection. Binary works too: a byte array sent from the client came back transformed, verified. Use wss:// in production, the TLS-encrypted form, the same way you use https://.
What you do not get: reconnection (a dropped socket stays dropped until your code opens a new one - verified, nothing retries on its own), heartbeats to notice a connection that died without a close frame, back-pressure handling when a client reads slower than you write, and any structure to your messages - the { type: ... } envelope is yours to invent and to route on. Libraries such as Socket.IO exist precisely to add reconnection, rooms, acknowledgements, and transport fallbacks back on top.
Four Gotchas
- EventSource cannot send custom headers. There is no way to add an
Authorizationheader, so token auth has to go through a cookie, a query-string parameter, or a polyfill that runs SSE overfetch. This catches people adding SSE to an existing bearer-token API. - HTTP/1.1 caps parallel connections per domain at about six. Each open
EventSourceuses one. Open the app in several tabs and the later tabs stall waiting for a slot. HTTP/2 multiplexes many streams over one connection and removes the limit - so this bug shows up only on HTTP/1.1, often only in production behind a proxy. - Proxies and load balancers buffer or kill streams. A default nginx config buffers responses, so SSE events arrive in a clump instead of live; you need
proxy_buffering offand raised read timeouts. Idle-connection timeouts drop both SSE and WebSockets - send a periodic comment line or ping to keep them warm. - WebSockets need sticky sessions or a backplane to scale. With more than one server instance a client is connected to exactly one of them, so broadcasting to everyone requires all instances subscribed to a shared channel (Redis pub/sub, NATS) or a load balancer pinning each client to its instance.
Which to Choose
Pick SSE when data flows mostly downward: notifications, activity feeds, live scores, build and deploy progress, dashboard metrics, and streaming LLM tokens - which is why many AI chat UIs use SSE. It is less code, needs no extra dependency, and degrades better through corporate proxies.
Pick WebSockets when the client sends messages often and latency matters: chat, multiplayer games, collaborative editing and cursors, live support consoles, remote terminals.
If the client only sends something occasionally, SSE plus a normal POST for the upward messages is a perfectly good design and keeps you on plain HTTP. And if updates are rare - every few minutes - plain polling with setInterval and fetch is simpler than either, and worth considering before you commit to any persistent connection.
