What this module covers: The four ways a server can push to a client, and the practical rule most teams get wrong — choose SSE unless you genuinely need the client to stream too. Why holding connections makes your servers stateful, and what that costs at deploy time: a reconnect storm whose expense is authentication and state rehydration rather than sockets. Scaling to a million connections, where the real bottleneck is fan-out writes rather than connection count, and where the ephemeral-port limit is widely misunderstood. The backplane that lets any server reach any client, and the subscription topology that quietly multiplies every message by your server count. Presence that is not a lie, since TCP will happily hold a socket to a phone that lost signal an hour ago. Delivery guarantees over a socket — a send() is not a delivery — and the sequence-plus-replay design that survives a reconnect. Backpressure on a slow client, and conflation as the technique that makes high-frequency data tractable. Then the security and infrastructure details that break real-time specifically: CORS not applying to WebSockets, a JWT that expires mid-connection, and a load balancer's 60-second idle timeout.
Four Mechanisms, and the Rule for Choosing
HTTP is request/response: the client asks, the server answers. Real-time needs the opposite direction, and there are four ways to get it.
Direction
Per-message overhead
Reconnect/resume
Proxy-friendly
Use when
Polling
client asks
full HTTP request
trivial (stateless)
yes
frequency ≥ several seconds
Long polling
client asks, server holds
full request per message
trivial
mostly
legacy support needed
SSE
server → client
~a few bytes
built in (Last-Event-ID)
yes
notifications, feeds, progress, token streaming
WebSocket
bidirectional
~2–6 bytes/frame
you build it
needs config
client also streams: chat, collaboration, games
Polling is not embarrassing. If the requirement is "the dashboard should be roughly current," a 10-second poll is stateless, cacheable, survives deploys with no reconnect logic, and needs no backplane. The latency is half the interval on average, and the waste is real but often trivially affordable. Reach for something else when the interval you would need is under a couple of seconds, or when the poll is expensive relative to the change rate.
Long polling holds the request open until data arrives or a timeout fires. It gets you push latency over ordinary HTTP — and it costs a held connection per client anyway, so you pay WebSocket's resource profile with HTTP's per-message overhead. It exists for environments that cannot do better.
Server-sent events is a plain HTTP response that never ends, streaming data: lines. Its underrated advantages: automatic reconnection with resume is in the browser, via the Last-Event-ID header replaying from where the client left off; it passes through proxies as ordinary HTTP; and it needs no special server protocol.
text
Its limits: one direction only, text only (base64 for binary), and the HTTP/1.1 six-connections-per-origin limit — a user with four tabs open exhausts the browser's connection budget for your domain, which manifests as the rest of your site hanging. Over HTTP/2 this disappears because streams are multiplexed (Module F-6), which makes SSE substantially more attractive than its reputation suggests.
WebSockets give a full-duplex frame-based channel after an HTTP upgrade. Genuinely necessary when the client streams — chat, cursors, collaborative editing, gameplay. The costs are the ones people underestimate: no reconnect or resume semantics (you implement both), HTTP caching and standard header-based auth no longer apply, load balancers and proxies need explicit configuration, and every message needs its own framing convention because the protocol gives you only bytes.
The rule: use SSE unless the client needs to stream to the server. Most "real-time" features are server→client — notifications, live counts, order status, progress bars, LLM tokens — and for those SSE gives you reconnection and replay for free, which is precisely the machinery teams end up hand-rolling badly on WebSockets. Client→server events can go over ordinary POST requests; they are usually infrequent.
(WebTransport over HTTP/3 is the emerging option, adding unreliable and unordered datagrams alongside reliable streams — genuinely useful for games and media. WebRTC data channels solve peer-to-peer and very low latency, at the cost of a signalling server, ICE/TURN infrastructure, and considerable complexity.)
Analogy: the difference between checking your letterbox (polling), standing at the letterbox until something arrives (long polling), a radio broadcast you tune into and which tells you the last bulletin number so you can catch up after a dropout (SSE), and a telephone call (WebSocket). The telephone is the most capable and the only one where hanging up loses the conversation, where the exchange must remember which line you are on, and where nobody can tell whether you are still listening unless one of you speaks.
Connections Make Servers Stateful, and Deploys Become the Problem
A stateless HTTP server can be replaced at will: drain, exit, start a new one, and no client notices (Module A-5). A server holding 40,000 WebSockets cannot — terminating it drops 40,000 connections that must all re-establish.
That is the reconnect storm, and the arithmetic decides whether it is a non-event or an outage:
text
The expensive part of a reconnect is not the socket. It is what happens on connect: the TLS handshake (CPU), the token validation, the session lookup, the subscription restore, and the "what did I miss?" query — typically several database or cache reads per client. A million clients reconnecting is a million-fold read spike against your database, which is Module P-12's thundering herd in its most concentrated form.
The mitigations, all of which must be in place before you have a million connections:
Jittered exponential backoff in the client (Module A-6), non-negotiable. Without jitter, every client that dropped at the same moment retries at the same moment, forever.
Stagger the rollout deliberately, and treat "how long does a full fleet replacement take" as a design parameter rather than whatever your orchestrator defaults to.
Tell the client when to come back. On graceful shutdown, send a close frame with a reconnect hint — a randomised delay the server chooses — so the server controls the return rate rather than hoping clients are polite.
Make reconnect cheap. Cache the session lookup, make the "what did I miss" query a single indexed read from a cursor rather than a scan, and consider a resumable token that avoids re-authenticating from scratch.
Do not require sticky routing. If a client can reconnect to any instance and still receive its messages, you have removed an entire class of problem — which is what the backplane below is for.
Scaling Connections: the Bottleneck Is Fan-Out, Not Sockets
Holding many connections costs four things: memory per connection (socket buffers plus TLS state, tens of kilobytes each), file descriptors, CPU for TLS, and — the one that actually binds — the writes.
Rough figures worth having: a tuned Node process holds on the order of 50,000–100,000 idle WebSocket connections, memory-bound at roughly 10–50 KB each. So a million connections is 10–20 processes plus headroom — a real cluster, but not an exotic one.
Two misconceptions to clear up:
File descriptors are a limit you must raise, not a limit that stops you.ulimit -n defaults are often 1,024, which fails at a trivial connection count and produces confusing EMFILE errors. Raise it explicitly.
The 65,535 port limit is not a per-server connection limit. A TCP connection is identified by the four-tuple (source IP, source port, destination IP, destination port), so a server listening on one port accepts connections from many clients without exhausting anything. Ephemeral port exhaustion affects outbound connections from one host to one destination — which is a real problem for a proxy or a server making many connections to one backend, and not a limit on inbound sockets.
And the real bottleneck: fan-out writes. One message to a room of 10,000 subscribers is 10,000 socket writes. At 100 messages/second into that room, that is a million writes per second — and the connection count barely mattered.
The single most effective optimisation is to serialise (and compress) once and write the same buffer to every socket:
ts
Related: permessage-deflate compression is enabled by default in some stacks and is CPU-expensive per message per client. For small frequent messages it can cost more than it saves; measure before enabling, and prefer compressing once at the application level if payloads are large.
The Backplane: Any Server Must Be Able to Reach Any Client
Once connections span N instances, an event produced anywhere must reach the instance holding the relevant socket. That component is the backplane, and choosing it is choosing your delivery guarantee.
Redis pub/sub. Trivial to use, extremely fast, and at-most-once with no persistence: a message published while an instance is briefly disconnected is gone, with no record. Correct for genuinely ephemeral data — cursor positions, typing indicators, live viewer counts.
Redis Streams or Kafka. Durable and replayable, so a reconnecting client can resume from a cursor and a briefly-disconnected server can catch up. Required whenever "the user must not miss this" is true — notifications, chat messages, order updates.
Postgres LISTEN/NOTIFY. A legitimate small-scale backplane with three real limits: no persistence (a notification with no listener is lost), a payload cap of 8,000 bytes, and each listener needs a dedicated connection, which collides with connection pooling (Module P-4). Fine for a handful of instances; not a fan-out substrate.
Then the topology problem that catches people:
text
That N× amplification means the backplane, not the sockets, becomes the ceiling. Three ways out:
Subscribe only to the channels you have local subscribers for. Obvious and often not done, because a wildcard subscription is easier to write.
Pin rooms to instances with consistent hashing (Module P-7), so a room's messages go to exactly one instance — which also gives you per-room ordering for free, and reintroduces a routing requirement for clients.
Maintain a client→instance registry (a Redis hash) so a targeted message is delivered to one instance rather than broadcast. Cheap and effective for direct messages, and it inherits Module A-5's stale-entry problem: an instance that died without cleaning up leaves entries pointing nowhere, so registrations need a TTL refreshed by heartbeat.
Presence That Is Not a Lie
TCP does not tell you the client is gone. A phone that enters a tunnel, a laptop that closes its lid, a NAT that drops its mapping — in all of these the server's socket remains open and writable for minutes or hours, until a write eventually fails or an OS keepalive expires (which defaults to hours).
So a green dot based on "the socket is open" is a lie, and every real-time system needs an application-level heartbeat:
ts
And presence must be modelled as a lease, not a flag:
text
This is the same lesson as Module P-19's job lease, Module A-2's lock and Module P-16's idempotency claim: any state that means "someone is holding this" must expire on its own, because the cleanup path does not run when a process is killed.
Two more honesty requirements. Presence is per session, not per user — one person may have a phone, a laptop and two tabs, so "online" is a non-empty set of sessions and "offline" needs all of them gone. And "last seen 2 minutes ago" is more honest than a green dot whose accuracy is bounded by your heartbeat interval; if your detection window is 45 seconds, do not render a claim more precise than that.
A send() Is Not a Delivery
The layers of "delivered" are distinct, and treating them as one is where lost messages come from:
text
A client that receives a message and immediately reloads has lost it. A socket that drops between the ack and the handler has lost it. Neither produces any signal at the server.
So for anything that must not be missed, the design is sequence numbers plus client acknowledgement plus server-side replay:
Every message on a channel carries a monotonically increasing sequence (Module A-1's logical clock, in its simplest form).
The client persists the highest sequence it has processed — not received.
On reconnect, the client sends that cursor and the server replays from a durable log (Module P-15, or Redis Streams). SSE does this for you with Last-Event-ID; on WebSockets you build it.
Delivery is therefore at-least-once, so the client must deduplicate by message ID — Module P-16's argument pointed at the browser.
If you do not build this, be explicit that the channel is lossy and make the client's correctness independent of it — for example, by treating the socket as a hint to refetch rather than as the source of data. That pattern ("something changed, go read it") is underrated: it eliminates the entire replay problem and makes the socket a latency optimisation over polling rather than a delivery mechanism.
Backpressure on a Socket, and Conflation
Module A-8's rule applies per connection: a client on poor mobile network cannot absorb your message rate, so its send buffer grows in your process memory. A thousand slow clients is an out-of-memory kill.
Every socket needs a bounded outbound buffer and a policy for exceeding it:
ts
The three policies, and when each is right: disconnect (correct for a client that cannot keep up with a stream it needs in full), drop (correct for genuinely disposable events), and — the technique worth internalising — conflate.
Conflation (latest-value-wins) is the answer for high-frequency state. A price ticker, a cursor position, a telemetry gauge, a progress percentage: the client does not need every intermediate value, only the current one. So instead of queueing every change, keep the latest value per key and flush on a fixed tick:
text
Bandwidth and CPU drop by a factor of twenty, the client's rendering improves (it was throttling anyway), and the semantics are unchanged because state is idempotent and events are not. This is Module P-16's absolute-versus-relative distinction, applied to a wire protocol: send state, not deltas, and the slow client problem largely disappears.
Auth and Infrastructure, Where Real-Time Breaks Specifically
The browser cannot set custom headers on a WebSocket upgrade. So the common patterns are a short-lived ticket fetched over ordinary authenticated HTTP and passed as a query parameter, or authenticating in the first message after the socket opens (rejecting the connection if it does not arrive within a second or two). Prefer the ticket, keep its lifetime to seconds, make it single-use — and note that a token in a query string lands in access logs, which is a reason to keep it short-lived and to scrub logs (Module O-9).
CORS does not apply to WebSockets. This is the security detail most likely to be missed: the same-origin policy does not prevent a malicious page from opening a WebSocket to your server, and cookies are sent on the upgrade. That is cross-site WebSocket hijacking, and the defence is to validate the Origin header on the upgrade request yourself, plus not relying on cookies alone for authorisation (the ticket pattern also fixes this, since the attacker's page cannot obtain a ticket).
A long-lived socket outlives its token. A JWT valid for 15 minutes on a connection open for six hours means you are serving an unauthenticated client for five hours and forty-five minutes. Either re-authenticate periodically over the socket (the client sends a fresh token; the server closes the connection if it does not arrive) or subscribe to a revocation channel so a logout or permission change closes affected sockets. A permission change that does not reach open connections is a real authorisation gap (Module A-13).
Infrastructure details that produce baffling bugs:
Load balancer idle timeouts. An ALB defaults to 60 seconds and will close a connection with no traffic. Your heartbeat interval must be comfortably below it — this is the single most common cause of "connections drop every minute in production but not locally."
Proxy buffering breaks SSE. nginx buffers responses by default, so events are held until the buffer fills and the stream appears frozen. proxy_buffering off; or the X-Accel-Buffering: no response header fixes it.
Serverless and edge runtimes cannot hold connections (Module F-10) — no work after the response, execution ceilings, frozen containers. Real-time needs long-lived processes, which is why it usually sits outside a serverless architecture even when everything else fits.
Deploy configuration matters more here than anywhere else: a termination grace period longer than your drain, and a rollout speed chosen against the reconnect arithmetic above.
Why this matters in production: real-time is the most operationally expensive thing in this phase, because it makes your servers stateful and puts your deploy process, your auth lifetime and your client's network conditions into the failure path. The honest first question is therefore whether you need it: if a 5-second poll meets the requirement, poll — it is stateless, needs no backplane, survives deploys invisibly, and has no presence, replay, or backpressure problem. And for small teams, a managed service (Pusher, Ably, AppSync, Supabase Realtime) is a legitimate answer that buys exactly the machinery this module describes; the reason to build it yourself is scale, cost at scale, or a requirement those products do not serve.
Where This Shows Up in Your Stack
Node.js:ws is the standard and uWebSockets.js is materially faster if connection count is your constraint. The wins that matter: pre-serialise the frame once and write the same buffer to N sockets; measure permessage-deflate before enabling it, since per-message compression per client is expensive; use protocol ping/pong for liveness; raise ulimit -n; and remember a synchronous handler blocks every connection on that instance (Module P-19). Watch bufferedAmount per socket and enforce a policy.
Redis: pub/sub for ephemeral fan-out (at-most-once, no persistence), Streams when a reconnecting client must resume, a TTL key per session for presence, and a hash for the client→instance registry. client-output-buffer-limit is Redis applying this module's backpressure rule to you — a subscriber that cannot keep up is disconnected rather than allowed to grow the server's memory.
PostgreSQL:LISTEN/NOTIFY is a workable small-scale backplane, bounded by no persistence, an 8,000-byte payload, and a dedicated connection per listener that conflicts with pooling (Module P-4). Good for "something changed, go refetch"; not a fan-out substrate.
Kafka: the durable source of the events being pushed, and the replay log that makes resume-from-cursor possible (Module P-15). It is not a per-client transport — millions of consumer groups is not its model — so it feeds the gateway tier rather than the clients.
Load balancers and proxies: raise idle timeouts above your heartbeat, disable response buffering for SSE, and confirm the upgrade path is configured. Sticky sessions are not required if you have a backplane, and avoiding stickiness is worth real effort (Module F-9).
Managed real-time services: buy this rather than build it unless you have a specific reason. They provide presence, replay, fan-out, auth tickets and reconnection — the whole of this module — and the cost crossover generally arrives at high connection counts or unusual requirements.
Summary
Four mechanisms: polling (stateless, fine at multi-second intervals, needs no backplane), long polling (holds a connection anyway, so it costs WebSocket resources with HTTP overhead), SSE (server→client, with automatic reconnect and replay via Last-Event-ID, proxy-friendly, text only, and constrained by HTTP/1.1's six-connections-per-origin limit which HTTP/2 removes), and WebSockets (bidirectional and low-overhead, but reconnect, resume, framing and auth are all yours to build).
The rule: use SSE unless the client needs to stream to the server. Most real-time features are one-directional, and SSE gives you for free the reconnect-and-replay machinery teams hand-roll badly on WebSockets. Client→server events can go over ordinary POSTs.
Holding connections makes servers stateful, which makes deploys the problem. A fleet of 20 instances holding a million connections replaced over 60 seconds is ~17,000 reconnects/second; replaced in 5 seconds it is 200,000/second and a collapse. The cost of a reconnect is the TLS handshake, token validation, session lookup and state rehydration — not the socket — so a mass reconnect is a million-fold database read spike.
Mitigate with jittered client backoff (mandatory), staggered rollouts treated as a design parameter, a server-chosen reconnect delay in the close frame, a cheap single-read resume path, and no reliance on sticky routing.
The bottleneck at scale is fan-out writes, not connection count. One message to a 10,000-member room is 10,000 writes. Serialise and compress once and write the same buffer to every socket; measure permessage-deflate rather than assuming it helps.
Raise ulimit -n, and stop worrying about the 65,535-port limit — a connection is a four-tuple, so an inbound listener is not port-bound; ephemeral exhaustion affects outbound connections to one destination.
The backplane determines your delivery guarantee: Redis pub/sub is at-most-once with no persistence (correct for typing indicators and cursors), Redis Streams or Kafka are durable and replayable (required when a message must not be missed), and Postgres LISTEN/NOTIFY works at small scale with no persistence, an 8 KB payload and a connection per listener.
Avoid the N× subscription amplification: subscribe only to channels with local subscribers, pin rooms to instances with consistent hashing (which also gives per-room ordering), or keep a client→instance registry for targeted delivery — with a TTL, since a dead instance's entries otherwise point nowhere.
TCP will not tell you the client is gone. A socket to a phone in a tunnel stays open and writable for a long time, so application-level heartbeats are the only liveness signal, and presence must be a TTL lease refreshed by heartbeat, never a flag set on connect and cleared on disconnect — because the clear does not run when a process is killed.
Presence is a set of sessions, not a boolean, and "last seen 2 minutes ago" is more honest than a green dot whose accuracy is bounded by your heartbeat interval.
A send() is not a delivery. Buffered, acked, received, processed and rendered are five different things, and a reconnect between any two loses the message with no server-side signal. For must-not-miss channels: per-channel sequence numbers, a client cursor persisted on processed, server-side replay from a durable log, and client-side deduplication by message ID. If you will not build that, make the socket a hint to refetch rather than the source of data — which removes the replay problem entirely.
A slow client's send buffer is your memory. Bound it per socket and choose a policy: disconnect, drop, or — for high-frequency state — conflate. Latest-value-wins at a fixed tick cuts bandwidth and CPU by an order of magnitude with unchanged semantics, because state is idempotent while events are not.
Auth has real-time-specific holes. Browsers cannot set headers on the upgrade, so use a short-lived single-use ticket (and remember it lands in access logs). CORS does not apply to WebSockets and cookies are sent on the upgrade, so validate Origin yourself or you have cross-site WebSocket hijacking. And a six-hour socket outlives a fifteen-minute token — re-authenticate over the connection or subscribe to a revocation channel, or a permission change never reaches open connections.
Infrastructure breaks real-time in specific ways: load-balancer idle timeouts (an ALB's 60-second default is the usual cause of "drops every minute in production"), nginx response buffering freezing SSE, and serverless/edge runtimes being unable to hold connections at all.
Ask whether you need it. A 5-second poll is stateless, backplane-free, deploy-invisible, and has no presence, replay or backpressure problem. And for small teams a managed real-time service buys exactly this module; build it yourself for scale, cost at scale, or an unmet requirement.
The next module, Blob Storage and Media Pipelines, deals with the other kind of payload — the bytes too large to put in a database or a message. It covers object-storage semantics and how they differ from a filesystem, pre-signed uploads that keep gigabytes out of your application tier entirely, transcoding as a job pipeline, and delivery through a CDN with signed URLs so that access control survives caching.
Knowledge Check
A team builds a live order-status feature with WebSockets. In production, connections drop roughly every 60 seconds and reconnect, though everything works locally and no errors appear in application logs. Separately, users occasionally miss a status change entirely — the socket reconnected, but the message sent during the gap never arrived. What are the two causes?
A trading dashboard streams price updates over WebSockets at up to 300 updates per second per symbol. Under load the server's memory climbs until it is OOM-killed, and profiling shows most of it is in socket send buffers for clients on mobile networks. What does the module prescribe, and why does it not change the product's semantics?
A "who is online" feature sets presence:user:<id> = online in Redis when a WebSocket connects and deletes the key on disconnect. Users report seeing colleagues shown as online who left hours earlier, and occasionally being shown as offline while actively using the app on a second device. What are the two defects?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
event: order.updated
id: 8891
data: {"orderId":42,"status":"shipped"}
: comment lines act as keep-alives ← send one every ~20s
1,000,000 connections, 20 instances.
Rolling deploy, 20 instances over 60 seconds:
~17,000 reconnects/sec → survivable with headroom
Whole fleet restarted (bad config push, cluster event) in 5 seconds:
200,000 reconnects/sec → 200k TLS handshakes, 200k auth lookups,
200k state rehydrations. Total collapse.
// Bad: JSON.stringify per recipient — 10,000 serialisations of one object.for(const c of room) c.send(JSON.stringify(payload));// Good: one serialisation, one buffer, N writes.const frame = Buffer.from(JSON.stringify(payload));for(const c of room) c.send(frame);// libraries can even pre-build the WS frame
Every instance subscribes to every channel:
1 message × 20 instances = 20 deliveries, 19 of which are discarded.
At scale, every instance processes every message in the system.
// Protocol-level ping/pong is the right tool; a missed pong is your only liveness signal.setInterval(()=>{for(const c of clients){if(!c.isAlive){ c.terminate();continue;}// missed the last pong: gone c.isAlive =false; c.ping();}},20_000);ws.on("pong",()=>{ ws.isAlive =true;});
WRONG: SET presence:user42 online (on connect)
DEL presence:user42 (on disconnect — never runs if the process dies)
RIGHT: SET presence:user42:sess7 1 EX 45 (refreshed by each heartbeat)
→ presence is "does any session key exist", and a dead server's entries expire
send() returned → buffered in your process
TCP acked → arrived at the client's OS
onmessage fired → the client's JS received it
processed → the client applied it to its state
user saw it → rendered
// bufferedAmount is the signal. Choose a policy — never let it grow unbounded.if(ws.bufferedAmount >1_000_000){// 1 MB backed up ws.close(1013,"too slow");// or drop, or conflate — but decide}
Events in: ▁▃▂▄▅▃▂▄▅▆▄▃ (200/s)
Sent out: ────▅────▃──▃ (10/s, latest value each time)