Use Server-Sent Events (SSE) when the server pushes and the client mostly listens: notifications, live dashboards, feeds, LLM token streams. Use WebSockets when both sides send frequently: chat, collaborative editing, multiplayer. SSE is plain HTTP with reconnection and resume built in; WebSockets give a full-duplex channel and leave reconnection, resume and heartbeats to you. Either way, the hard parts in production are the same: authenticating the connection, resuming after a drop without losing messages, fanning out across instances, handling slow clients, and deploying without a reconnect storm.
This guide covers each of those with working Node.js and browser code, then the proxy, deploy and monitoring details that decide whether real-time features survive real traffic.
Key takeaways
- Default to SSE for server-to-client updates. It runs over ordinary HTTP and the browser handles reconnection and resume.
- Authenticate before accepting the connection. Check the session or a one-time ticket, and the Origin header, during the HTTP upgrade.
- Give every message an ID. Resume after reconnect means replaying from the last ID the client saw, which needs a server-side buffer.
- Pub/sub fans out, it does not remember. Pair Redis Pub/Sub with Redis Streams (or use NATS JetStream) so missed messages can be replayed.
- Protect the server from slow clients. Watch per-connection buffers and disconnect consumers that cannot keep up.
- Deploys are the biggest reconnect event you will have. Drain gradually, send close code 1012, and make clients reconnect with jitter.
WebSockets vs SSE vs polling
| Property | Polling or long polling | Server-Sent Events | WebSockets |
|---|---|---|---|
| Direction | Client pulls | Server to client | Both directions |
| Transport | Repeated HTTP requests | One long HTTP response | HTTP upgrade to the WebSocket protocol (RFC 6455) |
| Payload | Anything | UTF-8 text | Text and binary |
| Reconnect and resume | Not applicable | Built into EventSource, with Last-Event-ID | You implement it |
| Custom headers from the browser | Yes | No; cookies only | No; cookies, subprotocol or URL only |
| Proxies and CDNs | Work everywhere | Work if response buffering is off | Need upgrade support and long idle timeouts |
| Browser connection limits | Normal | Over HTTP/1.1, shares the browser's limit of six connections per domain across all tabs; over HTTP/2 the stream limit is negotiated with the server and is much higher | Separate connections |
| Good for | Rare updates, simplest possible setup | Notifications, dashboards, feeds, LLM streaming | Chat, collaboration, games, live cursors |
The common mistake is reaching for WebSockets by default. If the client only needs to receive, SSE does the job with less code, and the occasional client action can go through a normal HTTP request.
Server-Sent Events, properly
An SSE response is a stream of text events. Each event has optional id, event and retry fields, one or more data lines, and ends with a blank line. Lines starting with a colon are comments, which make a convenient heartbeat.
import http from "node:http";
http.createServer(async (req, res) => {
if (req.url !== "/events") return res.writeHead(404).end();
const user = await authenticate(req); // session cookie
if (!user) return res.writeHead(401).end();
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", // ask nginx not to buffer this response
});
res.write("retry: 5000\n\n"); // browser reconnect delay in ms
// EventSource sends Last-Event-ID on reconnect; replay what the client missed
for (const evt of await missedEvents(user.id, req.headers["last-event-id"])) send(res, evt);
const unsubscribe = subscribeUser(user.id, (evt) => send(res, evt));
const heartbeat = setInterval(() => res.write(": ping\n\n"), 15000);
req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
}).listen(8080);
function send(res, evt) {
res.write(`id: ${evt.id}\nevent: ${evt.type}\ndata: ${JSON.stringify(evt.data)}\n\n`);
}The browser side is short because the browser does the work:
const source = new EventSource("/events");
source.addEventListener("notification", (e) => showNotification(JSON.parse(e.data)));
source.addEventListener("price-update", (e) => updatePrice(JSON.parse(e.data)));
source.onerror = () => {
// EventSource retries on its own. It gives up (CLOSED) only on a non-200
// response or a wrong content type, for example after the session expired.
if (source.readyState === EventSource.CLOSED) redirectToLogin();
};EventSource only makes GET requests. Streaming LLM APIs that take a POST body usually send SSE-formatted events that the client reads with fetch and a stream reader instead, which also lets you set headers.
WebSockets, properly
With the ws library on Node.js, handle the upgrade yourself so that authentication and the Origin check happen before a WebSocket exists:
import http from "node:http";
import { WebSocketServer } from "ws";
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
server.on("upgrade", async (req, socket, head) => {
const user = ALLOWED_ORIGINS.has(req.headers.origin) ? await redeemTicket(req) : null;
if (!user) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req, user));
});
wss.on("connection", (ws, req, user) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
ws.on("message", (raw, isBinary) => {
const msg = parseClientMessage(raw, isBinary); // schema check; null if invalid
if (!msg) return ws.close(1008, "invalid message");
handleMessage(user, msg, ws);
});
ws.on("close", () => presenceLeave(user.id));
});
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; } // missed the last ping: dead connection
ws.isAlive = false;
ws.ping();
}
}, 25000);
server.listen(8080);Three details in that code are security controls, not style:
- The Origin check blocks cross-site WebSocket hijacking, where a malicious page opens a socket to your server and the browser attaches the user's cookies. Origin is not authentication (non-browser clients can send anything), but it closes the cross-site hole. More on this class of issue in our API security guide.
- The ticket. Browsers cannot set an Authorization header on a WebSocket. Either rely on a same-site session cookie plus the Origin check, or have the client fetch a short-lived, single-use ticket over authenticated HTTP and pass it in the URL. Long-lived tokens in URLs end up in access logs.
maxPayload. Set it to the largest message you expect. The library default is far larger than any chat message needs.
Long-lived connections also outlive the credentials that opened them. Re-check authorization periodically, or record the session expiry at connect time and close the socket with an application code (for example 4001) when it passes, so the client fetches a fresh ticket.
Backpressure
ws.send queues data in memory when the client reads slowly. One slow phone on a bad network is harmless; thousands of them during a busy event can exhaust the server's memory. Check the buffer before sending:
const MAX_BUFFERED_BYTES = 1024 * 1024;
function sendTo(ws, payload) {
if (ws.readyState !== ws.OPEN) return;
if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
ws.close(1013, "too slow, reconnect and resync"); // 1013: try again later
return;
}
ws.send(payload);
}The client reconnects, resumes from its last ID or resyncs from a snapshot, and the server's memory stays bounded.
A message protocol you can resume
Define the protocol up front. Every server event carries the channel and an ID that orders it within that channel, and every client message carries an ID so retries can be deduplicated.
type ServerMessage =
| { type: "event"; channel: string; id: string; data: unknown }
| { type: "resync"; channel: string; id: string; snapshot: unknown }
| { type: "error"; code: string; message: string };
type ClientMessage =
| { type: "subscribe"; channel: string; lastId?: string }
| { type: "publish"; channel: string; clientMsgId: string; data: unknown };On subscribe with a lastId, the server replays newer events from its buffer. If the buffer no longer reaches back that far, it sends a resync with the current state instead. For collaborative and stateful views, resync from a snapshot is the reliable default; reconstructing state from a long gap of missed events is where subtle bugs live.
The client reconnects with full jitter, resubscribes from the last IDs it saw, and keeps a bounded queue of unsent messages:
class RealtimeClient {
private ws?: WebSocket;
private attempt = 0;
private lastIds = new Map<string, string>();
private pending: string[] = [];
constructor(
private getUrl: () => Promise<string>, // fetches a fresh one-time ticket each time
private onMessage: (m: ServerMessage) => void,
) {}
async connect(): Promise<void> {
const ws = new WebSocket(await this.getUrl());
this.ws = ws;
ws.onopen = () => {
this.attempt = 0;
for (const [channel, lastId] of this.lastIds) {
ws.send(JSON.stringify({ type: "subscribe", channel, lastId: lastId || undefined }));
}
while (this.pending.length) ws.send(this.pending.shift()!);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as ServerMessage;
if (msg.type === "event" || msg.type === "resync") this.lastIds.set(msg.channel, msg.id);
this.onMessage(msg);
};
ws.onclose = (e) => {
if (e.code === 1000) return; // closed on purpose
const cap = e.code === 1012 ? 2000 : Math.min(30000, 1000 * 2 ** this.attempt++);
setTimeout(() => this.connect(), Math.random() * cap); // full jitter
};
}
subscribe(channel: string): void {
if (!this.lastIds.has(channel)) this.lastIds.set(channel, "");
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "subscribe", channel }));
} // otherwise onopen subscribes
}
publish(channel: string, data: unknown): void {
const raw = JSON.stringify({ type: "publish", channel, clientMsgId: crypto.randomUUID(), data });
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(raw);
else if (this.pending.length < 100) this.pending.push(raw); // bounded; drop beyond this
}
}Close codes tell the client what to do next:
| Code | Meaning | Client behavior |
|---|---|---|
| 1000 | Normal closure | Do not reconnect |
| 1001 | Going away (page unload, server shutting down) | Reconnect with backoff |
| 1006 | Abnormal closure; never sent on the wire, the connection just dropped | Reconnect with backoff |
| 1008 | Policy violation | Do not retry blindly; surface the error |
| 1011 | Internal error (unexpected condition on the server) | Reconnect with backoff |
| 1012 | Service restart | Reconnect soon, with jitter |
| 1013 | Try again later | Reconnect with backoff, then resync |
| 4000 to 4999 | Reserved for applications (for example 4001, session expired) | Application-defined, such as fetching a new ticket |
Scaling out: fan-out across instances
With several server instances, a message published on instance A must reach subscribers connected to instance B. Redis Pub/Sub is the usual broadcast layer, but it is fire-and-forget: an instance that is reconnecting to Redis misses whatever was published meanwhile. Pair it with a Redis Stream per channel as the replay buffer.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL); // a subscribed connection cannot run other commands
export async function publish(channel, data) {
const payload = JSON.stringify(data);
// durable, ordered copy for resume, trimmed to roughly the last 10,000 entries
const id = await redis.xadd(`stream:${channel}`, "MAXLEN", "~", 10000, "*", "data", payload);
// live fan-out to every instance with local subscribers
await redis.publish(`live:${channel}`, JSON.stringify({ id, payload }));
return id;
}
export async function replay(channel, lastId) {
// "(" makes the start exclusive (Redis 6.2 and later)
const entries = await redis.xrange(`stream:${channel}`, `(${lastId}`, "+");
return entries.map(([id, fields]) => ({ id, data: JSON.parse(fields[1]) }));
}
sub.on("message", (redisChannel, raw) => {
const channel = redisChannel.slice("live:".length);
const { id, payload } = JSON.parse(raw);
const frame = `{"type":"event","channel":${JSON.stringify(channel)},"id":"${id}","data":${payload}}`;
for (const ws of localSubscribers(channel)) sendTo(ws, frame); // serialize once, not per socket
});Three refinements matter at scale:
- Subscribe on demand. Each instance subscribes to
live:<channel>when its first local client joins and unsubscribes when the last one leaves, so instances do not receive traffic for channels nobody on them watches. - Close the subscribe gap. When a client resumes, subscribe it to live messages first, buffer them, run the replay, then deliver the buffer while skipping IDs already replayed. Otherwise a message published between replay and subscribe is lost.
- Use sharded pub/sub on Redis Cluster. Classic
PUBLISHis broadcast to every node in a cluster; sharded pub/sub (SPUBLISH,SSUBSCRIBE, Redis 7.0 and later) keeps each channel on its shard. For very high channel counts or cross-region delivery, NATS with JetStream is a common alternative.
Presence without ghosts
A frequent bug is storing presence in one Redis hash and calling EXPIRE on it. The TTL applies to the whole key, so one active user keeps everyone "online", and when it finally expires everyone vanishes at once. Store a last-seen time per user in a sorted set instead:
export async function touchPresence(userId) {
await redis.zadd("presence", Date.now(), userId); // call on connect and on each heartbeat
}
export async function onlineUsers(windowMs = 60000) {
await redis.zremrangebyscore("presence", 0, Date.now() - windowMs);
return redis.zrange("presence", 0, -1);
}If one user can have several tabs or devices, track connections rather than users, or a closed tab will mark the user offline while another tab is still open.
Proxies, load balancers and timeouts
Every hop between client and server needs to allow upgrades (for WebSockets), stop buffering (for SSE), and keep idle connections open longer than your heartbeat interval. For nginx:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws {
proxy_pass http://realtime_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location /events {
proxy_pass http://realtime_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
}On AWS, the Application Load Balancer supports WebSockets natively and its idle timeout defaults to 60 seconds, so the 25-second ping and 15-second SSE comment above keep connections alive. Sticky sessions are not required for a WebSocket itself, since one connection stays on one backend; they matter only for long-polling fallbacks such as Socket.IO's.
Serverless functions with request time limits are a poor fit for holding connections. Use long-running containers, or a managed gateway that holds the sockets for you (API Gateway WebSocket APIs, Cloudflare Durable Objects, or hosted services such as Ably and Pusher). Our Kubernetes vs serverless comparison covers that trade-off more broadly.
Deploys without a reconnect storm
Restarting an instance disconnects every client on it at the same moment. If they all reconnect at once, they hit the remaining instances together. Drain gradually instead:
process.on("SIGTERM", () => {
markNotReady(); // readiness probe fails; no new connections
const sockets = [...wss.clients];
const drainMs = 30000;
sockets.forEach((ws, i) => {
setTimeout(() => ws.close(1012, "server restarting"), (i / sockets.length) * drainMs);
});
setTimeout(() => process.exit(0), drainMs + 5000);
});On Kubernetes, set terminationGracePeriodSeconds longer than the drain window, and roll instances one or a few at a time. The client's jittered reconnect on 1012 spreads the load further.
Capacity and load testing
There is no universal connections-per-server number. What limits a node, roughly in the order teams hit them:
- File descriptors. Each connection is a socket. Raise the process limit (
ulimit -nand the service manager's limit) well above your target. - Memory per connection. Library buffers, your per-connection state and any queued outbound data.
- CPU for fan-out. Serializing a message once and writing the same bytes to every subscriber is far cheaper than calling
JSON.stringifyper socket. - Event loop lag on Node.js. A long synchronous handler delays heartbeats for every connection on the process.
Load test with k6 or Artillery using your real connect rate, subscription mix and message sizes, and include a test where every client disconnects and reconnects at once. That is what a deploy or a network blip will do in production.
What goes wrong in production
- SSE arrives in bursts. A proxy is buffering the response. Disable buffering on every hop, including CDNs.
- Quiet connections die every minute. An idle timeout somewhere is shorter than the heartbeat interval.
- Tokens show up in logs. Long-lived credentials were passed in WebSocket URLs. Use single-use tickets.
- Cross-site WebSocket hijacking. No Origin check, cookie authentication, and another site could act as the user.
- Memory climbs during busy events. Slow consumers accumulate unbounded send buffers. Watch
bufferedAmountand disconnect. - Messages vanish during a Redis failover. Pub/sub delivered nothing while subscribers reconnected. The stream buffer and resume close the gap.
- Ghost users in presence. A shared TTL on one key, or no handling for multiple tabs.
- Every deploy causes an incident. All clients reconnect at once. Drain gradually and add jitter.
- Sockets outlive revoked access. A user removed from a workspace keeps receiving its events until the connection drops. Re-check authorization on long-lived connections.
Instrument active connections per instance, connects and disconnects per minute, close codes, reconnect rate, messages in and out, publish-to-delivery latency, slow-consumer disconnects and event loop lag. A rising reconnect rate is usually the first sign of trouble. For the wider metrics, logging and tracing setup, see observability in production.
Frequently Asked Questions
What is the difference between WebSockets and Server-Sent Events?
Server-Sent Events are a one-way stream from server to browser over an ordinary HTTP response, with automatic reconnection and resume built into the EventSource API. WebSockets upgrade an HTTP connection to a separate protocol that carries text and binary messages in both directions. SSE is simpler to run through existing HTTP infrastructure, while WebSockets suit applications where the client also sends frequent messages.
When should I use SSE instead of WebSockets?
Use SSE when updates flow mainly from server to client: notifications, live dashboards, activity feeds, progress updates and streaming LLM responses. The client can still send the occasional action through normal HTTP requests. Choose WebSockets when the client sends frequent messages too, as in chat, collaborative editing, multiplayer games or live cursors.
Do WebSockets need sticky sessions?
A WebSocket connection is a single long-lived TCP connection, so once it is established it stays on one backend without any session affinity. Sticky sessions are needed when a library falls back to HTTP long polling, as Socket.IO can, because each poll is a separate request that must reach the same server. Cross-instance messaging should go through a broker such as Redis or NATS, not through affinity.
How do you authenticate a WebSocket connection?
Authenticate during the HTTP upgrade, before the connection is accepted, because browsers cannot set an Authorization header on a WebSocket. Use a same-site session cookie together with an Origin check, or a short-lived single-use ticket fetched over an authenticated HTTP request and passed in the URL. Avoid long-lived tokens in query strings, since URLs end up in proxy and server logs.
How many WebSocket connections can one server handle?
There is no fixed number; it depends on memory per connection, message rates, payload sizes, fan-out work and the file descriptor limit. Idle connections are cheap, while busy connections with large fan-out are limited by CPU and event loop time. Load test with your real connection rate, subscription mix and message sizes, and include a mass reconnect in the test.
Should I use Socket.IO or plain WebSockets?
Socket.IO adds rooms, acknowledgements, automatic reconnection and a long-polling fallback on top of WebSockets, which saves time if you want those features and accept its protocol on both ends. Plain WebSockets with a library such as ws are lighter and interoperate with any client, but you write reconnection, resume and heartbeats yourself. For server-to-client streaming only, SSE is often simpler than either.
Need real-time features that survive deploy day?
We build real-time systems with the unglamorous parts done first: authentication at the handshake, resume, backpressure, graceful drains and the metrics that show trouble early. If we built it, we are on the pager for it, so we design for the reconnect storm before it happens.