Designs by Duhart All writing

·10 min read·websockets · backend · systemdesign · realtime · softwareengineering · golang · javascript · codinginterview · distributedsystems · webdevelopment

WebSockets (WSS): 8 Concepts Every Backend Engineer Should Know

The upgrade handshake, frames, TLS, ping and pong, backpressure, scaling with pub/sub, auth and reconnects, with the bugs I found in my own chat server.

Cover slide on a dark background: 8 WEBSOCKET  CONCEPTS  EVERY BACKEND ENGINEER NEEDS. From the upgrade handshake to scaling. With the bugs I found in my own chat server. The numbered list: The upgrade; Frames; WSS and TLS; Ping and pong; Backpressure; Scaling; Auth; Reconnects. A gold band reads Save this for your next interview.

A WebSocket is the easy part

Opening a WebSocket takes one line. new WebSocket(url), an onmessage handler, done. Every tutorial stops there, and that's the problem, because almost everything that goes wrong with WebSockets in production happens after that line.

I run a real-time chat and call signaling service: Go with gorilla/websocket, Kafka between instances, Redis for presence and rate limits, Cloudflare in front. In August I wrote a 20 chapter manual for it with a register of every gap I found. Several of the bugs in that register are exactly the concepts interviewers ask about, so this post walks through eight of them, with the protocol detail first and the production lesson second.

WebSocket concepts every backend engineer needs

Cover slide on a dark background: 8 WEBSOCKET  CONCEPTS  EVERY BACKEND ENGINEER NEEDS. From the upgrade handshake to scaling. With the bugs I found in my own chat server. The numbered list: The upgrade; Frames; WSS and TLS; Ping and pong; Backpressure; Scaling; Auth; Reconnects. A gold band reads Save this for your next interview.
Eight concepts, from the upgrade to the reconnect.

The 35 second version

All eight concepts in 35 seconds, captions on.
Poster frame of the explainer video: Your websocket isn't idle. it's dead.
All eight concepts in 35 seconds, captions on. Watch the video: https://designsbyduhart.org/blog/websocket-concepts-every-backend-engineer-should-know/

1. It starts as HTTP

A WebSocket connection begins life as an ordinary HTTP/1.1 GET with two extra headers: Upgrade: websocket and Connection: Upgrade. The client also sends Sec-WebSocket-Version: 13 and a random base64 Sec-WebSocket-Key. If the server agrees, it answers 101 Switching Protocols, and from then on the same TCP connection carries WebSocket frames in both directions until somebody closes it.

The server proves it understood by returning Sec-WebSocket-Accept: the client's key concatenated with a fixed GUID from RFC 6455 (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), hashed with SHA-1 and base64 encoded. That stops a confused HTTP server or cache from accidentally "accepting" a WebSocket. It is not authentication. Anyone can compute it.

The upgrade handshake

Slide 2 of 10: IT STARTS AS HTTP. A normal GET asks to upgrade. The server answers 101 Switching Protocols and the socket stays open. A code card (HTTP, RFC 6455) shows: GET /chat HTTP/1.1 Host: api.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13  HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Interview trap: Accept is base64(SHA-1(key + a fixed GUID)). It proves the server speaks WebSocket. It proves nothing about who you are.
The exact example from RFC 6455. The Accept value is derived from the key, not a secret.

Computing Sec-WebSocket-Accept yourself. This prints the RFC 6455 example value.

python
import base64, hashlib

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
key = "dGhlIHNhbXBsZSBub25jZQ=="
accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
print(accept)  # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Two follow-ups interviewers like. First, over HTTP/2 the upgrade dance doesn't exist; RFC 8441 bootstraps WebSockets with an extended CONNECT instead, so one HTTP/2 connection can carry many sockets. Second, because the upgrade is a plain GET, everything in front of your server (load balancer, CDN, proxy) has to pass the Upgrade header through. A surprising number of "WebSockets don't work" bugs are a proxy config that strips it.

2. Messages travel as frames

