Quick Answer

Server-Sent Events (SSE) is a one-way stream from server to client over an ordinary HTTP response with Content-Type: text/event-stream. The browser's EventSource reconnects 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 event

The 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 Authorization header, so token auth has to go through a cookie, a query-string parameter, or a polyfill that runs SSE over fetch. This catches people adding SSE to an existing bearer-token API.
  • HTTP/1.1 caps parallel connections per domain at about six. Each open EventSource uses 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 off and 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.

Frequently Asked Questions

Can Server-Sent Events send data from client to server? No. SSE is server-to-client only. The client sends data with a separate ordinary HTTP request, such as a POST.
Do WebSockets reconnect automatically? No. A dropped WebSocket stays closed until your code opens a new one. You implement reconnection and backoff yourself, or use a library like Socket.IO that does it for you.
Does SSE work with binary data? Not directly - the format is UTF-8 text. Send base64 or JSON, or use WebSockets, which support binary frames natively.
Why do my SSE events arrive in bursts instead of live? A proxy, often nginx, is buffering the response. Disable response buffering for that endpoint and raise the read timeouts.
Which one is better for streaming AI or LLM responses? SSE. The data only flows one way, tokens down to the client, it is simple to implement, and automatic reconnection is built in.