Networking for Developers

WebSockets, Server-Sent Events and Real-Time Networking

Polling versus long polling, WebSockets, SSE, scaling real-time connections, and a working example of each.

Lesson 0714 min read#networking#websockets#sse#real-time

On this page

  1. Words used in this lesson
  2. The problem: the server cannot speak first
  3. Short polling: simple and wasteful
  4. Long polling: ask, and wait
  5. Server-Sent Events: the server writes to an open response
  6. WebSockets: a real two-way channel
  7. Keeping a connection honest: heartbeats
  8. Behind a proxy: where real-time apps break
  9. Backpressure: the slow client
  10. What does one connection cost?
  11. Scaling past one server
  12. Choosing between them
  13. Try it
  14. Interview questions
  15. Key takeaways
  16. What's next

In one line: HTTP is "ask, then answer", so a server cannot speak first. For live updates you have four tools. Polling asks again and again. Long polling asks and waits for the answer. Server-Sent Events (SSE) keeps one response open and the server writes to it. WebSockets turn the connection into a two-way channel. Each is simple to start and hard to scale, because every user now holds an open connection to one particular server.

Chat, live scores, notifications, a progress bar for a long job, a shared document, a stock price. All of these need the server to tell the client something when it happens, not when the client next asks. In this lesson we build each technique with real code, run it, and look at the bytes. Then we put the same servers behind nginx and watch three things that break real-time apps in production: buffering, missing upgrade headers and idle timeouts.

This is lesson 7 of a nine-lesson series. The previous lesson was load balancers, proxies and CDNs, and we will use nginx again here. You will also use ideas from HTTP/1.1 and HTTP/2 (chunked bodies, keep-alive, the six-connection limit) and TCP (what a connection is). The last lesson, debugging network problems, gives you the tools to find these problems on a live system.

What I ran / checked. Linux, Node.js v22.22.0 (its built-in WebSocket client, and EventSource behind the flag --experimental-eventsource), the npm package ws 8.22.0, redis 6.3.0 (the Node client) talking to Redis server 7.0.15, nginx 1.24.0, curl 8.5.0 and OpenBSD netcat. All servers were local toy servers on 127.0.0.1. Output is pasted from real runs; timings, ports and memory numbers change from run to run. Sources I read (in my own words): the ws README on GitHub, the MDN pages "Writing WebSocket servers" and "Using server-sent events" (raw markdown from the mdn/content repository), and the Node.js globals.md documentation page (for WebSocket and EventSource). Did not read: the WebSocket RFC (RFC 6455) and the HTML Standard's SSE section; where I describe the wire format beyond what I observed, it comes from the MDN page plus my own runs. Could not run: real browsers (so the "six connections per browser" limit comes from the MDN page, not my test), WebSockets over HTTP/2 (I describe it from memory), and a real multi-machine deployment. The scaling test uses two Node processes and one Redis on a single machine.

Words used in this lesson

  • Polling means the client asks the server again and again on a timer: "anything new?"
  • Long polling means the client asks, and the server holds the request open until it has something to say (or a timeout).
  • WebSocket is a protocol that starts as an HTTP request and then becomes a two-way message channel on the same TCP connection.
  • SSE (Server-Sent Events) is a way for a server to push text events to a client over one long HTTP response. The browser API is called EventSource.
  • Upgrade is an HTTP header (Upgrade: websocket) that asks the server to switch to a different protocol on the same connection. The server answers 101 Switching Protocols.
  • Frame is one small packet of a WebSocket conversation: a header (type, length, mask) and a payload.
  • Ping and pong are tiny control frames used to check that the other side is alive.
  • Heartbeat is any regular small message to keep a connection alive and to notice when it is dead.
  • Half-open connection is a connection that one side thinks is open while the other side is gone.
  • Backpressure means slowing down the sender because the receiver cannot keep up.
  • Pub/sub (publish and subscribe) is a pattern where publishers send messages to a named channel and every subscriber of that channel receives them.
  • Sticky session means the load balancer always sends the same client to the same server.
  • Fan-out means delivering one message to many recipients.
  • Last-Event-ID is a header that an SSE client sends when it reconnects, telling the server the id of the last event it received.

The problem: the server cannot speak first

In the HTTP lesson every exchange had the same shape: the client sends a request, the server sends a response. The server never starts. So how does a chat message from Alice reach Bob's screen? Bob's browser must ask. The question is how often and in what style. Here are the four answers, in order of how much they change the picture.

TechniqueWho starts each messageConnectionsTypical use
Short pollingClient, on a timerNew request each time (may reuse a connection)Slow-changing status, simple dashboards
Long pollingClient, but the server delays the answerOne request open at a timeOld chat apps, fallbacks
SSEServer, over one open responseOne long HTTP responseNotifications, live feeds, progress, streaming text
WebSocketEither side, at any timeOne two-way connectionChat, games, collaboration, anything with many small messages both ways

Let us make each one real.

Short polling: simple and wasteful

Short polling is the first idea everyone has. Ask every second.

I made a server with a pretend job that finishes 3.2 seconds after the server starts. The server has an endpoint /poll that answers {"done": true/false} right away:

JavaScript
// poll.mjs (the /poll part)const doneAt = Date.now() + 3200;// ...if (req.url === "/poll") return res.end(JSON.stringify({ done: Date.now() >= doneAt }));