After the upgrade, data moves in frames. Each frame has a FIN bit (is this the last piece?), an opcode, a payload length and, from the client, a masking key. Text frames (0x1) must be valid UTF-8; binary frames (0x2) carry anything. A large message can be split across a first frame and continuation frames (0x0). Your WebSocket library reassembles them, so your code sees whole messages, but limits on message size apply to the reassembled message, which is why my server caps inbound messages at 1 MiB.

Control frames keep the connection healthy: close (0x8), ping (0x9) and pong (0xA). They can be interleaved between the fragments of a data message, and their payload is limited to 125 bytes.

Frames and opcodes

Slide 3 of 10: MESSAGES TRAVEL AS FRAMES. Every frame has an opcode. Data frames carry your messages, control frames keep the line healthy. A table: OPCODE, FRAME, WHAT IT DOES; 0x1, Text, UTF-8 data, like JSON; 0x2, Binary, Raw bytes, like protobuf; 0x0, Continuation, Next piece of a split message; 0x8, Close, Carries a code, like 1000; 0x9 / 0xA, Ping / Pong, Heartbeat and reply. Interview trap: Every client to server frame must be masked; server frames are not. And 1006 is never sent: it means the socket died with no close frame.
Data frames carry messages, control frames keep the connection alive.

Two details that come up in interviews:

  • Masking. Every frame from client to server must be masked with a random 4 byte key, and a server must close the connection if it gets an unmasked one. It isn't for secrecy. It stops a malicious page from crafting bytes that a naive intermediary cache could mistake for an HTTP request (cache poisoning).
  • Close codes. A close frame carries a status code. 1000 is a normal close, 1001 means going away (a server shutting down, a tab closing), 1008 is a policy violation, 1011 an unexpected server error. 1006 is the one you'll see most in logs, and it's never sent on the wire: it's what your library reports when the connection died without any close frame. When you see 1006, think network, proxy timeout or crash, not application logic.

3. Always use WSS

wss:// is WebSocket over TLS, the same way HTTPS is HTTP over TLS, and it normally runs on port 443. Use it everywhere. Privacy is the smaller reason. Plain ws:// on port 80 passes through proxies and middleboxes that think they understand HTTP and mangle or buffer the upgrade. Encrypted traffic on 443 looks like any other HTTPS connection and gets through. Browsers also block a page served over HTTPS from opening a ws:// socket, as mixed content.

WSS and TLS

Slide 4 of 10: ALWAYS USE TLS. wss:// is WebSocket over TLS on port 443, just like HTTPS. Why it matters: Proxies and middleboxes break plain ws:// on port 80. Encrypted traffic on 443 gets through, and an HTTPS page is not allowed to open ws:// anyway. A code card (javascript) shows: // mixed content: blocked on an https page new WebSocket("ws://api.example.com/chat");  // what you ship new WebSocket("wss://api.example.com/chat"); In production: My chat server terminates TLS at Cloudflare. The origin listener is plaintext and only reachable through the tunnel.
Mixed content is blocked. Ship wss.

Where TLS terminates is a design decision. In my setup it ends at Cloudflare's edge; the origin listener speaks plain WebSocket and is only reachable through a Cloudflare tunnel, never from the internet. That's a common pattern, and it means the edge is now part of your WebSocket path: its idle timeouts, its header handling, its routing. The first finding in my manual was exactly that: the signaling hostname had no DNS record and the tunnel had no ingress rule for the socket port. The Go code was fine. Nobody could reach it.

4. Ping, pong, or it's dead

A TCP connection can die without either side noticing. A phone switches from wifi to cellular, a NAT forgets the mapping, a load balancer silently drops a flow that's been idle for 60 seconds. The socket on your server still looks open. It just never receives anything again. A dead connection and a quiet one look identical until you check.

So you check. The server sends a ping on a timer, the client's WebSocket implementation answers with a pong automatically, and the server sets a read deadline that every pong pushes forward. If the deadline passes, the connection is dead: close it and free its memory. My chat server pings every 30 seconds and drops any socket that hasn't been heard from in 60. The ping traffic also keeps idle connections from tripping proxy timeouts.

