WebSockets
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: A persistent, two-way connection between browser and server — once open, either side can send messages instantly without a new HTTP request. The protocol behind chat apps, live dashboards, multiplayer games, and Supabase Realtime.
In plain English
Regular HTTP is request/response. The browser asks; the server answers; the connection ends (or is reused for the NEXT request the browser makes). The browser does the asking; the server is silent until asked.
This breaks for realtime scenarios:
- A chat app — when someone else sends a message, the server needs to tell YOU, but you didn’t ask
- A live dashboard — stock prices, sports scores updating every second
- A collaborative editor — when a teammate types, your screen needs to update
- A multiplayer game — every player’s position changes 60 times a second
The old workarounds were UGLY:
- Long polling — the browser asks “any new messages?” every second. Wasteful, slow.
- HTTP streaming — keep the response stream open, server writes new data. One-way only.
- Comet, BOSH, JSONP polling — historical hacks; mostly forgotten.
WebSockets (RFC 6455, 2011) replace all of them. The browser opens ONE persistent connection to the server. After a brief handshake, EITHER side can send messages to the other AT ANY TIME, with very low overhead.
The protocol:
- Starts as an HTTP request with an
Upgrade: websocketheader - Server responds
101 Switching Protocols - Connection switches from HTTP to the WebSocket protocol (still over the same TCP/TLS connection)
- Both sides send “frames” — small messages with minimal overhead
- Connection stays open until either side closes it
Every modern browser supports WebSockets natively. Server frameworks (Node, Python, Go, etc.) all have libraries. Most realtime services (Supabase Realtime, Pusher, Ably, Pubnub) use WebSockets under the hood — sometimes with abstractions on top (presence, pub/sub channels) that handle reconnection and scaling.
This entry covers the protocol layer: how WebSockets work on the wire. For application-level patterns (chat UI, Supabase Realtime subscriptions), check the relevant project entries.
Why it matters
Three concrete reasons WebSocket knowledge pays off:
-
Realtime features need them. Chat, live updates, collaboration, multiplayer — all WebSockets or things built on WebSockets.
-
Debugging realtime is harder than debugging HTTP. A failed WebSocket may have multiple causes: handshake rejected, connection dropped, frame too large, browser closed silently. Knowing the protocol layers lets you trace where it broke.
-
WebSocket hosting has gotchas. Serverless functions (Vercel, Lambda) don’t natively support WebSockets — they’re stateless and time-bounded. Cloudflare Durable Objects, Supabase Realtime, dedicated services exist BECAUSE WebSockets need long-lived state.
The trade-off: most webapps DON’T need WebSockets. HTTP polling, Server-Sent Events, or push notifications cover many “realtime-ish” needs more simply. Reach for WebSockets when you need TRUE bidirectional, low-latency communication.
The handshake — how it starts
A WebSocket connection begins as an HTTP request:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
The client says: “I want to upgrade this connection to WebSocket.”
Server responds:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101 Switching Protocols is the magic. From here on, the connection is no longer HTTP — it’s WebSocket frames in both directions.
The Sec-WebSocket-Accept value is derived from the client’s Sec-WebSocket-Key plus a magic constant, SHA-1’d, base64’d. It proves the server understood the upgrade request and isn’t a cached HTTP proxy that doesn’t know about WebSockets.
For HTTPS sites, this whole exchange happens INSIDE a TLS-encrypted connection — wss:// instead of ws://. In 2026, wss:// is the only reasonable choice; plain ws:// is rare and discouraged.
The frame format — what’s on the wire
After the handshake, both sides send frames. Each frame has a tiny header (2-14 bytes) plus a payload. The header encodes:
- FIN bit — is this the last frame of a message? (Big messages can be split.)
- Opcode — 4 bits: text (
0x1), binary (0x2), close (0x8), ping (0x9), pong (0xA) - Masked — client-to-server frames are MASKED (XOR’d with a random key) for legacy proxy safety
- Payload length — variable encoding: 7 bits if small, 16 bits if medium, 64 bits if big
- Masking key — 4 bytes, only present if masked
- Payload — the actual message bytes
For a TEXT frame containing “Hello”:
0x81 0x85 [4 bytes mask key] [5 bytes masked "Hello"]
â”” FIN=1, opcode=text
â”” MASK=1, length=5
For most application code, you never touch frame bytes — the WebSocket library handles it. You just see “message arrived” or “message sent.”
Opcodes — the message types
The opcode in each frame tells what kind of frame it is:
| Opcode | Name | Meaning |
|---|---|---|
0x0 | Continuation | ”Part of a previous message (which had FIN=0)“ |
0x1 | Text | UTF-8 text payload |
0x2 | Binary | Arbitrary bytes |
0x8 | Close | ”I’m closing the connection; here’s the reason” |
0x9 | Ping | ”Are you alive?” |
0xA | Pong | ”Yes, I’m alive” |
| 0x3-0x7, 0xB-0xF | Reserved | Unused |
The application-visible message types are TEXT and BINARY. Browsers’ JavaScript receives them as string (text) or ArrayBuffer/Blob (binary).
CONTROL frames (close, ping, pong) are usually invisible to application code — handled by the library.
A concrete example: a chat in the browser
// Client-side
const ws = new WebSocket("wss://example.com/chat");
ws.onopen = () => {
console.log("Connected");
ws.send(JSON.stringify({ type: "join", room: "general" }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log("Received:", msg);
// Update UI with the new message
};
ws.onerror = (event) => {
console.error("Error:", event);
};
ws.onclose = (event) => {
console.log("Closed:", event.code, event.reason);
// Maybe reconnect with exponential backoff
};
// Send a message
ws.send(JSON.stringify({ type: "chat", text: "Hello!" }));Server-side (Node.js with ws library):
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
console.log("Client connected");
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "chat") {
// Broadcast to everyone
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(JSON.stringify(msg));
}
});
}
});
ws.on("close", () => {
console.log("Client disconnected");
});
ws.on("ping", () => ws.pong());
});That’s a functioning chat. Real chat apps add persistence, authentication, room management, presence, but the protocol mechanics are this simple.
Ping / pong — keeping connections alive
WebSocket connections can sit idle for minutes or hours. Intermediate NAT routers and firewalls often time out idle TCP connections, silently dropping them — the application sees the connection as still open but messages stop arriving.
The fix: ping/pong frames. The server (or client) sends a PING frame every 30-60 seconds; the other side replies with PONG. The traffic keeps NATs and firewalls happy.
Browser JavaScript can’t send pings directly (the API doesn’t expose it). The browser handles ping/pong automatically. The SERVER usually drives the heartbeat.
Application-level heartbeats are also common — sending a small “ping” message via ws.send("ping") at regular intervals. Easier to debug; same effect.
Close codes — why the connection ended
When either side closes, they can include a CODE and a REASON:
| Code | Name | When |
|---|---|---|
| 1000 | Normal Closure | ”We’re done; everything fine.” |
| 1001 | Going Away | Browser tab closed; server shutting down |
| 1002 | Protocol Error | Malformed frame |
| 1003 | Unsupported Data | Got binary when text expected (or vice versa) |
| 1005 | No Status Received | The peer didn’t include a status |
| 1006 | Abnormal Closure | TCP connection died unexpectedly (RST, network gone) |
| 1007 | Invalid Payload | Bad UTF-8 in a text frame |
| 1008 | Policy Violation | Server-defined; “you’re not allowed” |
| 1009 | Message Too Big | Payload exceeded the server’s limit |
| 1011 | Internal Error | Server bug |
| 1012 | Service Restart | Server is restarting |
| 1013 | Try Again Later | Backpressure; retry shortly |
| 3000-3999 | App-specific | Reserved for library / framework use |
| 4000-4999 | User-defined | Yours to use |
The browser’s onclose event gives you event.code and event.reason. 1006 is the one to recognize — “the connection died without ceremony” usually means a network problem.
WebSockets vs alternatives
When NOT to use WebSockets:
- Server-Sent Events (SSE) — for one-way (server → client) updates. Simpler, runs over plain HTTP, auto-reconnects. Use when the client doesn’t need to push.
- Long polling — for low-traffic or environments where WebSockets are blocked. Less efficient but works everywhere.
- WebTransport (over HTTP/3) — newer; bidirectional like WebSockets but built on QUIC. Limited browser support (Chrome+, partial Safari).
- Webhooks — for server-to-server async (not realtime UI).
| Scenario | Right tool |
|---|---|
| Live chat | WebSockets |
| Stock ticker feed (server pushes to client) | SSE |
| Collaborative editor (both push) | WebSockets |
| Server triggering a UI update once a minute | SSE or polling |
| Multiplayer game positions | WebSockets (or WebTransport) |
| Notification you’re not online for | Web Push (notification, not realtime) |
For Bible Quest-style projects, Supabase Realtime (built on WebSockets) handles most realtime needs without writing WebSocket code directly.
Hosting WebSockets — where things get tricky
A WebSocket connection is LONG-LIVED. It’s stateful from the server’s perspective: you need to track WHICH client is connected, route messages between them, manage state.
This is INCOMPATIBLE with the dominant 2026 hosting model (serverless functions):
- Vercel Functions — stateless; max ~5 minutes per request; no persistence between calls. Can’t host WebSockets.
- AWS Lambda — same.
- Netlify Functions — same.
For WebSockets, you need one of:
| Option | Notes |
|---|---|
| Dedicated server (VPS, Fly Machines, Railway, Render) | A traditional Node/Python/Go process that listens for WebSocket connections forever |
| Managed realtime service (Supabase Realtime, Pusher, Ably, Pubnub) | They run the WebSocket layer; you call their API |
| Cloudflare Durable Objects | Stateful “objects” with global addressing; can hold WebSocket connections |
| AWS API Gateway WebSocket APIs | Splits the connection management from Lambda execution; awkward but functional |
For Bible Quest: Supabase Realtime is the right answer. You subscribe to database changes in client code; Supabase handles the WebSocket plumbing on their side.
Scaling WebSockets
A single server can handle ~10,000-100,000 concurrent WebSocket connections (Node, depending on memory). Beyond that, you need to scale horizontally — multiple servers, each holding a subset of connections.
The challenge: a message published by user A on server 1 needs to reach user B connected to server 2. Solutions:
- Pub/Sub layer — Redis, NATS, Kafka. Servers publish events; subscribers receive.
- Sticky sessions + intelligent routing — load balancers route each user to a consistent server; messages route between servers as needed.
- Sharded by room/channel — all users in “room 47” go to the same server.
Managed services hide this complexity. Building it yourself is a real engineering project.
Browser WebSocket API
The WebSocket global is in every modern browser:
const ws = new WebSocket(url, protocols?);
// url: "wss://..." or "ws://..."
// protocols: optional string or array of sub-protocols
// Events
ws.onopen = (event) => {};
ws.onmessage = (event) => { /* event.data is string or Blob/ArrayBuffer */ };
ws.onerror = (event) => {};
ws.onclose = (event) => { /* event.code, event.reason */ };
// Methods
ws.send(data); // string or Blob or ArrayBuffer
ws.close(code?, reason?);
// State
ws.readyState; // 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
ws.bufferedAmount; // bytes queued, not yet sent
ws.binaryType; // "blob" (default) or "arraybuffer"For Node.js, the ws library is the standard. For Python, websockets. For Go, gorilla/websocket.
Authentication — the awkward part
WebSocket connections start as an HTTP upgrade request. The HTTP request CAN carry auth (cookie, Authorization header). After the handshake, there’s no more HTTP — auth tokens can’t be added mid-connection without your own protocol on top.
Common patterns:
-
Cookie-based — the browser sends session cookies on the upgrade request. Server reads it; if invalid, rejects the upgrade. Simplest; works for first-party WebSockets.
-
Token in URL —
wss://example.com/chat?token=abc123. Easy but leaks tokens to logs. -
Token in subprotocol —
new WebSocket(url, ["bearer", "token-abc123"]). Send the token as a Sec-WebSocket-Protocol header (clean but obscure). -
First-message auth — accept the connection, require the client’s first message to be an auth token, disconnect if not provided in N seconds.
-
Short-lived JWT — server signs a token (valid 60s); client uses it to connect; server verifies. New connections need new tokens.
For Supabase Realtime: you authenticate via JWT in the connection URL — the standard managed-service pattern.
Common gotchas
-
ws://is plaintext; usewss://. Same security concern ashttp://vshttps://. Modern browsers don’t allowws://from anhttps://page. -
WebSocket connections die silently. A
1006close code means “TCP died.” Network changes (WiFi to cellular, sleep/wake) cause this. Always implement reconnection logic with exponential backoff. -
Reconnection logic is essential. No browser auto-reconnects a WebSocket. The app must detect close, wait (with backoff), and create a new connection.
-
bufferedAmountmatters. If you send messages faster than the network can transmit, they queue in memory. Monitor and slow down sends if it grows large. -
Browsers limit concurrent connections per origin. Typically 50+ WebSockets per origin per browser. Rarely a constraint, but possible with apps that open many connections.
-
WebSocket connections don’t follow redirects. A 301/302 during the upgrade fails. Connect to the final URL.
-
Some corporate firewalls block WebSocket upgrades. They allow port 443 (HTTPS) but inspect for HTTP-only traffic and drop upgrades. Affected users get connection failures with no fallback unless your app implements one (long-polling fallback like Socket.IO).
-
Mobile networks can be hostile. Aggressive NAT timeouts (sometimes 30 seconds). Heartbeats every 25-30 seconds keep connections alive.
-
Messages are NOT guaranteed to arrive. If the connection dies, in-flight messages are lost. Apps that need delivery guarantees implement acks / retransmit at the application layer.
-
Order WITHIN one connection IS guaranteed. TCP underneath. But if you reconnect, the new connection’s messages aren’t ordered with the old.
-
Frames have a maximum size. Many servers limit individual frames to 1MB or less. For larger data, split or use binary file upload separately.
-
Don’t send huge JSON over WebSockets. A 5MB JSON message is slow to send, parse, and acks. Stream / paginate.
-
Browser tab close fires
1001 Going Away. Tab refresh fires close too. Reconnection is required after refresh. -
Server-side, each connection holds memory. A thousand idle connections is ~10-30MB. 100k connections requires careful resource management.
-
WebSockets break naive load balancers. A round-robin LB might send different requests for the same user to different servers. Use sticky sessions OR a pub/sub backplane.
-
onerrorfires beforeonclosebut doesn’t give detail. The Error event in browsers is intentionally vague (security). For detailed errors, look at the close code or check server logs. -
Browser DevTools shows WebSocket messages. Network tab → WS filter → click connection → Messages tab. Shows both directions, raw payload, timing.
-
CORS doesn’t apply to WebSockets. Browser doesn’t enforce same-origin for WS. Server must validate the
Originheader itself (or use authentication). -
Token-in-URL leaks via logs. Server access logs, browser history, intermediate proxies may capture the URL. Prefer first-message auth or subprotocol token.
-
Sub-protocols are negotiable.
new WebSocket(url, ["graphql-ws", "json"])says “I support these; pick one.” Server responds with its choice. Used by GraphQL subscriptions, etc. -
Sec-WebSocket-Protocolis for sub-protocols, not auth. Don’t confuse the spec’s intended use with auth-via-protocol hacks (which do work but feel wrong). -
HTTP/2 doesn’t natively support WebSockets. RFC 8441 adds it (“Bootstrapping WebSockets with HTTP/2”), but support is partial. Most WebSockets in 2026 still run over HTTP/1.1.
-
WebSocket frames are masked client-to-server but NOT server-to-client. Defense against certain proxy attacks; specified in RFC 6455. Libraries handle this transparently.
-
onmessageruns on the main thread. Heavy processing in onmessage blocks UI. Offload to Web Workers if needed. -
Don’t blast messages with no backpressure. Server may drop them, network may queue, client may run out of memory. Throttle on the server side.
-
WebSocket secure cookies have the same rules as HTTP.
Securecookies only onwss://.HttpOnlyworks.SameSite=Nonerequires Secure. -
Vercel Functions can ACCEPT WebSocket upgrades only via Edge Functions with limitations. Most teams use Supabase Realtime or a separate service for WebSockets in a Vercel-based stack.
-
WebSocket connections count against Cloudflare Workers’ duration limits. Use Durable Objects for long-lived connections.
-
WebTransport (HTTP/3-based) is the next-gen alternative. Not yet widely supported but watch over the next 2-3 years.
-
Socket.IO is NOT WebSockets. It’s a layer ON TOP of WebSockets (and falls back to long-polling) with extra features (rooms, broadcasting, acks). Use plain WS for portable clients; Socket.IO when its sugar is worth the JS dependency.
-
Streaming JSON over WebSockets is awkward. Each message is its own JSON object. For partial / streaming responses, use NDJSON (newline-delimited JSON) — one JSON object per message.
-
AI tools sometimes confuse WebSockets and SSE. When prompted “add realtime updates,” check what’s generated. SSE is often simpler for one-way; AI may default to WebSockets unnecessarily.
-
Don’t roll your own WebSocket library. Use
ws(Node),websockets(Python), or a managed service. The protocol’s edge cases (masking, fragmentation, close-code handshakes) are well-handled by mature libraries.
When to reach for WebSockets
Use WebSockets when:
- True bidirectional realtime is needed (chat, multiplayer)
- Sub-second update latency matters
- Many small messages per second
- The cost of long-poll polling is too high
Don’t use WebSockets when:
- One-way updates suffice (use SSE)
- Updates happen infrequently (use HTTP polling)
- The hosting environment doesn’t support them well (use a managed service instead)
- You can use a higher-level abstraction (Supabase Realtime, Pusher) that handles the plumbing
For Bible Quest, the answer is “use Supabase Realtime” — which internally uses WebSockets — rather than rolling your own.
See also
- HTTP & HTTPS 🟩 — WebSocket starts as HTTP
- TCP vs UDP 🟩 — WebSocket runs on TCP
- Ports 🟩 — 80/443 typically; non-standard for some setups
- IP addresses đźź©
- Webhooks 🟩 — the inverse direction; different shape
- Webhooks deep dive 🟩 — protocol-level webhook view
- APIs — the big picture 🟩 — WebSockets are one API family
- Server actions (Next.js) 🟩 🟦 — sometimes overlap with realtime concerns
- Supabase 🟩 🟦 — Realtime is built on WebSockets
- Cloudflare 🟩 🟦 — Durable Objects for WebSocket hosting
- Edge functions 🟩 — limited WebSocket support
- Serverless functions 🟩 — can’t host WebSockets directly
- Glossary: WebSocket, Upgrade, Realtime, Sub
Sources
- RFC 6455 — The WebSocket Protocol
- MDN — Writing WebSocket client applications
- MDN — Writing WebSocket servers — protocol-level for those rolling their own
wslibrary (Node) — the de facto Node implementation- Supabase Realtime — managed WebSocket layer
- Cloudflare Durable Objects + WebSockets
- RFC 8441 — WebSockets over HTTP/2
- Socket.IO — popular WS abstraction with fallbacks