And the client asks once per second:

JavaScript
// pollclient.mjsconst t0 = Date.now(); const ms = () => Date.now() - t0;let n = 0;while (true) {  n++;  const j = await (await fetch("http://127.0.0.1:9000/poll")).json();  console.log(`poll #${n} at ${ms()} ms -> done=${j.done}`);  if (j.done) break;  await new Promise((r) => setTimeout(r, 1000));}console.log(`short polling: learned at ${ms()} ms using ${n} requests`);
TEXT
poll #1 at 99 ms -> done=falsepoll #2 at 1103 ms -> done=falsepoll #3 at 2107 ms -> done=falsepoll #4 at 3110 ms -> done=trueshort polling: learned at 3110 ms using 4 requestsserver hits so far: 4

(The client started about half a second after the server, so the job really finished at about 2.7 seconds on the client's clock.) The client learned the news about 0.4 seconds late, and used four requests, three of which carried no news. That is the trade-off of polling in a nutshell:

  • Delay: on average half the polling interval, at worst the whole interval.
  • Waste: most requests return "nothing new". Each one still pays for headers, the server's work, a database check, and (for HTTPS) encryption.
  • Scale: 10,000 users polling every 2 seconds is 5,000 requests per second even when nothing happens.

Polling is still often the right answer. If the data changes rarely, or a minute of delay is fine, or you must work through the simplest possible infrastructure, a 30-second poll with a cache in front is cheap and robust. Do not reach for WebSockets because they sound modern.

Trap: polling storms. If a server is slow and clients retry on a fixed timer, all clients end up asking at the same moments, and each slow answer makes the next wave bigger. Add random "jitter" to the interval, back off when you get errors, and honor Retry-After. Also make the poll endpoint cheap (read from a cache, return 304 Not Modified with an ETag).

Long polling: ask, and wait

Long polling fixes the delay with a small change. The server does not answer straight away if it has nothing to say. It keeps the request open and answers when something happens, or after a timeout. The client then immediately asks again.

JavaScript
// poll.mjs (the /longpoll part): park the response until the job is doneif (req.url === "/longpoll") {  if (Date.now() >= doneAt) return res.end(JSON.stringify({ done: true }));  waiters.push(res);                                  // keep it, answer later  setTimeout(() => {                                  // but not forever    const i = waiters.indexOf(res);    if (i >= 0) { waiters.splice(i, 1); res.end(JSON.stringify({ done: false })); }  }, 25000);  return;}// when the event happens:setTimeout(() => { for (const r of waiters.splice(0)) r.end(JSON.stringify({ done: true })); }, 3200);
JavaScript
// lpclient.mjsconst t0 = Date.now(); let n = 0;while (true) {  n++;  const j = await (await fetch("http://127.0.0.1:9000/longpoll")).json();  console.log(`long poll request #${n} returned at ${Date.now() - t0} ms -> done=${j.done}`);  if (j.done) break;}
TEXT
long poll request #1 returned at 2765 ms -> done=trueserver hits: 1

One request, answered at about 2.7 seconds, the moment the event happened (on the client's clock). Compare with polling: 4 requests and 0.4 seconds late. In a busy system the loop continues forever: ask, wait, answer, ask again, so there is always one request parked on the server for each user.

Things to know about long polling:

  • The server must hold many open requests cheaply. In Node that is easy: a parked response costs a few kilobytes and no thread. In a thread-per-request server, it is deadly.
  • Each reply costs a new HTTP request with headers, so under a heavy stream of messages it is slow.
  • Every proxy in the path has a timeout. A request that waits longer than the proxy's idle timeout is cut. So use a timeout smaller than the shortest timeout on the path (25 seconds in my code, for a typical 30 or 60 second limit), and have the client simply ask again on an empty answer.
  • Messages can fall in the gap between one reply and the next request. The server needs a way to keep events (an id or a cursor: "give me everything after event 41") or messages are lost.

Long polling works everywhere HTTP works, even through awkward corporate proxies. That is why libraries like Socket.IO used it as a fallback for a long time. But today there are better tools.

Server-Sent Events: the server writes to an open response

SSE is the plainest idea of all. The client makes one ordinary GET. The server answers with the header Content-Type: text/event-stream and then never finishes the response. Whenever it has news, it writes a few lines of text and flushes. It is the chunked response from the HTTP lesson, kept open on purpose.

Here is a complete server. It sends a numbered event twice a second:

JavaScript
// sse.mjsimport http from "node:http";let id = 0; const clients = new Set();http.createServer((req, res) => {  if (req.url !== "/events") { res.statusCode = 404; return res.end(); }  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });  const last = req.headers["last-event-id"];  console.log("[server] client connected, Last-Event-ID =", last ?? "(none)");  res.write("retry: 1000\n\n");                              // reconnect after 1 s  if (last) for (let i = +last + 1; i <= id; i++)            // replay what the client missed    res.write(`id: ${i}\nevent: tick\ndata: {"n":${i},"replayed":true}\n\n`);  clients.add(res);  req.on("close", () => { clients.delete(res); console.log("[server] client left"); });}).listen(9400, "127.0.0.1");setInterval(() => { id++; for (const r of clients) r.write(`id: ${id}\nevent: tick\ndata: {"n":${id}}\n\n`); }, 500);setInterval(() => { for (const r of clients) r.write(": keep-alive comment\n\n"); }, 2000);

Now look at what the client sees. curl -N (no buffering) shows the raw bytes. I added cat -A so line ends are visible:

TEXT
$ curl -sN -i -m 2 http://127.0.0.1:9400/events | cat -A | head -20HTTP/1.1 200 OK^M$content-type: text/event-stream^M$cache-control: no-cache^M$connection: keep-alive^M$Date: Sat, 17 Oct 2026 13:37:13 GMT^M$Transfer-Encoding: chunked^M$^M$retry: 1000$$id: 2$event: tick$data: {"n":2}$$id: 3$event: tick$data: {"n":3}$$: keep-alive comment$$id: 4$

The format has just a few rules:

  • An event is a few field: value lines followed by one blank line. The blank line is what tells the client "event complete, deliver it".
  • data: is the payload (text only; use JSON if you need structure). Several data: lines are joined with a newline.
  • event: gives the event a name. Without it, the browser delivers it as a plain message.
  • id: labels the event. The browser remembers the last one.
  • retry: tells the browser how many milliseconds to wait before reconnecting.
  • A line starting with : is a comment. Clients ignore it, and servers use it as a heartbeat to keep proxies from closing an idle connection.

Notice Transfer-Encoding: chunked. That is just the HTTP/1.1 way of sending a body of unknown length, and every write to the response is a chunk.

Reconnecting for free

The best feature of SSE is that the browser handles dropped connections itself. If the connection dies, EventSource waits for the retry time and reconnects, sending the header Last-Event-ID with the last id it saw. If your server keeps recent events, it can replay what was missed. I tested this by hand with curl first. I pretended to have seen event 2 and asked for the stream again:

TEXT
$ curl -sN -m 1.2 -H "Last-Event-ID: 2" http://127.0.0.1:9400/events | head -12retry: 1000id: 3event: tickdata: {"n":3,"replayed":true}id: 4event: tickdata: {"n":4,"replayed":true}id: 5event: tick

Then I tested the real client. Node 22 has EventSource as an experimental feature, so I turned it on with --experimental-eventsource (without the flag, typeof EventSource is undefined; with it, function). My test server drops the connection every 3 messages on purpose, and counts from the Last-Event-ID it receives:

JavaScript
// es.mjs  (run with: node --experimental-eventsource es.mjs)const es = new EventSource("http://127.0.0.1:9401/");es.onopen = () => console.log("client: open, readyState", es.readyState);es.onmessage = (e) => console.log(`client: got "${e.data}" (lastEventId ${e.lastEventId})`);es.onerror = () => console.log("client: error/reconnecting, readyState", es.readyState);
TEXT
client: open, readyState 1client: got "message 1" (lastEventId 1)client: got "message 2" (lastEventId 2)client: got "message 3" (lastEventId 3)client: error/reconnecting, readyState 0client: open, readyState 1client: got "message 4" (lastEventId 4)client: got "message 5" (lastEventId 5)client: got "message 6" (lastEventId 6)client: error/reconnecting, readyState 0client: open, readyState 1client: got "message 7" (lastEventId 7)

And the server log, which proves the browser-side API sent the header on its own:

TEXT
[server] connect, Last-Event-ID = (none)[server] dropping connection after 3[server] connect, Last-Event-ID = 3[server] dropping connection after 6[server] connect, Last-Event-ID = 6

No message was lost and none was repeated, and I wrote no reconnect code. With a WebSocket you must write all of that yourself.

What SSE cannot do

  • One direction only. Server to client. For client to server you use a normal fetch POST next to it. That is fine for most needs (a notification feed, a progress bar, streaming a text answer token by token).
  • Text only. No binary frames. (Base64 inflates size by about a third.)
  • The browser's EventSource cannot set custom headers, so you cannot send Authorization: Bearer .... Use a cookie, or read the stream with fetch and a stream reader and handle the format yourself (this is what many streaming-AI client libraries do).
  • Six connections per browser per host on HTTP/1.1. The MDN page says that when SSE is not used over HTTP/2, the browser limits open connections to 6 per host, across all tabs, and that browsers have marked this as "won't fix". With HTTP/2 the limit is the number of streams, which defaults to 100. I did not test this in a browser. It is a strong reason to use HTTP/2 or HTTP/3 in front of any SSE service. (An easy trap: open the app in seven tabs and the seventh never gets its updates.)

Trap: buffering. SSE only works if every write reaches the client straight away. Anything in the path that collects output before forwarding it, such as a proxy, a gzip layer, or a framework that buffers the response, makes the stream look dead. We will see this happen with nginx in a moment.

WebSockets: a real two-way channel

SSE is one-way. A WebSocket is a message channel in both directions. The client and the server can send at any time, and the messages are small frames, with no repeating HTTP headers.

It starts as HTTP

A WebSocket begins with a normal-looking HTTP GET that asks to switch protocols. I typed one by hand against a ws server on port 9100:

TEXT
$ printf 'GET /chat HTTP/1.1\r\nHost: localhost:9100\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n' | nc -w1 127.0.0.1 9100 | cat -AHTTP/1.1 101 Switching Protocols^M$Upgrade: websocket^M$Connection: Upgrade^M$Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=^M$^M$

Status 101 Switching Protocols. From now on, this TCP connection no longer speaks HTTP. It speaks WebSocket frames. The request headers:

  • Upgrade: websocket and Connection: Upgrade: "please switch".
  • Sec-WebSocket-Version: 13: the protocol version, which is the only one in use.
  • Sec-WebSocket-Key: a random value made by the client.

And the reply carries Sec-WebSocket-Accept, which is proof that the server really understands WebSocket (and is not an old HTTP server echoing headers). It is computed as: take the key, add the fixed text 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, take the SHA-1 hash, and write it in base64. I computed it myself:

TEXT
$ node -e 'console.log(require("crypto").createHash("sha1").update("dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"))'s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

It matches. This is the same example key that the MDN page uses, and the value matches what that page says too. It is not a security feature: it protects against confused servers and caches, not against attackers. And the same server, asked with a plain GET (no upgrade headers), answers:

TEXT
$ curl -si http://127.0.0.1:9100/ | head -2HTTP/1.1 426 Upgrade RequiredContent-Length: 16

The simplest working example

Server, with the ws package (npm i ws):

JavaScript
// wsecho.mjsimport { WebSocketServer } from "ws";const wss = new WebSocketServer({ port: 9100, host: "127.0.0.1" });wss.on("connection", (ws, req) => {  console.log("[server] client connected from", req.socket.remoteAddress, "url", req.url);  ws.on("message", (data) => { console.log("[server] got:", data.toString()); ws.send("echo: " + data); });  ws.on("close", (code, reason) => console.log("[server] closed", code, reason.toString()));});

Client. Node 22 has a WebSocket built in, the same API as in browsers, so no import is needed:

JavaScript
// wsclient.mjsconst ws = new WebSocket("ws://127.0.0.1:9100/chat");ws.addEventListener("open", () => { console.log("open, protocol:", JSON.stringify(ws.protocol)); ws.send("hello"); });ws.addEventListener("message", (e) => { console.log("message:", e.data); if (e.data.includes("hello")) ws.close(1000, "bye"); });ws.addEventListener("close", (e) => console.log("close", e.code, e.reason, e.wasClean));
TEXT
[server] client connected from 127.0.0.1 url /chatopen, protocol: ""[server] got: hellomessage: echo: hello[server] closed 1000 byeclose 1000 bye true

1000 is the code for a normal close. You will meet others: 1001 (going away, for example a server restart), 1006 (the connection dropped with no close frame; browsers use it to say "something broke"), 1008 (policy violation) and 1011 (server error). I saw 1006 in practice: when my nc handshake test disconnected without a close frame, the server logged closed 1006.

The frames, byte by byte

Messages travel in frames. I wrote a small raw client with Node's net module that does the handshake itself and then sends one frame by hand, so we can see the bytes:

TEXT
client sends frame bytes: 81 85 12 34 56 78 7a 51 3a 14 7dserver frame bytes:       81 0b 65 63 68 6f 3a 20 68 65 6c 6c 6fpayload text: "echo: hello"

Read the client frame:

  • 81: the first byte. The top bit 1 means "this is the final fragment of the message". The low four bits 1 mean opcode 1, a text frame. (Opcode 2 is binary, 8 is close, 9 is ping, A is pong.)
  • 85: the top bit 1 means "the payload is masked", and 5 is the payload length in bytes.
  • 12 34 56 78: the 4-byte mask key. Every frame from client to server must be masked: each payload byte is XORed with a mask byte. The mask is not encryption (the key travels with the message). It exists to stop a malicious page from crafting bytes that confuse old caching proxies. This is why even a plain ws:// message is not readable at first glance but is trivially decodable.
  • 7a 51 3a 14 7d: the masked payload. XOR each byte with the mask and you get hello.

The server's frame has 81 (final, text), 0b (length 11, no mask bit, since server frames are not masked) and then echo: hello in plain bytes. The header is 2 bytes for small messages (compare with hundreds of bytes of HTTP headers on every poll). Lengths up to 125 fit in the second byte; bigger ones use 2 or 8 more bytes. This frame description is confirmed by my run and agrees with the MDN page.

Always check the Origin

Browsers allow a page from any site to open a WebSocket to any server, and they send the cookies for that server. The same-origin policy does not stop this. If your WebSocket server trusts cookies for login, a malicious page can open a socket as the logged-in user. This is called Cross-Site WebSocket Hijacking. The defence is to check the Origin header the browser sends, during the handshake. I tested a server that accepts only one origin:

JavaScript
// origin.mjsconst ALLOWED = new Set(["https://app.example.com"]);const wss = new WebSocketServer({ port: 9800, host: "127.0.0.1",  verifyClient: ({ origin }, done) => done(ALLOWED.has(origin), 403, "Forbidden origin") });
TEXT
--- Origin: https://evil.exampleHTTP/1.1 403 Forbidden--- Origin: https://app.example.comHTTP/1.1 101 Switching Protocols

MDN says the same: browsers always send Origin, so check it, and remember that non-browser clients can fake it, so it is not a replacement for real authentication. Authenticate during the handshake, with a short-lived token (a one-time ticket obtained from your normal API, passed in the query string or as the first message), since the browser WebSocket API cannot set an Authorization header either. Do not put long-lived secrets in the URL: URLs end up in logs.

Trap: authenticating once and trusting forever. A WebSocket can stay open for hours. If the user's session is revoked or the token expires, the open socket still works unless you close it. Re-check on a timer or on events like logout, and keep access tokens short-lived.

Keeping a connection honest: heartbeats

A connection can die without anyone telling you. A phone goes into a tunnel. A laptop lid closes. A NAT device forgets the mapping (see the NAT section of the IP lesson). A cable is pulled. The other side may never see a FIN packet, so the server believes the client is still there and keeps its memory, forever. This is a half-open connection.

The WebSocket protocol has ping and pong frames for this. The ping is a tiny frame. The receiver must answer with a pong. The ws README recommends the pattern I used: send a ping on an interval, mark the client "alive" when the pong arrives, and terminate any client that did not answer by the next round.

JavaScript
// hb.mjswss.on("connection", (ws) => {  ws.isAlive = true;  ws.on("pong", () => { ws.isAlive = true; });});setInterval(() => {  for (const ws of wss.clients) {    if (!ws.isAlive) { ws.terminate(); continue; }   // no pong since last ping: kill it    ws.isAlive = false; ws.ping();  }}, 1000);                                              // 1 s here to keep the test short

To simulate a dead client I used kill -STOP on the client process. That freezes the program but not the operating system, so the kernel still keeps the TCP connection open. This is an important point: TCP alone does not tell you the program is alive. Only an answer from the application does.

TEXT
client open, pid 3812 1002 ms ping sent 1004 ms pong received 2002 ms ping sent 2003 ms pong received 3005 ms ping sent 3005 ms pong received>>> freezing client (SIGSTOP) 4006 ms ping sent 5008 ms no pong since last ping -> terminate() 5009 ms server: socket closed

The frozen client was detected after about two ping intervals, and the server cleaned up. In production, use something like 20 to 30 seconds, not 1 second. The same heartbeat does a second job: it keeps the connection from being closed by idle timeouts in middle boxes, as we will see now. Browsers answer pings automatically, but browser JavaScript cannot send a ping frame. If you need the client to detect a dead server, send an ordinary application message such as {"type":"ping"} and expect pong back.

Behind a proxy: where real-time apps break

Everything above ran direct to Node. In production you put nginx, a load balancer or a CDN in front (lesson 6). Each of those was designed for short request and response pairs, and three defaults hurt us. I set up nginx 1.24.0 in front of my SSE server and my WebSocket server and tested each.

1. Buffering swallows SSE

nginx's default config proxies with response buffering on. It collects the upstream response before sending it. For a normal page that is a good thing. For an endless stream it is a disaster.

TEXT
=== SSE via nginx default (3 s)(bytes received: 0)=== SSE via nginx proxy_buffering off (3 s)(bytes received: 245)

Three seconds of listening: zero bytes with the default, 245 bytes with buffering off. The events existed, but nginx was holding them. The fix in my config:

TEXT
location / {  proxy_pass http://127.0.0.1:9400;  proxy_http_version 1.1;          # the default for proxying is 1.0  proxy_buffering off;  proxy_set_header Connection "";  # allow keep-alive to the upstream}

The upstream can also tell nginx itself: a response header X-Accel-Buffering: no turns buffering off for that response only. (I know this header from nginx docs and did not test it here.) Other layers have similar problems: compression middleware that waits for a full chunk, and CDNs that buffer unless told not to. In Node, disable gzip on the event-stream route.

2. WebSockets need the upgrade headers passed on

Upgrade and Connection are hop-by-hop headers: a proxy removes them by default and does not forward them. So the upstream never sees a WebSocket request. I connected through nginx twice, once with a plain proxy_pass and once with the headers set:

TEXT
=== websocket via nginx :9502   (plain proxy_pass)error: Received network error or non-101 status code.=== websocket via nginx :9503   (proxy_http_version 1.1 + Upgrade + Connection headers)openmsg echo: hiclose 1005

The working config:

TEXT
location / {  proxy_pass http://127.0.0.1:9100;  proxy_http_version 1.1;  proxy_set_header Upgrade $http_upgrade;  proxy_set_header Connection "upgrade";}

(close 1005 means "closed with no status code", which is what the built-in client reports when I called close() without one.) Whenever you see a WebSocket that works on localhost and fails in production with a 200, 400 or 426 or without a clear error, suspect that a proxy or load balancer in between is not configured for the upgrade. A layer 7 balancer must support WebSockets. A layer 4 (TCP) balancer needs nothing, since it never reads HTTP.

3. Idle timeouts close quiet connections

nginx closes a proxied connection if the upstream is silent for proxy_read_timeout, which defaults to 60 seconds. Cloud load balancers have their own idle timeouts, often 60 seconds. A WebSocket where nothing is said for a minute is cut. I set the timeout to 3 seconds to make this quick to test, and compared a silent client against one that pings every second:

TEXT
open (heartbeat none)closed with code 1006 after 3014 msopen (heartbeat every 1 s)still open after 7006 ms

The silent connection died at exactly 3 seconds, with code 1006 (no close frame; the proxy just dropped it). The one with a heartbeat lived. So the rule is: send something more often than the shortest idle timeout on the whole path: WebSocket pings, or SSE comment lines (: keep-alive), every 15 to 30 seconds. And find that timeout number before you choose the interval.

WebSockets also work over HTTP/2 and HTTP/3 (there is a separate extension for it, RFC 8441 as I remember, using a CONNECT request on a stream). I did not test it. Most deployments still use a classic HTTP/1.1 upgrade for WebSockets, even if the rest of the site uses HTTP/2.

Backpressure: the slow client

A fast server and a slow client make a classic memory leak. The server calls ws.send() in a loop. If the client reads slowly, the data piles up in memory on the server. Node does not stop you. ws.send() returns right away, and the data is buffered. The property ws.bufferedAmount tells you how many bytes are queued and not yet written to the socket.

I made a client that completes the handshake but then never reads (socket.pause()), and a server that tries to send a 64 KB message every 10 ms, and drops the client when more than 1 MB is queued:

TEXT
sent 50 messages (3.1 MB), bufferedAmount=0.0 MBafter 76 messages: bufferedAmount=1.0 MB -> client too slow, dropping it

For the first ~4 MB the operating system's socket buffers absorbed the data (so bufferedAmount stayed at zero). After that, the bytes started to queue inside Node. At 1 MB queued, the server cut the client off. Without this check, one stuck mobile client costs memory growing at 6.4 MB per second. Options when a client is slow: drop the client, skip intermediate updates and send only the latest state ("conflation", good for prices and positions), or slow the producer.

What does one connection cost?

Real-time systems hold a connection for every user. How many can one Node process hold? I opened 10,000 WebSocket connections to one ws server on this machine (client and server on the same machine, all idle):

TEXT
SERVER clients=0 rss=63 MBSERVER clients=10000 rss=158 MBCLIENT 10000 open in 2302 ms

That is about 95 MB more memory for 10,000 idle connections, around 9.5 KB each, and it opened them all in about 2.3 seconds. This is a rough number. It was loopback, idle, with no TLS, and no per-user state, and the memory figure includes garbage that had not yet been collected. Real apps store user data per connection and use TLS (extra memory per connection), so plan on several times more, and measure your own. What the test shows is that memory for idle connections is not the problem. The limits you will meet first are:

  • File descriptors. Each connection is a file descriptor, and the default limit on many systems is 1024 (ulimit -n; here it was 20000, so I could run the test). Raise it for the process.
  • Work per message (fan-out to thousands of clients), and CPU for TLS.
  • Reconnect storms. If a server restarts, all 10,000 clients reconnect at once. Use exponential backoff with random jitter in the client.
  • Ports. A single client IP can use roughly 28,000 ephemeral ports to one destination, which matters for load tests and for a proxy that connects to your backend (see the ports section of the TCP lesson).

Scaling past one server

Here is the problem that surprises people. With HTTP, any server can answer any request, so you add servers and a load balancer. With WebSockets, Alice is connected to server A and Bob is connected to server B. When Alice sends a chat message, server A has no idea that Bob exists. I built exactly that: two chat servers on ports 9201 and 9202, a client "alice" and a client "carol" on server 9201, and "bob" on 9202. Each server broadcasts to its own clients:

TEXT
=== two servers, no shared bus  alice (on 9201) received: [from server 9201] hi from alice  carol (on 9201) received: [from server 9201] hi from alice

Alice and Carol got it. Bob never did. The fix is to put a shared message bus between the servers. I used Redis pub/sub. Every server subscribes to a channel. When a client sends a message, the server publishes it to Redis instead of sending to its own clients directly. Redis delivers it to every subscribed server, and each server forwards it to its local clients:

JavaScript
// chat.mjs (the Redis part)pub = createClient(); const sub = pub.duplicate();await pub.connect(); await sub.connect();await sub.subscribe("chat", (m) => local(m));       // every instance hears every message// ...ws.on("message", async (d) => {  const text = `[from server ${port}] ${d}`;  if (useRedis) await pub.publish("chat", text); else local(text);});
TEXT
=== two servers + Redis pub/sub  alice (on 9201) received: [from server 9201] hi from alice  carol (on 9201) received: [from server 9201] hi from alice  bob (on 9202) received: [from server 9201] hi from alice

Now Bob gets it too, even though he is on another server (note that the text still says it came from server 9201). The same pattern is used by libraries such as the Socket.IO Redis adapter (I know this from background, I did not test it). Other buses work too: NATS, Kafka, a database LISTEN/NOTIFY for small systems. Redis pub/sub is "fire and forget": a subscriber that is disconnected at that moment misses the message. If you need no missed messages, use a stream (Redis Streams, Kafka) and let clients ask "give me everything after id X", which is the same idea as SSE's Last-Event-ID.

Load balancer choices

  • Sticky sessions (always send the same client to the same server) are needed if your protocol does several HTTP requests that must meet the same server, as in long-polling fallbacks. For a pure WebSocket, the connection itself is already "sticky", because it is one long TCP connection. After it drops, the client can land on any server, so keep state in a shared store, not in the server's memory.
  • Least connections is usually a better balancing rule than round robin for long-lived connections, because connection counts, not request counts, are what differ between servers.
  • Deploys. Restarting a server drops all its connections. Drain gently: stop accepting new ones, send a close frame with code 1001 (going away), and let clients reconnect elsewhere with jitter. Never restart all instances at the same moment.
  • Limits. Set a maximum payload size (maxPayload in ws), a limit on connections per user, and a rate limit on messages. A WebSocket bypasses many of the per-request protections you built for your HTTP API.

Trap: state in memory. A map like usersByRoom in one Node process works in development and silently breaks the day you run two instances. Everything that must be shared (who is online, which room holds whom, pending messages) belongs in Redis, a database or the bus. Test with two instances early.

Choosing between them

QuestionAnswer points to
Updates are rare and a delay of seconds is fineShort polling (with a cache)
Only the server sends, text is enough (notifications, progress, live feed, streamed text)SSE
Both sides send often, small messages, low latency (chat, games, collaboration)WebSocket
Binary data, or custom protocol on topWebSocket
Must work through strange corporate proxies, with minimal infrastructureLong polling or SSE
You do not want to write reconnect and resume codeSSE (the browser does it)
Behind a proxy you do not controlTest first. SSE needs buffering off; WebSocket needs upgrade support

My own rule: start with SSE for one-way updates, because it is plain HTTP, works with normal auth and logging, reconnects by itself, and gets the same HTTP/2 benefits. Use a WebSocket when the client really has to talk back often. Use polling when the product does not need more. For managed services (hosted pub/sub, push notification systems), the same connection issues apply, just on someone else's servers.

Try it

You need Node 22 and npm i ws.

  1. Write a server that sends the current time to SSE clients every second. Test it with curl -N. Then add an id: field and test the Last-Event-ID header with curl. What does the server need to remember to support it?
  2. Put the file wsecho.mjs from this lesson on port 9100 and open a connection with Node's built-in WebSocket. Send three messages quickly. Do the echoes come back in order?
  3. Start nginx (or any proxy you have) in front of the SSE server without any special settings and see if you get events. Then fix it.
  4. Add the ping/pong heartbeat to your server. Open a client with ws in Node, and freeze it with kill -STOP <pid>. How long does the server take to notice?
  5. Run two chat servers with no bus and show that a message does not reach the other server. Then add Redis pub/sub (redis-server and the redis npm package).
  6. A teammate says "SSE connections stop working when I open the dashboard in a 7th tab". What do you suspect, and what is the fix?

Answers

  1. It needs to remember recent events (or be able to rebuild them from a database) with their ids, so it can replay everything after the id the client sends. If it only keeps a counter, it can say "you missed 3" but cannot say what they were.
  2. Yes. A WebSocket runs over one TCP connection, which keeps bytes in order, and the server handles messages one after another. Messages on the same connection arrive in the order sent.
  3. In my test, the default nginx config delivered zero bytes in three seconds. Fix with proxy_buffering off;, proxy_http_version 1.1; and proxy_set_header Connection "";.
  4. About two ping intervals. In my 1 second test, the freeze happened at about 3 s, and the socket was terminated at about 5.0 s.
  5. You should see that only clients of the same server get the message. With Redis publish and subscribe, every server receives it and delivers to its local clients.
  6. The browser limit of 6 connections per host on HTTP/1.1 (the limit is shared across all tabs). Serve the page and event stream over HTTP/2 (streams default to 100), or use one shared connection per browser (a SharedWorker or the BroadcastChannel API to share one stream between tabs).

Interview questions

What are the ways to get live updates from a server to a browser, and how do you choose? (Must know)

Short polling, long polling, SSE and WebSockets. Polling is simplest and fine for slow-changing data. Long polling gives faster updates on plain HTTP but wastes a request per message. SSE is a one-way server-to-client stream of text events over a single HTTP response, with automatic reconnect and resume. WebSockets are a two-way channel for frequent small messages from both sides. I start with SSE when only the server sends, and pick WebSockets when the client talks back a lot.

How does a WebSocket connection start? (Must know)

It begins as an HTTP/1.1 GET with Upgrade: websocket, Connection: Upgrade, a version of 13 and a random Sec-WebSocket-Key. The server answers 101 Switching Protocols with a Sec-WebSocket-Accept value, which is the SHA-1 of the key plus a fixed GUID, in base64. I computed it by hand and it matched. After that, the TCP connection carries WebSocket frames, not HTTP.

What is the difference between SSE and WebSockets? (Must know)

SSE is one direction, text only, and plain HTTP, so it goes through ordinary auth, logging and proxies, and the browser reconnects and resumes with Last-Event-ID for free. A WebSocket is two-way, supports binary, and has smaller frames, but I must write reconnect, resume, heartbeat and authentication myself and my proxies need upgrade support. SSE has a limit of six connections per host on HTTP/1.1, which goes away on HTTP/2.

A WebSocket works on localhost but fails in production behind nginx. What do you check? (Trap)

Whether the proxy forwards the upgrade. Upgrade and Connection are hop-by-hop headers, so nginx drops them unless I set proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". I reproduced it: without them the client got "non-101 status code". After that I check idle timeouts, because proxy_read_timeout defaults to 60 seconds and cuts quiet connections. And I check that any cloud load balancer in front supports WebSockets.

Our SSE endpoint works directly but shows nothing through the proxy. Why? (Trap)

Buffering. The proxy (or a compression layer) collects output before forwarding it, so small events never leave. In my test, nginx with default settings delivered 0 bytes in three seconds, and 245 bytes with proxy_buffering off. I turn off buffering on that route, use HTTP/1.1 to the upstream, avoid gzip for text/event-stream, and send comment heartbeats.

How do you detect a dead WebSocket client? (Must know)

TCP will not tell me promptly, since a client that vanished may never send a FIN, and even a frozen program keeps its TCP connection alive at the kernel level. I use ping and pong frames on a timer: ping each client, mark it alive on pong, and terminate any client that has not answered by the next round. I tested it with a client frozen by SIGSTOP; the server terminated it after about two ping intervals. In production I pick 20 to 30 seconds, and make it shorter than any idle timeout on the path.

How would you scale a chat server to several instances? (Senior signal)

The instances need a shared message bus, because a client on instance A cannot be reached from instance B. I tested a setup where each instance broadcast only to its own clients: the user on the second server never got the message. With Redis pub/sub, each instance publishes incoming messages and subscribes to the channel, then forwards to its local clients, and the user on the other server got it. Redis pub/sub loses messages for disconnected subscribers, so if delivery matters I would use a durable log (Redis Streams, Kafka) with ids and replay, and keep other shared state (rooms, presence) in Redis or a database, not in process memory.

Do you need sticky sessions for WebSockets? (Senior signal)

Not for the WebSocket connection itself, since it is one long TCP connection that stays on the server it was opened on. Sticky sessions matter for protocols that use several separate HTTP requests that must reach the same server, such as long-polling fallbacks. After a disconnect, a client can land on any instance, so the design must not depend on in-memory state of one server. I prefer the least-connections balancing rule for long-lived connections.

What is backpressure, and how does it show up with WebSockets? (Senior signal)

It is the receiver telling the sender to slow down. In Node, ws.send() returns immediately and queues data if the socket is slow, so a stuck client makes server memory grow. In my test, once the operating system buffers were full (about 4 MB), data started to queue in Node, and I dropped the client at 1 MB of bufferedAmount. Choices are to drop the slow client, to send only the latest state and skip old updates, or to slow the producer. I also set maxPayload and rate limits.

What security checks does a WebSocket endpoint need? (Senior signal)

Browsers do not apply the same-origin policy to WebSockets and send cookies along, so I must check the Origin header during the handshake to prevent cross-site WebSocket hijacking. In my test a bad origin got 403 and the good one got 101. Because the browser API cannot set an Authorization header, I authenticate with a short-lived ticket from my normal API, not with a long-lived secret in the URL. I use wss:// (TLS), limit message size and rate, validate every message like any other input, and close sockets when a session ends or a token expires.

What are the downsides of long polling? (Must know)

Every message costs a full HTTP request and response, with headers, and the client has a gap between getting a reply and sending the next request, in which events can be missed unless the server keeps them with ids. Each client holds an open request, so the server must handle many idle connections, and the wait must be shorter than all proxy timeouts. I measured one parked request answered right when the event occurred, against four requests and 0.4 seconds of delay for 1-second polling, so it is a clear win over short polling, but SSE and WebSockets are usually better.

How many WebSocket connections can one server hold? (Senior signal)

It depends, so I would measure. In my loopback test, one Node process held 10,000 idle connections with about 95 MB extra memory (around 9.5 KB each) and no problem. Real limits come from file descriptor limits, CPU for TLS and for fan-out, per-connection application state, and reconnect storms after a restart. I would raise ulimit -n, load-test with realistic messages, use jittered backoff on the client, and drain connections gradually during deploys.

Key takeaways

  • HTTP cannot push, so live updates use polling, long polling, SSE or WebSockets. Choose the simplest one that fits.
  • Short polling is easy but wasteful and late (four requests and 0.4 s delay in my test). Long polling answers at once but still costs a request per message.
  • SSE is one long response of text/event-stream. Events are id, event, data lines ended by a blank line. The browser reconnects and sends Last-Event-ID, so the server can resume.
  • A WebSocket starts as an HTTP GET with Upgrade: websocket and becomes a two-way frame channel after 101 Switching Protocols. Small frames have only a 2-byte header, and client frames are masked.
  • Check Origin on the handshake, authenticate with a short-lived ticket, and use wss://.
  • Use heartbeats. They detect half-open connections and stop idle timeouts from cutting quiet connections. nginx's default read timeout is 60 seconds.
  • Proxies need care: turn buffering off for SSE (0 bytes versus 245 in my test), pass the Upgrade and Connection headers for WebSockets, and find every idle timeout on the path.
  • Backpressure matters: watch bufferedAmount and drop or conflate for slow clients.
  • Connections are state. With more than one server you need a bus (Redis pub/sub, or a durable log) and no per-user state in process memory.

What's next

Next: Debugging Network Problems and Networking Interview Questions. This is the closing lesson. You will use curl, dig, ss, tcpdump and Wireshark basics on real broken setups, follow a troubleshooting checklist from DNS to TLS to HTTP, and practise interview questions that cover the whole series. Practise now: take the nginx WebSocket setup from this lesson, break it in one way at a time (remove the upgrade headers, set a short timeout, turn buffering on) and write down the exact symptom you see for each, since matching symptoms to causes is the skill lesson 8 teaches.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series