How SSE and WebSockets work on the wire
Server-Sent Events (SSE) is not a separate protocol. It is an ordinary HTTP response that the server never finishes. The server replies with Content-Type: text/event-stream and keeps writing to the response body. Each event is a group of lines, and a blank line ends the event.
| SSE: response body on the wire | WebSocket: handshake on the wire |
|---|---|
id: 41 |
Request: GET /ws HTTP/1.1 |
event: order.updated |
Upgrade: websocket / Connection: Upgrade |
data: {"id":"A17","status":"shipped"} |
Sec-WebSocket-Key: <random base64> |
| (blank line ends the event) | Sec-WebSocket-Version: 13 |
retry: 5000 |
Response: HTTP/1.1 101 Switching Protocols |
: keep-alive comment |
Sec-WebSocket-Accept: <hash of the key> |
SSE uses four fields:
data:carries the payload.event:names the event type.id:gives the client a cursor. A browserEventSourceresends the last value in aLast-Event-IDheader when it reconnects.retry:sets the reconnect delay in milliseconds.
Lines that start with a colon are comments. They are often used as heartbeats to keep proxies from closing an idle connection. Because the stream is plain HTTP, existing middleware, auth headers and compression keep working. The tradeoff is that the stream only goes from server to client, and the format is UTF-8 text only. Binary data has to be encoded, for example as base64, which makes it larger.
A WebSocket uses HTTP only to open the connection. After the 101 response, both sides stop speaking HTTP and switch to a framed, full-duplex protocol on the same TCP connection. Each frame has an opcode that marks it as text, binary, ping, pong or close. Frames sent from the client are masked. Either side can send at any time, and binary payloads are supported natively. The cost is that intermediaries no longer understand the traffic after the handshake.
# --- SSE: one HTTP response that never ends ---
# Client request
GET /orders/stream HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Last-Event-ID: 40 # sent by EventSource on reconnect
# Server response: status, headers, then a body that keeps growing
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
retry: 5000 # reconnect delay in ms
id: 41
event: order.updated
data: {"id":"A17","status":"shipped"}
# blank line dispatches the event
: keep-alive # comment line, used as a heartbeat
# --- WebSocket: HTTP only for the opening handshake ---
# Client request (key/accept pair is the sample from RFC 6455)
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# Server response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
# After this point the TCP connection carries binary WebSocket frames
# (text, binary, ping, pong, close), not HTTP text.
Raw bytes on the wire: an SSE response streaming id/event/data/retry fields as plain UTF-8 text, next to a WebSocket Upgrade handshake that ends in 101 and hands the connection to the framed protocol.
Choosing by traffic direction: who should use which protocol
The deciding question is which way the data flows. SSE is one-way: the server pushes events down a long-lived HTTP response, and the client cannot write to that stream. When the client needs to act, such as acknowledging a message, changing a subscription or submitting input, it sends an ordinary HTTP request. Each of those requests carries its own headers, auth check and routing. It may also reach a different backend instance than the one holding the stream. Nothing orders a client's POST relative to the events the server is pushing, so if ordering matters, the application has to correlate them with IDs or sequence numbers. WebSockets carry both directions on a single connection, so client sends and server pushes share one ordered channel.
| SSE | WebSockets | |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Payload types | UTF-8 text (binary must be encoded) | Text and binary frames |
| Reconnection | Built into EventSource, resumes via Last-Event-ID |
Application implements it |
| Auth | Normal HTTP auth; EventSource cannot set custom headers, so cookies or fetch-based clients |
Checked at the upgrade request; browsers cannot set custom headers |
| HTTP tooling | Works with curl, standard logs and middleware | Needs WebSocket-aware tools |
| Proxy behavior | Passes through if response buffering is disabled | Proxy must support Upgrade |
Most backend update streams flow from the server to the client: job progress, deploy status, log tails, notifications and dashboard metrics. The client mostly listens and occasionally issues a command, and an HTTP request handles that well. For backend and platform teams, that makes SSE the sensible default. Reach for WebSockets when clients send frequently, or when you need client and server messages ordered on the same channel.
Implementing a minimal SSE endpoint in Node.js
With the core http module, an SSE endpoint is an ordinary request handler that never calls res.end(). Call res.writeHead(200, ...) with Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive. Then leave the response open and write to it as events occur.
Event format and heartbeats
Each event is a block of field: value lines followed by a blank line, for example id: 42\ndata: {"status":"ok"}\n\n. A message that spans several lines needs one data: line per line of payload. Every few seconds, write a comment line (: ping\n\n). The browser ignores comments, but the traffic stops proxies and load balancers from closing a connection they consider idle.
Replay with Last-Event-ID
When EventSource reconnects, it sends the last ID it received in the Last-Event-ID header. Read req.headers['last-event-id'], replay every event after that ID from an ordered log, and then switch to live events. Without a durable history, events published during the disconnect are lost. The client does not get an error, so a gap in the data goes unnoticed.
Backpressure and cleanup
res.write() returns false once the socket buffer is full. When that happens, stop writing and wait for the 'drain' event before sending more. If you keep writing to a slow consumer, its unsent data accumulates in process memory without limit. Register a handler for req.on('close') that clears the heartbeat timer and removes the subscriber. Otherwise each disconnected client leaves a timer and a reference behind.
Browser client
const es = new EventSource('/events') opens the stream, and es.onmessage = (e) => render(JSON.parse(e.data)) handles each event. The browser reconnects automatically and sends Last-Event-ID without any extra code.
const http = require('http');
const log = []; // ordered, ID'd history (use durable storage in production)
const clients = new Set();
let nextId = 1;
const MAX_QUEUED = 10000; // cap per-client backlog so a slow consumer can't exhaust memory
const format = (e) => `id: ${e.id}\ndata: ${e.data}\n\n`; // JSON has no raw newlines
function flush(c) {
while (c.queue.length && !c.paused) {
if (!c.res.write(c.queue.shift())) { // socket buffer full: stop writing
c.paused = true;
c.res.once('drain', () => { c.paused = false; flush(c); });
}
}
}
function enqueue(c, chunk) {
if (c.queue.length >= MAX_QUEUED) return c.res.destroy(); // drop; client reconnects and replays
c.queue.push(chunk);
flush(c);
}
function publish(payload) {
const e = { id: nextId++, data: JSON.stringify(payload) };
log.push(e);
for (const c of clients) enqueue(c, format(e));
}
http.createServer((req, res) => {
if (req.url !== '/events') { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const c = { res, queue: [], paused: false };
const lastId = Number(req.headers['last-event-id']) || 0;
for (const e of log) if (e.id > lastId) enqueue(c, format(e)); // replay missed events first
clients.add(c); // then receive live events
const hb = setInterval(() => enqueue(c, ': ping\n\n'), 15000); // comment heartbeat for proxies
req.on('close', () => { clearInterval(hb); clients.delete(c); }); // no leaked timers/refs
}).listen(8080);
setInterval(() => publish({ status: 'ok', at: Date.now() }), 2000); // demo producer
// Browser: const es = new EventSource('/events'); es.onmessage = (e) => render(JSON.parse(e.data));
A core-http SSE endpoint that replays from an ordered event log using Last-Event-ID, sends ': ping' heartbeats, waits for 'drain' when res.write() returns false, caps each client's backlog, and cleans up on 'close'. A one-line EventSource client is shown in the final comment.
What you take on with a WebSocket server
WebSockets give you none of that for free. Node's core http module exposes the upgrade event but no WebSocket server, so teams typically adopt a library or framework. Once you do, your team owns:
- Upgrade handling: validating the handshake, rejecting bad origins, and deciding which paths may upgrade.
- Liveness: sending periodic pings and dropping sockets that miss pongs. TCP alone won't tell you a mobile client vanished, and idle proxies close quiet connections silently.
- Framing conventions: text or binary, JSON or something compact, and a maximum message size.
- An application protocol: message types, request/response correlation IDs, error shapes, and versioning. Conceptually, every frame becomes
{type, id, payload}, and you write the dispatcher. - Client reconnection: exponential backoff with jitter, plus resume logic such as "send me everything after sequence N". SSE gets most of this from
Last-Event-ID. Here, both sides must implement it.
Auth happens at handshake time, via a cookie or a token in the query string or first message. The browser WebSocket API can't set arbitrary headers either. A session that lasts hours can outlive the credential, so the server must re-validate periodically and close sockets whose access was revoked. SSE has a similar constraint, because EventSource can't send custom headers and relies on cookies or short-lived tokens. With SSE, though, each reconnect is a fresh HTTP request that passes through your normal auth path.
The larger cost is tooling. After the upgrade you leave HTTP behind, along with caching semantics, per-request middleware, access logs, and request-level metrics and tracing. Logging, rate limiting, and observability have to be rebuilt at the message level.
// Conceptual sketch of what a WebSocket server makes you own.
// `Conn` stands in for whatever socket object your chosen library provides.
interface Conn {
send(data: string): void;
ping(): void;
close(code: number, reason: string): void;
alive: boolean; // set back to true by your pong handler
token: string; // credential captured at handshake time
}
type Frame = { type: string; id?: string; payload?: unknown };
type Handler = (conn: Conn, payload: unknown) => unknown;
declare const allConns: Set<Conn>;
declare function isTokenValid(token: string): boolean; // e.g. expiry + revocation lookup
// Framing and application protocol: size limit, JSON envelope, correlation IDs, error shape.
function onMessage(conn: Conn, raw: string, handlers: Record<string, Handler>): void {
if (raw.length > 64 * 1024) return conn.close(1009, "message too big");
let frame: Frame;
try { frame = JSON.parse(raw); } catch { return conn.close(1007, "invalid JSON"); }
const handler = handlers[frame.type];
if (!handler) {
return conn.send(JSON.stringify({ type: "error", id: frame.id, payload: "unknown type" }));
}
// Resume is just another handler, e.g. type "resume" with { after: N } replaying your own log.
conn.send(JSON.stringify({ type: "result", id: frame.id, payload: handler(conn, frame.payload) }));
}
// Liveness and long-session auth: neither happens unless you schedule it.
function sweep(): void {
for (const conn of allConns) {
if (!conn.alive) { conn.close(1001, "missed pong"); allConns.delete(conn); continue; }
if (!isTokenValid(conn.token)) { conn.close(1008, "credential expired"); allConns.delete(conn); continue; }
conn.alive = false; // stays false until the next pong arrives
conn.ping();
}
}
setInterval(sweep, 30_000);
A library-agnostic sketch of the server-side pieces a WebSocket deployment owns: message size limits, a typed JSON envelope with correlation IDs and a dispatcher, and a periodic sweep that drops sockets that miss pongs or whose credentials have expired.
Running long-lived connections in production
Infrastructure between client and server sees a long-lived response, not a protocol, so defaults written for short requests cause most production failures. Reverse proxies often buffer upstream responses or compress them, and either can hold SSE events until a buffer fills. Disable buffering and compression on streaming routes; with nginx, an X-Accel-Buffering: no response header does this per response. Idle timeouts close quiet connections of both kinds. Some proxies and load balancers also drop the Upgrade and Connection headers unless you configure WebSocket pass-through explicitly.
Over HTTP/1.1, browsers cap connections per origin, typically at six. Each SSE stream holds one open, so a few tabs can exhaust the pool and stall ordinary requests. HTTP/2 multiplexes streams over a single connection, which largely removes this limit.
clients ──> load balancer ──> app-1 ─┐
├─> app-2 ─┼──> pub/sub broker
└─> app-3 ─┘
Each instance holds only its own sockets. A publish on app-1 cannot reach a client on app-3 unless every instance subscribes to a shared broker and forwards matching messages.
On deploys, stop accepting new connections and close existing ones gradually rather than all at once. If thousands of clients reconnect in the same second, they create a reconnect storm. Clients should use exponential backoff with random jitter. For SSE, the server can send a retry: field to set the reconnect delay, and Last-Event-ID lets a client resume from where it left off.
Some intermediaries break streams without closing them. Send periodic heartbeats: SSE comment lines, or WebSocket ping/pong frames. Have clients reconnect when no data or heartbeat arrives within a set timeout.
┌──────────┐ ┌──────────┐ ┌──────────┐ heartbeats + client timeout
│ tab A │ │ tab B │ │ tab C │ backoff + jitter, retry: hint
└────┬─────┘ └────┬─────┘ └────┬─────┘ resume via Last-Event-ID
└─────────────┼─────────────┘ HTTP/1.1: ~6 conns per origin
▼ HTTP/2: one shared connection
┌─────────────────────────────────────────────┐
│ reverse proxy / load balancer │
│ SSE routes: buffering off, compression off │
│ idle timeout longer than heartbeat interval│
│ WebSocket: pass Upgrade/Connection headers │
└────────┬─────────────┬─────────────┬────────┘
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ app-1 │ │ app-2 │ │ app-3 │ deploy: stop new conns,
│ own conns│ │ own conns│ │ own conns│ drain existing gradually
└───┬─┬────┘ └───┬─┬────┘ └───┬─┬────┘
▼ ▲ ▼ ▲ ▼ ▲ publish ▼ ▲ subscribe
┌─────────────────────────────────────────────┐
│ shared pub/sub broker │
│ every instance subscribes, forwards to its │
│ own matching clients over SSE or WebSocket │
└─────────────────────────────────────────────┘
Clients connect through a load balancer configured for streaming. Each app instance holds only its own connections, so a shared pub/sub broker delivers every published message to all instances, and each instance forwards it to its matching clients.
When to avoid streaming, and the alternatives
Streaming is not always the right default. Each protocol has cases where it costs more than it returns.
SSE is the wrong fit for binary-heavy payloads. The event stream format is UTF-8 text, so images, audio, or protobuf messages must be base64-encoded. That inflates payloads by about a third and adds encode and decode work on both ends. It is also a poor fit for high-rate bidirectional messaging. Every client-to-server message becomes a separate HTTP request with its own headers and routing, which adds latency and overhead that a single socket avoids.
WebSockets are the wrong fit for mostly one-way traffic. You take on framing, your own reconnection logic, and resume semantics, and you lose the automatic Last-Event-ID handling that SSE provides.
Neither is worth it for low-frequency updates. An idle stream still holds a file descriptor, memory, and heartbeat traffic. If data changes every few minutes, that is hours of open connection carrying almost nothing. There are two simpler alternatives:
- Plain polling works with ordinary caching, load balancers, and stateless servers.
- Long polling holds the request open until data arrives or a timeout expires. It gives near-immediate delivery without a persistent protocol.
| Use case | Recommended | Why |
|---|---|---|
| Notifications | Polling, or SSE if latency matters | Updates are infrequent and one-way |
| Dashboards | SSE, or polling for slow refresh | Server pushes metrics and clients only read |
| LLM token streaming | SSE | Text flows one way and per-request streams end naturally |
| Collaborative editing | WebSockets | Frequent edits travel both ways with low latency |
| Multiplayer or high-rate chat | WebSockets | High message rate in both directions, often binary |
Impact on development cost and engineering time
Most of the cost difference comes from what you can reuse. An SSE endpoint is an ordinary HTTP response that stays open. Your existing authentication middleware, request logging, rate limiting, tracing headers and reverse proxy rules apply to it unchanged. The browser's EventSource reconnects on its own and sends Last-Event-ID if you set event IDs, so resumption becomes a server-side lookup rather than client logic you have to write.
A WebSocket server starts from less. After the upgrade handshake, your HTTP middleware no longer sees individual messages. That leaves several jobs to your team:
- Message protocol. Define message types, versioning and error frames.
- Liveness. Add ping/pong handling or heartbeats to detect dead peers.
- Reconnection. Write client-side backoff and state resync.
- Observability. Instrument per-message metrics, because request logs stop at the handshake.
Each of these needs tests, and you maintain each one for the life of the system.
Neither protocol is cheaper to keep open. Both hold memory and a file descriptor for every connection.
Illustrative estimate (assumed numbers, not a benchmark): total memory = concurrent connections × memory per connection. Assume 20,000 connections at 50 KB each: 20,000 × 50 KB ≈ 1 GB. Substitute figures you measure on your own stack.
Default to SSE and move to WebSockets when specific signals appear. These include clients sending frequent low-latency messages, a need for binary frames, or a per-message request cost on the client-to-server path that has become your bottleneck.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to server-sent events vs websockets, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.