Heartbeats

Slide 5 of 10: PING, PONG, OR IT'S DEAD. A dead connection looks exactly like a quiet one. Heartbeats tell them apart. Why it matters: Phones switch networks, NATs and load balancers drop idle flows. Without a deadline, the server keeps a ghost connection and its memory forever. A code card (go) shows: // what my chat server does (gorilla/websocket) const pongWait = 60 * time.Second // ping every 30 s conn.SetReadDeadline(time.Now().Add(pongWait)) conn.SetPongHandler(func(string) error { return conn.SetReadDeadline( time.Now().Add(pongWait)) }) Interview trap: Browser JavaScript cannot send a ping frame. The browser answers server pings, so the client side needs an app level heartbeat.
A read deadline that every pong pushes forward. Miss it and the connection is closed.

The heartbeat in a gorilla/websocket server: a writer goroutine pings on a ticker, and the reader keeps extending its deadline whenever a pong arrives.

go
const (
    pongWait   = 60 * time.Second
    pingPeriod = 30 * time.Second
)

func readPump(conn *websocket.Conn) {
    conn.SetReadLimit(1 << 20) // 1 MiB
    conn.SetReadDeadline(time.Now().Add(pongWait))
    conn.SetPongHandler(func(string) error {
        return conn.SetReadDeadline(time.Now().Add(pongWait))
    })
    for {
        if _, msg, err := conn.ReadMessage(); err != nil {
            return // deadline passed or closed: clean up
        } else {
            handle(msg)
        }
    }
}

func pinger(conn *websocket.Conn, done <-chan struct{}) {
    t := time.NewTicker(pingPeriod)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            deadline := time.Now().Add(10 * time.Second)
            if err := conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
                return
            }
        case <-done:
            return
        }
    }
}

The trap: browser JavaScript has no API to send a ping frame. Browsers answer pings from the server, but if the client also needs to notice a dead connection (to show "reconnecting" instead of a frozen chat), it has to send an application level heartbeat, a small JSON message on a timer, and treat a missing reply as a dead socket. My clients send a presence ping every 30 seconds, which doubles as that heartbeat.

5. Backpressure: slow clients eat memory

A server can produce messages much faster than a phone on bad wifi can read them. If each connection has an unbounded outgoing queue, one slow client grows that queue until the process runs out of memory. Backpressure is the set of decisions you make instead: bound the queue, and decide what happens when it's full. You can drop messages that a newer one makes obsolete (cursor positions, typing indicators), merge updates, slow the producer, or disconnect the client and let it reconnect and resync.

My server uses a bounded channel per connection with a single writer goroutine, and a full channel disconnects the client. That's a good policy for a chat server: memory stays flat, and a reconnecting client fetches what it missed. But the bound matters. It started at 64 messages, and my audit found that a burst of call setup signals (ICE candidates arrive in bursts) on a congested link could fill it and drop the call in the middle of connecting. It's 512 now, and the "send buffer full" log line has an alert on it.

Backpressure

Slide 6 of 10: SLOW CLIENTS EAT MEMORY. Backpressure: what you do when you produce faster than a client can read. Why it matters: An unbounded queue per socket turns one phone on bad wifi into a memory leak. Bound it, then decide: drop, merge, or disconnect. A code card (javascript) shows: // browser: bytes queued but not yet sent if (ws.bufferedAmount > 1_000_000) { skipFrame(); // drop or coalesce } else { ws.send(JSON.stringify(update)); } In production: My server disconnects a client when its send buffer fills. At 64 messages, a burst of call setup signals on a slow link could drop the call. Now 512, with an alert.
Bound the queue, then choose: drop, merge or disconnect.

In the browser, the same signal is WebSocket.bufferedAmount: the number of bytes you've passed to send() that haven't gone out on the network yet. If it keeps growing, you're producing faster than the connection can carry, and you should skip or coalesce updates. Chromium also has a newer WebSocketStream API with stream based backpressure built in; check support before you depend on it.

