How SSE and WebSockets work on the wire
Both protocols keep a single connection open so the server can push updates as they happen. That avoids the latency and wasted requests of polling. They differ in what that connection looks like once it is established.
SSE: a response that never ends
An SSE endpoint is an ordinary HTTP response with Content-Type: text/event-stream that the server keeps open. The handler sets that header along with Cache-Control: no-cache and flushes after every write, because any buffering in the application, framework or proxy delays delivery. The body is UTF-8 text made of fields:
id:labels the event.event:names its type.data:carries the payload and can span multiple lines.retry:sets the client's reconnect delay in milliseconds.
A blank line terminates each event. Lines starting with a colon are comments. Servers send them periodically as heartbeats so that idle-timeout intermediaries don't close the stream.
The browser's EventSource handles reconnection automatically. It honors retry and resends the last seen id in a Last-Event-ID request header. The client only reports where it left off. To resume correctly, the server still needs a replay buffer or an offset store that maps that ID to the events the client missed.
WebSockets: an upgraded socket
A WebSocket starts as an HTTP GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key and Sec-WebSocket-Version: 13. The server answers 101 Switching Protocols with a Sec-WebSocket-Accept value derived from the key. After that exchange the connection stops being HTTP. It becomes a full-duplex, framed protocol with text and binary data frames plus control frames: ping, pong and close. Reconnection and resumption are entirely the application's responsibility.
const http = require('node:http');
// Bounded in-memory replay buffer so reconnecting clients can resume.
const buffer = [];
const MAX_BUFFERED = 1000;
let nextId = 1;
const clients = new Set();
// Wire format: one field per line, blank line terminates the event.
const format = (evt) =>
`id: ${evt.id}\nevent: ${evt.type}\ndata: ${JSON.stringify(evt.data)}\n\n`;
function publish(type, data) {
const evt = { id: nextId++, type, data };
buffer.push(evt);
if (buffer.length > MAX_BUFFERED) buffer.shift();
for (const res of clients) res.write(format(evt));
}
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',
});
res.write('retry: 5000\n\n'); // reconnect delay hint for EventSource, in ms
// EventSource resends the last id it saw; replay anything newer.
// If lastId is older than buffer[0].id, events were lost: signal a full resync.
const lastId = Number(req.headers['last-event-id'] || 0);
for (const evt of buffer) if (evt.id > lastId) res.write(format(evt));
clients.add(res);
// Comment lines are ignored by clients but keep idle-timeout proxies from closing the stream.
const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 15000);
req.on('close', () => { clearInterval(heartbeat); clients.delete(res); });
}).listen(8080);
setInterval(() => publish('price', { symbol: 'ACME', value: 101.25 }), 2000);
A minimal Node.js SSE endpoint that sets the event-stream headers, emits id/event/data fields and heartbeat comments, and uses a bounded replay buffer to honor the Last-Event-ID header on reconnect.
Server-sent events vs WebSockets: direction, auth and tooling
| Concern | SSE | WebSockets |
|---|---|---|
| Direction | Server to client only | Full duplex |
| Binary payloads | No, UTF-8 text only | Yes, binary frames |
| Browser reconnection | Built into EventSource, sends Last-Event-ID |
None; application code must reconnect |
| Custom headers from browser | Not supported by EventSource |
Not supported by the browser API either; only subprotocols |
| HTTP/2 multiplexing | Yes, one stream per subscription | Only where extended CONNECT is supported end to end |
| Existing middleware and observability | Works unchanged | Mostly bypassed after the upgrade |
| Delivery guarantees | None across disconnects | None across disconnects |
With SSE, anything the client sends is an ordinary HTTP request: a POST to submit, a DELETE to cancel. Authentication, rate limiting, schema validation and request logging stay in the middleware you already run, and each action gets its own status code and access-log line. Over WebSockets, every inbound message arrives on one long-lived connection. You have to build message routing, authorize each message rather than only the handshake, and add your own metrics and logs. HTTP caching, standard access logs and many WAF rules see the upgrade request and nothing after it.
SSE has two real costs. Payloads are text, so binary data must be base64-encoded, which adds about a third to its size, or sent as a URL the client fetches separately. Because EventSource cannot set an Authorization header, auth ends up in cookies or a query-string token. Query strings are written to proxy and server logs, so those tokens should be short-lived and single-purpose.
Neither transport guarantees delivery across a disconnect. Last-Event-ID only tells the server where the client stopped. You still need a server-side replay window sized for realistic outages, per-stream sequence numbers to keep ordering, and idempotent client handlers that can safely receive the same event twice.
Connection limits, SSE scaling and capacity sizing
Under HTTP/1.1, browsers cap concurrent connections per origin, commonly at six. Each SSE stream holds one of those connections open indefinitely. A user with several tabs open can exhaust the pool, and then ordinary API calls queue behind the streams and appear to hang. HTTP/2 multiplexes streams over a single connection, with a concurrency limit the server negotiates, which largely removes the problem. The catch is that HTTP/2 must hold on every client-facing hop. A proxy that downgrades to HTTP/1.1 brings the limit back. Without that guarantee, use a single connection per browser: a shared worker owns one stream and fans events out to tabs, or one multiplexed stream carries every topic the user needs.
On the server, the cost is idle connections. Each connection holds socket buffers, TLS state, and application state. Event-loop and async servers keep that to kilobytes. Thread-per-connection servers add a thread stack to every connection, which caps them far earlier. Raise file descriptor limits above the number of connections you expect to hold, plus the upstream sockets each instance opens.
clients ──▶ LB ──▶ [instance A] ─┐
[instance B] ─┼──▶ pub/sub (topics)
[instance C] ─┘
Instances are stateless subscribers to the pub/sub layer, so any instance can serve any client.
Illustrative estimate. Assume:
- 300,000 concurrent subscribers
- 32 KB per connection
- a 2 GB connection budget per instance
That gives about 65,000 connections per instance. Capping at 50,000 for headroom means 6 instances, or 7 to leave room for draining. With a heartbeat every 20 seconds, each instance sends about 2,500 small writes per second.
+--------------------------+ +--------------------------+
| Browser 1: tab tab tab | | Browser 2: tab tab |
| \ | / | | \ / |
| [ shared worker ] | | [ shared worker ] |
+-----------------+--------+ +-----------------+--------+
| one SSE stream per browser |
v (HTTP/2 on every hop) v
+---------------------------------------------------------+
| load balancer / proxy (HTTP/2 end to end, no downgrade)|
+---------------------------------------------------------+
| | |
v v v
+-------------+ +-------------+ +-------------+
| instance A | | instance B | | instance C | ...
| async loop | | async loop | | async loop |
| <=50k conns | | <=50k conns | | <=50k conns |
+-------------+ +-------------+ +-------------+
^ ^ ^
| subscribe | subscribe | subscribe
+--------------------+--------------------+
|
+-------------------------+
| pub/sub layer (topics) |
+-------------------------+
^
| publish
+-------------------------+
| backend services |
+-------------------------+
Each browser holds one SSE stream through its shared worker, carried over HTTP/2 on every client-facing hop, to stateless async instances that subscribe to a shared pub/sub layer, so any instance can serve any subscriber.
WebSocket load balancing and proxy behavior
Those heartbeats only help if every hop forwards them unaltered, and most production trouble with long-lived connections starts at the proxies.
Upgrade and Connection are hop-by-hop headers. Each proxy strips them unless configured to set them again on the upstream request. One proxy that doesn't do this breaks the handshake for every hop behind it.
For routing, you have two options. Sticky sessions tie a client to the instance that holds its state, which works until that instance dies. Stateless instances with a shared broker let any instance serve any client, because each instance subscribes to the broker and fans messages out to its own sockets. Either way, the balancer chooses an instance only at connect time. Instances added during scale-out receive no traffic until existing clients reconnect, while the original instances stay saturated.
Each intermediary failure has its own symptom:
- Buffering: An SSE stream returns 200, then nothing arrives until a buffer fills or the stream closes, and the events land in one burst. To confirm, check whether events appear when you bypass the proxy.
- Idle timeouts: Connections drop after a fixed period of silence, with no error on either side. Connection durations that cluster at one value (for example, exactly 60 seconds) identify this cause.
- Mishandled
Upgrade: The handshake returns 400, 404 or a plain 200 instead of 101, and it fails on every attempt.
The illustrative config below forwards Upgrade, disables response buffering for SSE paths and raises read timeouts. Set heartbeats (SSE comment lines such as : ping, WebSocket ping/pong) to well under the smallest idle timeout on the path. For example, a 20-second interval leaves room for a lost beat against a 60-second timeout.
# Fragment for the http {} context of nginx.conf.
# Upgrade/Connection are hop-by-hop, so this hop must set them again.
map $http_upgrade $connection_upgrade {
default upgrade; # client asked to upgrade: forward it
'' close; # plain request: no upgrade
}
upstream push_backend {
server 10.0.1.11:8080; # placeholder app instances
server 10.0.1.12:8080;
}
server {
listen 80;
location /ws/ {
proxy_pass
proxy_http_version 1.1; # Upgrade requires HTTP/1.1 upstream
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Idle limit between reads from upstream. App sends ping every 20s.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /events/ {
proxy_pass
proxy_http_version 1.1;
proxy_set_header Connection ''; # keep upstream connection open
proxy_set_header Host $host;
proxy_buffering off; # pass each event through immediately
proxy_cache off;
# App writes a ': ping' comment line every 20s, well under this.
proxy_read_timeout 300s;
}
}
An illustrative nginx config that re-sets the Upgrade and Connection headers for WebSockets, turns off response buffering for SSE, and raises read timeouts above the 20-second application heartbeat interval.
Deploys, draining and avoiding reconnect storms
When an instance receives its shutdown signal, fail its readiness check first so the load balancer stops routing new connections to it. Keep existing connections open for a drain window, then close them in randomized batches across that window. If every connection closes at the same moment, every client reconnects at the same moment. The remaining instances then absorb a burst of handshakes, auth checks and replay queries on top of their normal load.
Tell clients when to come back before you close. For SSE, send a retry: field. EventSource uses that value in milliseconds as its reconnection delay. Browsers apply it as a fixed delay, not a randomized one, so the server should send a different random value on each connection. Then send an event id so the client resumes with Last-Event-ID instead of a full resync. For WebSockets, close with code 1001 ("going away"). The client must implement its own backoff: a random delay between zero and min(cap, base * 2^attempt), with the attempt counter reset only after a connection stays healthy for some time. The example below shows this pattern.
Long-lived connections also make rebalancing slow. A new instance receives no traffic until clients disconnect from somewhere else. Scaling out therefore adds idle capacity while the existing instances stay hot, and autoscaling keeps reacting to load it can't relieve. To fix this, set a maximum connection age with jitter, for example 30–45 minutes. Connections then cycle steadily onto new instances, and their reconnects are spread out over time.
// Browser WebSocket client with full-jitter exponential backoff.
const BASE_MS = 500; // first retry window
const CAP_MS = 30_000; // never wait longer than 30s
const HEALTHY_MS = 60_000; // connection must survive this long to reset backoff
let attempt = 0;
function connect(url: string): void {
const ws = new WebSocket(url);
let healthyTimer: ReturnType<typeof setTimeout> | undefined;
ws.onopen = () => {
// Reset only after the connection proves stable. Otherwise a server that
// accepts and then immediately drops connections would get hammered.
healthyTimer = setTimeout(() => { attempt = 0; }, HEALTHY_MS);
};
ws.onmessage = (ev) => {
// application handling goes here
};
ws.onclose = (ev) => {
clearTimeout(healthyTimer);
// 1001 (going away) is a planned drain. It uses the same jittered path,
// so clients spread out instead of all reconnecting at once.
const windowMs = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
const delayMs = Math.random() * windowMs; // full jitter: uniform in [0, window)
attempt++;
setTimeout(() => connect(url), delayMs);
};
// A close event follows an error, so reconnect logic lives only in onclose.
ws.onerror = () => {};
}
connect("wss://push.example.com/updates");
A WebSocket client that reconnects after a random delay up to min(cap, base * 2^attempt) and resets the attempt counter only after the connection has stayed open for a minute, so a drain does not trigger a synchronized reconnect storm.
When to use SSE, WebSockets or polling
Pick the transport from the workload's traits, not from habit. The table maps each trait to the option it favors.
| Workload trait | Favors SSE | Favors WebSockets | Favors polling / long polling |
|---|---|---|---|
| Message direction | Server to client only | Frequent messages both ways | Client-initiated checks |
| Frequency | Steady or bursty server events | High rate in both directions | Minutes or more between updates |
| Latency needs | Sub-second push | Sub-second round trips | Seconds of staleness acceptable |
| Payload type | UTF-8 text such as JSON | Binary frames | Any HTTP response body |
| Client type | Browsers and any plain HTTP client | Clients that already ship a WebSocket library | Anything that can make an HTTP request |
| Infrastructure | Existing HTTP stack, ideally HTTP/2 | Proxies and load balancers configured for upgrades | Unchanged HTTP stack and caches |
Default to SSE for one-way feeds
Notifications, dashboards, job progress and activity feeds fit SSE well. Upstream traffic is rare, so it can go over ordinary requests. SSE also keeps your middleware, auth and observability intact. Over HTTP/2, many streams share a single connection, which avoids the per-origin connection cap that HTTP/1.1 browsers impose.
Avoid SSE for chatty or binary traffic
Avoid SSE for collaborative editing, games, or audio and sensor streams. In those workloads each client sends at a high rate, or payloads are binary and would need base64 encoding, which adds roughly a third to their size. Use WebSockets there.
Avoid both for infrequent updates
If data changes every few minutes, an idle persistent connection still costs memory, keepalives and a reconnect on every deploy, for little benefit. Short polling handles this well and can use HTTP caching. Long polling sits between short polling and SSE: it delivers near-push latency on infrastructure that buffers or kills streaming responses, but each message needs a new request.
Impact on development cost and engineering time
The bigger cost difference is not the initial build. It is what each choice adds to the work your team already does for every HTTP endpoint.
An SSE stream is an ordinary HTTP GET. Your existing auth middleware, rate limiter, access logs, WAF rules and request tracing apply to it without changes. The SSE-specific costs are real but bounded:
- Connection limits. Long-lived streams consume file descriptors and worker capacity. Browsers also cap concurrent connections per origin over HTTP/1.1.
- Proxy buffering. Every hop must flush responses immediately and allow long idle timeouts. This is configuration, not code.
- Replay buffer design. To honor
Last-Event-IDafter a reconnect, you have to decide how many events to retain and for how long.
WebSockets leave the HTTP request model after the upgrade. Each frame is invisible to your HTTP tooling, so you rebuild several things inside the protocol:
- Per-message authorization and routing.
- Rate limiting at the frame level.
- Load balancer and proxy settings for upgrades and connection stickiness.
- Custom metrics, because request counts and latencies no longer describe what users experience.
Some work is the same whichever transport you pick:
- Pub/sub fan-out across instances.
- Draining connections during deploys and spreading out reconnects.
- Delivery semantics: ordering, deduplication and at-least-once delivery.
Make SSE the default for server-to-client push. Pay for WebSockets when you need the following:
- Frequent client-to-server messages on the same session, where separate POST requests would add unacceptable latency or overhead.
- Binary framing.
- A protocol designed around a persistent bidirectional channel, such as collaborative editing or multiplayer state.
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 shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.