Real-Time Communication in Node.js
"Real-time" in a Node.js API almost always means one specific thing: the server needs to tell a connected client something before that client asks for it.
Search across all documentation pages
"Real-time" in a Node.js API almost always means one specific thing: the server needs to tell a connected client something before that client asks for it.
Plain HTTP was never built for that - every exchange starts with a client request, and a server can only reply to a request that already arrived.
WebSocket, Server-Sent Events, and long-polling are three different ways of bending that request-first model into something that supports server-initiated pushes, and each one makes a different trade between directionality, complexity, and infrastructure compatibility.
Real-Time Basics shows the code for picking between them; this page is about the underlying problem they all solve and the cost each solution imposes on a Node process once connections start piling up.
HTTP's request-response model means the server is fundamentally reactive: it can only speak when spoken to, replying to a request and then, in the classic model, closing that exchange.
That's fine for a page load, but it breaks down the moment a server needs to tell an already-connected client "something changed" without that client having asked in the last second.
Three mechanisms solve this, each by relaxing a different part of HTTP's rules:
data: lines to it over time, which the browser's EventSource API turns back into discrete events.A useful way to picture the difference: WebSocket is like opening a phone line and leaving it connected so either side can speak whenever they want; SSE is like a one-way radio broadcast the listener tuned into, where only the station transmits; long-polling is like repeatedly calling someone and asking "anything new yet?", waiting on hold until they either have news or hang up.
// SSE: the server never "finishes" this response - it just keeps writing
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write(`data: ${JSON.stringify({ price: 142.5 })}\n\n`);
// connection stays open; more res.write() calls follow laterWhich mechanism fits depends almost entirely on directionality: does the client ever need to send data through the same channel after the initial connection, or does it only ever receive?
The deeper mechanical difference between these three isn't the wire format - it's what each one costs a Node process to keep open.
An ordinary HTTP request occupies server resources only for the brief window between arrival and response; a persistent connection (WebSocket or SSE) occupies a socket, a chunk of memory for buffers and per-connection state, and an entry in the event loop's bookkeeping for as long as it's open, whether or not any data is actively flowing.
That's a fundamentally different resource model: a request-per-second server scales with request rate, while a persistent-connection server scales with connection count held simultaneously - a service with modest traffic but 50,000 open WebSocket connections is under real memory pressure even if messages are rare.
Node's single-threaded event loop handles this reasonably well for idle connections, since an open-but-quiet socket costs memory but no CPU; the loop only does work when a message actually arrives on one of them.
Where it gets expensive is active fanout - broadcasting a message to many connections at once - because serializing and writing to each socket is synchronous work that occupies the same single thread every other connection's messages are waiting behind.
// Broadcasting is O(n) work on the one thread every connection shares -
// this is the part that costs CPU, not the idle connections themselves
for (const client of connectedClients) {
client.send(payload); // each call executes serially on the same event loop
}That single line is why "how many connections can Node hold" and "how fast can Node broadcast to all of them" are two different questions with two different answers - idle connection count is bounded mostly by memory, but broadcast throughput is bounded by how much synchronous work each send() costs, multiplied by how many recipients there are.
Authentication has its own mechanical wrinkle here too: because a persistent connection isn't a fresh request each time, credentials have to be checked once, at connection or upgrade time - a WebSocket has no per-message "Authorization header" the way HTTP does, so an unauthenticated socket that's allowed to connect first and get checked later is a real, exploitable gap.
A single Node process is not where real-time systems stay for long once they need to scale past one instance, and that's where a second, harder problem shows up: fanout across processes.
If two users on the same chat channel connect to two different Node instances behind a load balancer, a message from one has to somehow reach the other - the WebSocket connections themselves are process-local, so instance A has no direct way to write to a socket instance B is holding.
The standard fix is a shared message bus (Redis pub/sub is the common choice in this stack) that every instance subscribes to: instance A publishes the message once, and every instance - including B - receives it and forwards it to whichever of its own local sockets care.
That single addition changes the failure mode of the whole system: connection count no longer needs sticky routing to "just work" for correctness, but the message bus itself becomes a new dependency that has to stay up, and message ordering/delivery guarantees are now only as strong as the bus provides.
Scaling Real-Time covers this pattern - sticky sessions, Redis adapters, and the operational tradeoffs - in depth; the point at this level is recognizing why a single-process solution stops being enough well before raw connection count is the bottleneck.
Higher-level libraries exist specifically to absorb this complexity: Socket.IO bundles automatic reconnection, room-based fanout, and a Redis adapter for exactly this cross-instance problem, at the cost of a custom protocol layered on top of WebSocket (or a fallback transport) rather than raw WebSocket frames.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Raw WebSocket (ws) | Full control, minimal overhead, standard protocol | You build reconnection, rooms, and fanout yourself | Custom protocols, latency-critical bidirectional apps |
| Server-Sent Events | Plain HTTP - works through most proxies/load balancers unmodified, automatic browser reconnect | Server-to-client only; no client-to-server channel on the same connection | Live feeds, dashboards, notification streams |
| Long-polling | Works anywhere plain HTTP works, including hostile network environments | Higher latency, constant connection churn, more server load per message | Fallback path only, rarely a first choice today |
| Socket.IO | Reconnection, rooms, and multi-instance fanout built in | Custom protocol - not interoperable with plain WebSocket clients; added dependency weight | Teams that want the scaling problem mostly solved out of the box |
Server-initiated delivery: getting data to an already-connected client without that client having to ask for it again. Plain HTTP only supports the reverse - a client asking and a server replying.
HTTP's request-response model requires a request to exist before a response can be sent - there's no channel for the server to write to on its own initiative, unless something (an upgrade, a held-open response, or repeated polling) changes that.
It starts as one - an HTTP request with an Upgrade header - but once the server responds 101 Switching Protocols, both sides stop speaking HTTP entirely and exchange raw framed messages over the same TCP socket for as long as it's open.
SSE is defined as part of the browser's EventSource API, which has built-in reconnection behavior specified as part of the standard. WebSocket is a lower-level protocol with no such API contract - reconnection is left entirely to application code.
Not by itself - idle connections mainly consume memory (socket buffers and per-connection state), and the event loop does no work on a connection until a message actually needs to be sent or received on it.
Usually message volume, specifically broadcast fanout - sending to many connections at once is synchronous work on the single event-loop thread, so it competes with every other connection's messages, while idle connections alone mostly just cost memory.
Each WebSocket connection is held by whichever specific process accepted it - there's no built-in way for one Node process to write to a socket a different process is holding, which is why cross-instance real-time systems need a shared message bus.
Rarely for new systems - mainly as a fallback when infrastructure (certain corporate proxies) blocks persistent connections outright. It costs more latency and server load per message than the alternatives.
No - it's a custom protocol layered on top of WebSocket (with a fallback transport for environments that block it), which is why a plain WebSocket client can't talk to a Socket.IO server without the matching client library.
At connection or upgrade time, before any messages are accepted - a persistent connection has no per-message equivalent of an HTTP Authorization header, so checking auth "after the first message" leaves a real window where an unauthenticated socket is already connected.
Only once you're running more than one server instance - a single-process real-time feature can broadcast directly to its own local connections. A shared pub/sub layer only becomes necessary when a message from one instance needs to reach a client connected to another.
Stack versions: This page was written for Node.js 24 LTS, npm 10+, and TypeScript 5.6+.
Reviewed by Chris St. John·Last updated Jul 15, 2026