6. Scaling: the user is on another server

A WebSocket is stateful. Once it's open, it lives on one process on one machine. With one server, sending a message to a user is a map lookup. With ten, the user you want to reach is probably connected to a different instance than the one handling the request.

Two ideas get mixed up here. Sticky sessions make a load balancer send a client back to the same instance, which matters when that instance holds state for it (and for transports like long polling that make many requests). They do nothing for delivering a message to someone connected elsewhere. That needs fan-out: every instance subscribes to a shared bus (Redis pub/sub, NATS, Kafka), a message for a user is published once, and whichever instance holds that user's socket delivers it.

Scaling with pub/sub

Slide 7 of 10: THE USER IS ON ANOTHER SERVER. Each socket lives on one instance. To reach a user, publish to a pub/sub bus every instance hears. Why it matters: Sticky sessions keep a client on the server that holds its state. They do not deliver a message to someone connected somewhere else. Fan-out does. A code card (python) shows: # every instance runs this (Redis pub/sub) async for msg in pubsub.listen(): for ws in local_sockets(msg["to"]): await ws.send(msg["data"]) From my audit: I found every instance of my chat service in one Kafka consumer group. That load balances instead of broadcasting. Fix: one group per instance.
Publish once, and the instance holding the socket delivers it.

Fan-out with Redis pub/sub (redis-py asyncio). Every instance runs the listener; only the one holding the socket sends anything.

python
import json
import redis.asyncio as redis

r = redis.Redis()
sockets: dict[str, set] = {}          # user id -> this instance's sockets

async def send_to_user(user_id: str, data: str):
    await r.publish("chat", json.dumps({"to": user_id, "data": data}))

async def listen():
    pubsub = r.pubsub()
    await pubsub.subscribe("chat")
    async for msg in pubsub.listen():
        if msg["type"] != "message":
            continue
        m = json.loads(msg["data"])
        for ws in sockets.get(m["to"], ()):
            await ws.send(m["data"])

This is where my favourite bug from the audit lives. My service fans out call signals across instances through Kafka. Every instance was configured with the same consumer group id. In Kafka, consumers in one group split the partitions between them: that's load balancing, the opposite of broadcast. With one instance it's invisible. With two, each signal reaches only one of them, so roughly half the time it lands on the instance that doesn't hold the callee's socket and is silently discarded. With N instances, (N-1)/N of them vanish. The fix is a unique group per instance, so every instance sees every message. If you use Kafka for fan-out, check this first.

Redis pub/sub has the opposite tradeoff: every subscriber gets every message, but delivery is fire and forget. A subscriber that's disconnected for a moment misses messages, so clients need a way to catch up (a sequence number and a "give me everything after N" call) when they reconnect.

7. Auth without headers

The browser WebSocket constructor takes a URL and an optional list of subprotocols. It can't set an Authorization header. So you choose a tradeoff:

  • Cookies. The browser sends them on the upgrade automatically. That convenience is also the danger: a malicious page can open a socket to your server and the browser will attach the user's cookie. This is cross-site WebSocket hijacking, and the defence is to check the Origin header on the upgrade against an allow list.
  • A token in the query string. Simple, and what my chat service does today (a JWT with a one hour lifetime). The downside is that URLs end up in access logs, proxies and monitoring, so keep the token short lived.
  • A one-time ticket. The client makes an authenticated HTTP call, gets a random ticket valid for a few seconds and a single use, and puts that in the URL. The log leak no longer matters. This is the one I'd pick for a new system.
  • Auth as the first message. Open the socket, require an auth message within a second or two, close it if it doesn't come. Works, but the server holds unauthenticated sockets for a moment.

Authentication options

Slide 8 of 10: AUTH WITHOUT HEADERS. The browser WebSocket API cannot set an Authorization header. You pick a tradeoff. Cookie: automatic, but check Origin or you invite cross-site WebSocket hijacking. Token in the URL: easy, but URLs land in logs. Keep it short lived. One-time ticket from an authed HTTP call: my favourite. First message auth: close the socket if it does not arrive fast. Interview trap: Revoking a token does not close a socket that is already open. Mine stayed live until it dropped. Re-check, or push a close.
Each option is a tradeoff. Check Origin whatever you pick.

