How Server-Sent Events and WebSockets work at the protocol level
Server-Sent Events
An SSE stream is an ordinary HTTP response that never finishes. The server sends Content-Type: text/event-stream, keeps the connection open, and writes UTF-8 text as events occur. Each event is a group of field: value lines ended by a blank line. The fields are event (a named type), data (the payload, which can span several lines), id (a cursor for resumption) and retry (the reconnect delay in milliseconds). Lines that start with a colon are comments, which makes them useful as keep-alives. A raw payload looks like this: id: 1042, event: order.updated, data: {"orderId":"A17","status":"shipped"}, then an empty line.
The id field matters because of how the browser EventSource API behaves. When the connection drops, EventSource reconnects on its own and sends the last ID it received in a Last-Event-ID request header. The server can then replay any events the client missed. Reconnection is built into the client, but resumption only works if the server can look up events by ID.
WebSockets
A WebSocket connection starts as an HTTP GET with Upgrade: websocket and a Sec-WebSocket-Key header. The server replies 101 Switching Protocols, and after that the TCP connection no longer carries HTTP. Both sides exchange framed messages (text, binary, ping/pong, close) in both directions at the same time.
Node.js has not had a long-standing core WebSocket server API. Servers have usually used a third-party library or newer runtime support. In either case, the protocol defines only frames. Your application has to implement heartbeats, reconnection with backoff, message types, acknowledgements and resumption.
// Minimal SSE endpoint using only Node's core http module.
const http = require('node:http');
// In-memory event log so reconnecting clients can resume (illustrative only).
const log = [
{ id: 1041, event: 'order.updated', data: { orderId: 'A16', status: 'packed' } },
{ id: 1042, event: 'order.updated', data: { orderId: 'A17', status: 'shipped' } },
];
function writeEvent(res, e) {
// Each event is a group of field lines ended by a blank line.
res.write(`id: ${e.id}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);
}
http.createServer((req, res) => {
if (req.url !== '/events') { res.writeHead(404).end(); return; }
// A normal HTTP response that is never ended.
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// Ask EventSource to wait 5 seconds before reconnecting.
res.write('retry: 5000\n\n');
// On reconnect, EventSource sends the last id it saw. Replay anything newer.
const lastId = Number(req.headers['last-event-id'] ?? 0);
log.filter((e) => e.id > lastId).forEach((e) => writeEvent(res, e));
// Comment lines (starting with ':') keep idle connections from timing out.
const ping = setInterval(() => res.write(': keep-alive\n\n'), 15000);
req.on('close', () => clearInterval(ping));
}).listen(8080);
A dependency-free Node.js SSE endpoint that writes raw text/event-stream frames (id, event, data, retry and comment keep-alives) and uses the Last-Event-ID header to replay events a reconnecting client missed.
SSE vs WebSockets in Node.js: which one to choose
For most Node.js APIs that push live updates, start with SSE. If data flows mainly from server to client as text or JSON, such as notifications, job progress, dashboards or feed updates, SSE gives you a stream over plain HTTP. The occasional client action, like marking an item read, goes out as a normal POST. You keep your existing routing, middleware, auth and logging. You also skip most of the plumbing that WebSockets leave to you.
| Concern | SSE | WebSockets |
|---|---|---|
| Directionality | Server to client only | Full duplex |
| Payloads | UTF-8 text (JSON fits) | Text and binary frames |
| Reconnection | Built into EventSource, resumes via Last-Event-ID |
You implement it |
| Auth | Cookies; browser EventSource can't set custom headers |
Cookies or token on handshake/first message; no custom headers from browsers |
| HTTP tooling | Normal request, works with curl and middleware |
Upgrade escapes most HTTP middleware |
| Proxies | Needs response buffering disabled and long idle timeouts | Needs Upgrade support and long idle timeouts |
Choose WebSockets when the client talks back often and with low latency. That covers chat, collaborative editing, multiplayer state, and binary protocols. In those cases, sending a separate HTTP request per message adds overhead, and the cost of managing your own message framing pays for itself.
If updates arrive every few seconds or less often, consider something simpler. Plain polling against a cacheable endpoint, or long polling, avoids holding connections open at all. That can be the cheapest option to scale.
When to avoid each option
Rule out SSE when
Traffic is bidirectional or binary. SSE only carries server-to-client messages, and the event stream is UTF-8 text. Binary payloads must be base64-encoded, which adds roughly a third to their size and costs CPU on both ends. Client-to-server messages travel as separate HTTP requests, each with its own headers, auth checks and routing. For chat or collaborative editing, where clients send often, that overhead adds up.
You rely on bearer-token auth. The native EventSource API cannot set custom headers, so there is no Authorization header. You are left with three options. Cookies bring CSRF and cross-origin configuration. Query-string tokens end up in access logs, proxy logs and browser history. A fetch-based stream reader avoids both, but you must then implement parsing and reconnection yourself.
Clients run on HTTP/1.1. Browsers limit concurrent connections per origin, commonly to around six. Each open tab holds one stream, so a user with several tabs can exhaust the pool and stall ordinary API calls. HTTP/2 multiplexes streams over one connection and largely removes this limit, provided every hop to the browser supports it.
Rule out WebSockets when
You depend on HTTP infrastructure. Once a connection upgrades, HTTP caching no longer applies, and neither do many request-level middleware, rate limiters and tracing tools. Some corporate proxies and firewalls block or drop upgraded connections.
You can't afford to build the plumbing. Heartbeats to detect dead peers, reconnection with backoff and resubscription after a reconnect are all left to your code.
Implementing SSE correctly in Node.js
Start with the headers. Send Content-Type: text/event-stream and Cache-Control: no-cache so neither the browser nor an intermediary stores the stream. On HTTP/1.1, add Connection: keep-alive. Omit it on HTTP/2, where connection-specific headers are invalid. Call res.flushHeaders() right away so the client's EventSource opens without waiting for the first event.
Next, make sure nothing between res.write() and the socket holds bytes back. Compression middleware is the usual cause, because gzip accumulates data until it has enough to compress efficiently. Exclude the SSE route from compression, or flush after each event. Reverse proxies that buffer upstream responses need buffering turned off for this path.
Idle connections get killed by load balancers and proxies with idle timeouts. Write a comment line (one starting with :) every 15–30 seconds. Clients ignore comments, but intermediaries see traffic, and a failed write tells you the peer is gone.
Give every event an id: field. On reconnect, the browser sends the last ID it received in the Last-Event-ID header, so the server can replay what the client missed.
When req emits close, clear the heartbeat timer and remove the client from any subscriber set. Skipping this is the most common source of leaked listeners and memory.
Finally, check the return value of res.write(). If it returns false, the kernel buffer is full. Either stop writing until drain fires, or, for consumers that stay behind, cap the queue and destroy the connection so the client reconnects and resumes by ID.
Test end to end through your real proxies and CDN. A local test can pass while production buffering delivers events in bursts or not at all.
const http = require('node:http');
const clients = new Set();
let nextId = 1;
const MAX_QUEUED = 100; // frames buffered per slow client before we disconnect it
function send(client, frame) {
if (client.queue) { // already waiting for 'drain'
client.queue.push(frame);
if (client.queue.length > MAX_QUEUED) client.res.destroy(); // client reconnects with Last-Event-ID
return;
}
if (!client.res.write(frame)) { // buffer full: hold frames until drain
client.queue = [];
client.res.once('drain', () => {
const pending = client.queue.join('');
client.queue = null;
if (pending) send(client, pending);
});
}
}
http.createServer((req, res) => {
if (req.url !== '/events') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive', // HTTP/1.1 only; omit on HTTP/2
});
res.flushHeaders(); // open the EventSource before the first event
const lastId = Number(req.headers['last-event-id']) || 0; // replay events with id > lastId here
const client = { res, queue: null };
clients.add(client);
const heartbeat = setInterval(() => send(client, ': ping\n\n'), 20000); // comment line keeps proxies from idling out
req.on('close', () => { clearInterval(heartbeat); clients.delete(client); }); // avoid leaked timers and listeners
}).listen(3000);
setInterval(() => { // demo publisher: every event carries an id
const frame = `id: ${nextId++}\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`;
for (const c of clients) send(c, frame);
}, 5000);
A minimal SSE server on Node's core http module that sets stream headers, flushes them right away, sends comment heartbeats, numbers events for Last-Event-ID resume, cleans up on close, and queues frames until drain, disconnecting clients whose queue grows past a cap.
Connection scaling fundamentals for long-lived connections
Capacity for both protocols is bounded by how many sockets stay open, not by requests per second. An idle subscriber sends nothing, but it still holds kernel and process resources until it disconnects.
| Limit | Why it bites | Mitigation |
|---|---|---|
| File descriptors | Every socket uses one, and the default per-process limit is often low | Raise ulimit -n and the system-wide limits |
| Per-connection memory | TLS state, kernel buffers and application objects grow linearly with clients | Measure the footprint and cap connections per instance |
| Event-loop write work | A broadcast to N clients means N writes on a single thread | Serialize the payload once, reuse the buffer, and watch event-loop lag |
| Load balancer idle timeout | Quiet connections get cut, then reconnect in bursts | Send heartbeats (SSE comment lines, WebSocket pings) at intervals shorter than the timeout |
Worked example (illustrative estimate)
The inputs below are assumptions, not measurements:
- Per-connection overhead: 50 KB, covering TLS state, socket buffers and app state.
- Target load: 200,000 concurrent clients.
- Per-instance cap: 20,000 connections.
The calculation:
- Each instance holds 20,000 × 50 KB ≈ 1 GB for connections alone, before heap and runtime overhead.
- Serving the target needs 200,000 ÷ 20,000 = 10 instances.
- Provision 12 instead of 10. When an instance dies, its clients reconnect to the survivors at almost the same moment, and the extra capacity absorbs that surge.
Your real per-connection number can be several times higher or lower than 50 KB. It depends on TLS settings, compression, buffered backlog and how much state you attach to each client. Load-test with realistic connection counts and message rates, and record resident memory and event-loop lag as the count climbs. Plan capacity from those results, not from estimates like this one.
Horizontal scaling with a pub/sub fan-out layer and reliable resumption
A socket lives inside exactly one Node.js process, and only that process can write to it. Once you run several instances behind a load balancer, the code that produces an event usually runs on a different instance from the one holding the subscriber's connection. The fix is a shared pub/sub bus. Producers publish to a topic, and every instance subscribes to the topics its local clients care about, then writes to its own sockets. Instances never call each other directly.
WebSockets need sticky routing only if the handshake spans several requests, for example with polling fallbacks. A plain upgrade completes in one request, but the connection stays on that backend for its whole lifetime. That makes load uneven after scale-out, so rebalancing has to happen through reconnects.
Neither protocol guarantees delivery across a disconnect. With SSE, set an id on every event and keep a short per-topic replay buffer. When the client reconnects, the browser sends Last-Event-ID, and you replay everything after it. For WebSockets, build the same protocol yourself: stamp each message with a per-topic sequence number, and have clients resubscribe with the last number they saw.
The bus adds its own failure mode. A partition or a lagging subscriber can drop events while every socket still looks healthy. Watch for sequence gaps on the instance side, and when you find one, send clients a "resync" signal so they fetch fresh state instead of trusting a stale stream.
During rolling deploys, drain each instance gracefully: stop accepting new connections, then close existing ones. Clients must reconnect with jittered exponential backoff. Without jitter, thousands of clients reconnect at the same moment and overload the remaining instances and the replay path.
Producers (API handlers, background workers)
|
| publish(topic, event, seq/id)
v
+------------------------------------------------------------+
| Shared pub/sub bus (topics, ordered per topic) |
| partition or lag -> silent drops while sockets look fine |
+------------------------------------------------------------+
| | |
subscribe subscribe subscribe
v v v
+----------------+ +----------------+ +----------------+
| Node inst A | | Node inst B | | Node inst C |
| local sockets | | local sockets | | DRAINING: |
| replay buffer | | replay buffer | | refuse new, |
| seq gap check | | seq gap check | | close existing |
+----------------+ +----------------+ +----------------+
| | | | |
SSE WS SSE WS reconnect, jittered
| | | | exp. backoff to A/B
v v v v v
+------------------------------------------------------------+
| Load balancer: WS pinned to one backend per connection; |
| sticky only if handshake spans requests (polling fallback) |
+------------------------------------------------------------+
^
| SSE: reconnect sends Last-Event-ID -> replay after id
| WS: resubscribe {topic, lastSeq} -> replay or 'resync'
| gap detected on instance -> 'resync', refetch state
Clients
Producers publish to a shared bus; each Node.js instance delivers only to its own sockets, replays from a short buffer on resume, signals resync on sequence gaps, and drains during deploys while clients reconnect with jittered backoff.
Impact on development cost and engineering time
The cost difference shows up less in the first prototype than in everything that has to exist around it once the feature reaches production.
SSE is an ordinary HTTP response that never finishes, so it passes through the stack you already run. Auth middleware validates the cookie or bearer token the same way it does for any GET. Access logs, tracing, rate limits and the load balancer's HTTP/2 termination apply without new configuration. On the client, EventSource reconnects on its own and sends the Last-Event-ID header. The only client work is handling events. The server's job is to honour that header.
WebSockets leave HTTP after the upgrade. From then on your team owns the framing above the socket: message types, acknowledgements, and authorization for each message if permissions can change mid-session. The browser API does not expose protocol-level ping/pong, so liveness detection usually becomes an application heartbeat. Reconnection and resumption have to be written and tested by hand. Request logs record a single upgrade and nothing after it, so message-level metrics need their own instrumentation.
| Concern | SSE | WebSockets |
|---|---|---|
| Auth | Existing HTTP middleware | Handshake plus per-message checks |
| Reconnect and resume | Built into EventSource |
Custom client code |
| Liveness | Comment-line keepalives | Application heartbeat protocol |
| Observability | Existing HTTP tooling | Separate message metrics |
| Proxy and LB config | Standard HTTP | Upgrade support, idle timeouts |
Default to SSE and send client actions over normal POST requests. Move to WebSockets when client-to-server traffic is frequent, latency-sensitive and tied to session state, as in collaborative editing, multiplayer state or live audio control. At that point, a request for every message costs more than maintaining your own protocol layer.
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 sse vs websockets nodejs, 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.