Home/Blog

WebSockets vs SSE: Building Real-Time Apps That Scale

When to use WebSockets or Server-Sent Events, with server and client code for auth, resume after reconnect, Redis fan-out, proxies and graceful deploys.

WebSockets vs SSE: Building Real-Time Apps That Scale

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

PropertyPolling or long pollingServer-Sent EventsWebSockets
DirectionClient pullsServer to clientBoth directions
TransportRepeated HTTP requestsOne long HTTP responseHTTP upgrade to the WebSocket protocol (RFC 6455)
PayloadAnythingUTF-8 textText and binary
Reconnect and resumeNot applicableBuilt into EventSource, with Last-Event-IDYou implement it
Custom headers from the browserYesNo; cookies onlyNo; cookies, subprotocol or URL only
Proxies and CDNsWork everywhereWork if response buffering is offNeed upgrade support and long idle timeouts
Browser connection limitsNormalOver 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 higherSeparate connections
Good forRare updates, simplest possible setupNotifications, dashboards, feeds, LLM streamingChat, 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:

CodeMeaningClient behavior
1000Normal closureDo not reconnect
1001Going away (page unload, server shutting down)Reconnect with backoff
1006Abnormal closure; never sent on the wire, the connection just droppedReconnect with backoff
1008Policy violationDo not retry blindly; surface the error
1011Internal error (unexpected condition on the server)Reconnect with backoff
1012Service restartReconnect soon, with jitter
1013Try again laterReconnect with backoff, then resync
4000 to 4999Reserved 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 PUBLISH is 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:

  1. File descriptors. Each connection is a socket. Raise the process limit (ulimit -n and the service manager's limit) well above your target.
  2. Memory per connection. Library buffers, your per-connection state and any queued outbound data.
  3. CPU for fan-out. Serializing a message once and writing the same bytes to every subscriber is far cheaper than calling JSON.stringify per socket.
  4. 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 bufferedAmount and 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.