Two lessons from my own service. The origin check needs to handle native clients: my server required an Origin header that matched the allow list, but the iOS client's WebSocket sends no Origin at all, so the check would have rejected every iPhone. Allow an absent Origin for native clients and constrain the browser origins. And authentication happens once, at the upgrade. Revoking a token later does nothing to a socket that's already open; mine stayed live until it dropped on its own. If revocation matters, re-check periodically or push a close to the sockets of a revoked session.

8. Reconnect with jitter

Connections drop: deploys, network changes, a laptop lid. Clients have to reconnect, and the naive version (retry immediately, forever) turns a blip into an outage. Exponential backoff spaces the attempts out: 500 ms, 1 s, 2 s, 4 s, capped at something like 30 s.

Backoff alone isn't enough. When you deploy, every client disconnects in the same second, and with identical backoff schedules they all retry in the same second too, a thundering herd that can knock the fresh instances over. Jitter fixes it by randomising each delay. My web client backs off from 500 ms to 30 s with jitter. My audit found the iOS client had the same backoff with no jitter, which means a server restart brings every phone back at the same instant.

Backoff with jitter

Slide 9 of 10: RECONNECT WITH JITTER. Connections will drop. Back off exponentially and add randomness. Why it matters: After a deploy every client disconnects in the same second. Without jitter they all come back in the same second too, and knock the server over again. A code card (javascript) shows: function reconnectDelay(attempt) { const cap = 30_000, base = 500; const exp = Math.min(cap, base * 2 ** attempt); return Math.random() * exp; // full jitter } From my audit: My web client backs off from 500 ms to 30 s with jitter. The iOS client had no jitter. A server restart meant every phone reconnecting at once.
Exponential backoff, capped, with randomness so clients spread out.

A small reconnecting client: full jitter backoff, reset after a healthy connection, and a resume cursor so the server can send what was missed.

javascript
let attempt = 0;
let lastSeq = 0;

function connect() {
  const ws = new WebSocket(`wss://api.example.com/chat?after=${lastSeq}`);
  ws.onopen = () => { attempt = 0; };
  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    lastSeq = msg.seq;              // remember where we are
    render(msg);
  };
  ws.onclose = (e) => {
    if (e.code === 1008) return;    // policy violation: do not hammer
    const cap = 30_000, base = 500;
    const delay = Math.random() * Math.min(cap, base * 2 ** attempt++);
    setTimeout(connect, delay);
  };
}
connect();

Reconnecting the socket is half the job. The other half is the state: the client should tell the server where it left off (a sequence number or last event id) so the server can replay what was missed, and messages the client re-sends should carry an id so the server can drop duplicates.

How I'd answer in an interview

If someone asks you to design a chat system, the socket is one box on the whiteboard. The questions that show experience are the ones in this post: how do you know a connection is dead, what happens when a client is slow, how does a message reach a user on another server, how do you authenticate without headers, and what happens when every client reconnects at once. Have an answer for each, and a story if you've got one. I clearly have a few.

Save it for your next system design interview

Closing slide 10 of 10: Save this. Recap: It starts as an HTTP upgrade (101); Messages travel as frames with opcodes; Always wss, TLS on 443; Ping and pong, or it is a ghost; Bound every send buffer; Fan out with pub/sub across instances; Auth without headers: pick a tradeoff; Reconnect with backoff and jitter. Your turn: What broke first when your WebSockets hit production? Full write-up with code at designsbyduhart.org.
The recap card.

Next in the interview series: CI/CD pipelines, the stages and the GitLab CI file I would show in an interview.

More: LinkedIn · Instagram. Portfolio and case studies: designsbyduhart.org.

If any of this saved you an afternoon, Buy me a coffee.

WebSockets (WSS): 8 Concepts Every Backend Engineer Should Know, Designs by Duhart