How WebSockets actually work, and what bites you
June 9, 2026•7 min read
How WebSockets work: the handshake, the frames, and the gotchas, learned from running real systems over them.

I have run some unusual things over WebSockets. A whole UI framework where the server owns
the DOM and the browser just renders patches. A browser terminal piping raw shell I/O. A 3D
engine that recomputes the scene in Go every frame and streams draw commands to a <canvas>.
So this is not the textbook definition of a WebSocket. It is how they actually work, and the handful of things that will bite you once you push past “hello world.”
What a WebSocket actually is
A normal web request is a transaction: the browser asks, the server answers, the connection closes. Great for fetching a page. Useless when the server needs to tell the browser something without being asked.
A WebSocket is a single, long-lived, two-way connection between browser and server. Once it is open, either side can send a message at any time, with no new request, no polling, no waiting your turn. It is full-duplex (both directions at once) over one TCP connection.
That property is the whole point. When I built godom, where the Go server owns the DOM, the entire model depends on the server being able to push a UI patch the instant state changes. You cannot do that cleanly over request/response. You can fake it with polling, but polling is just asking “anything new?” over and over, wasting requests and adding latency. A WebSocket replaces all of that with one open pipe.
The handshake: it starts as HTTP
Here is the part people miss: a WebSocket connection begins as an ordinary HTTP request.
The browser sends a normal GET with an Upgrade: websocket header. If the server agrees, it
replies with status 101 Switching Protocols, and from that moment the same TCP connection
stops speaking HTTP and starts speaking the WebSocket protocol. No second connection, no new
port. The HTTP request was just the negotiation.
This is why WebSocket URLs use their own scheme, ws:// or (over TLS) wss://, but still run
on the same ports as HTTP and HTTPS. It is also why a WebSocket server can sit behind the same
host and auth as your web app, which matters more than it sounds. More on that below.
Frames: text and binary
After the handshake, data moves in frames. A frame can be text (UTF-8, usually JSON) or binary (raw bytes). This choice is not cosmetic.
In godom, the server diffs the UI tree and sends a binary patch over the socket: a compact list of operations the browser applies to the DOM. Text/JSON would have been simpler to debug but heavier on the wire, and for something pushing patches on every state change, the wire cost adds up. I wrote up that wire protocol separately if you want the gory details.
The browser terminal goes the other way and pipes essentially raw bytes: the shell produces output, the bytes go straight over a WebSocket to xterm.js, which renders them. There is no “message format” to speak of, because a PTY does not have one.
The lesson: pick text when you want to read your own traffic in DevTools, pick binary when volume matters. Both are first-class.
A minimal example
The client side is genuinely this small:
const ws = new WebSocket("wss://example.com/socket");
ws.onopen = () => ws.send("hello");
ws.onmessage = (event) => console.log("server said:", event.data);
ws.onclose = () => console.log("connection closed");
Four lines and you have a live, two-way channel. The server side (here in Go, using the standard upgrade pattern) is not much bigger:
func handler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) // HTTP -> WebSocket
if err != nil {
return
}
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
break // client went away
}
conn.WriteMessage(mt, msg) // echo it back
}
}
That is a working echo server. Everything past this point is not protocol complexity. It is the operational reality of keeping a long-lived connection healthy.
What actually bites you
The handshake and the API are easy. These are the things that cost me real debugging time.
You can drown the socket
A WebSocket will happily accept more than the other side can process. When I streamed a 3D engine's draw commands from Go to the browser, mouse-move events were the problem: the browser can fire them faster than a frame, and naively forwarding every one floods the connection and the server behind it. The fix was to throttle to one event per animation frame. The socket is a pipe, not a magic buffer; if you pour in faster than the far side drains, you get lag and memory pressure. Throttle or batch on the hot paths.
Broadcasting is your job, and it is powerful
A WebSocket connects two endpoints. If you want ten browser tabs to see the same thing, the server has to keep the list of connected clients and send to each. That sounds like a chore until you realize it gives you live sync for free. The browser terminal gets multi-tab sync purely because the Go server broadcasts PTY output to every connected socket: type in one tab, it appears in the others. I did not write “multi-tab” code; it falls out of keeping the client list.
Connections drop, so plan to reconnect
Long-lived means long enough to break. Wi-Fi blips, laptops sleep, proxies time out idle connections. A WebSocket that was working five minutes ago can be silently dead. Production clients need a reconnect strategy (with backoff, not a tight retry loop) and often a heartbeat ping to detect a connection that is open in name only. “It worked on my machine” is doing a lot of hiding here.
Auth happens at connect time
Because the connection opens once and stays open, you authenticate at the upgrade, not per message. In the terminal I pass a token on the connect URL, and the server validates it before upgrading. Treat the open as the security boundary, and remember that a long-lived connection means a long-lived grant; if the token should expire, you have to handle that on a live socket, not just at the door.
When not to use a WebSocket
WebSockets are not a default. They are for genuinely bidirectional or server-pushed data. If your interaction is really request/response (load this, save that), plain HTTP is simpler, cacheable, and easier to scale. If you only need the server to push one way (notifications, a live feed), Server-Sent Events are lighter and reconnect for you. Reach for a WebSocket when both sides need to talk freely, which is exactly the case for server-driven UI, terminals, collaborative editing, and live games.
FAQ
What is a WebSocket?
A persistent, two-way (full-duplex) connection between a browser and a server over a single TCP connection. Once open, either side can send messages at any time without a new request, which makes it suited to real-time, server-pushed data.
How do WebSockets work?
The browser makes an HTTP request with an Upgrade: websocket header; if the server agrees it
responds with 101 Switching Protocols, and the same connection switches from HTTP to the
WebSocket protocol. After that, both sides exchange text or binary frames freely until either
closes the connection.
What is the difference between WebSocket and HTTP?
HTTP is request/response: the client asks, the server answers, done. A WebSocket starts as HTTP but upgrades to a persistent, bidirectional channel where the server can push data without being asked. Use HTTP for fetch-and-save; use WebSockets when both sides need to talk in real time.
Are WebSockets still used in 2026?
Yes. They are the standard for real-time, bidirectional browser-server communication: chat, live dashboards, collaborative apps, games, terminals, and server-driven UI. Newer options (HTTP/2 streams, WebTransport) exist for specific cases, but WebSockets remain the broadly supported default.
WebSocket vs Socket.IO, what is the difference?
A WebSocket is the underlying browser protocol. Socket.IO is a JavaScript library built on top that adds conveniences like automatic reconnection, fallbacks, and rooms. You can use raw WebSockets directly (as in the examples here); libraries like Socket.IO trade a little weight for those features.
A WebSocket is a simple idea, one open pipe both sides can talk through, wrapped around a few operational truths that only show up under load. Get the handshake, respect the backpressure, plan for the drop, and the rest is just messages.
