⏱️ Lectura: 14 min
Every time Slack shows a new message without you refreshing the page, or a trading dashboard updates a price every millisecond, there’s an open connection behind it that never closes: the WebSocket protocol. Unlike a normal HTTP request, which opens, responds, and closes, WebSocket keeps an active bidirectional channel between browser and server for the entire session.
📑 En este artículo
- TL;DR
- What WebSocket Is and Why It Matters
- How the Handshake and Frames Work
- Practical Examples
- How to Get Started Step by Step
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison with Alternatives
- Going Deeper: How It Works Under the Hood
- Architecture for Scaling to Multiple Servers
- Frequently Asked Questions
- Does WebSocket run over HTTP or over TCP?
- Do I need a special server to use WebSocket?
- What’s the difference between WebSocket and Server-Sent Events?
- Does WebSocket work with HTTPS?
- How do I scale WebSocket to thousands of simultaneous connections?
- What happens if the browser loses the connection mid-session?
- References
Think of it as the difference between mailing letters and having an open phone call: with HTTP, each exchange is a letter you send and pick up at the post office; with WebSocket, you pick up the phone once and both sides talk whenever they want, without hanging up. This article explains how the protocol works under the hood, how to implement it with real code, and when it makes sense to use it over alternatives like Server-Sent Events or HTTP polling.
TL;DR
- How the initial HTTP handshake uses the Upgrade header and status code 101 to open a persistent WebSocket connection.
- How to spin up a WebSocket server in Node.js with the ws library in under ten lines of code.
- How to tell WebSocket apart from HTTP polling, Server-Sent Events, and WebTransport, and when to choose each one.
- Why a WebSocket connection drops behind a proxy without a heartbeat, and how to prevent it with ping and pong.
- How a WebSocket frame is structured (opcode, mask, length) and why the client always masks its data.
- How to scale WebSocket across multiple servers with Redis pub/sub without losing messages between instances.
- How to verify with curl or devtools that a connection actually completed the upgrade to WebSocket.
What WebSocket Is and Why It Matters
WebSocket is a communication protocol that runs over a single TCP connection and lets client and server send each other data at any time, without either one having to ask the other. It was standardized in 2011 by the IETF in RFC 6455, and today every modern browser implements it natively through the DOM’s WebSocket API.
The difference from traditional HTTP is structural. In HTTP, each exchange is a request and a response: the client asks, the server answers, and the connection sits idle until the next request. If the server needs to tell the client something without being asked, it has no way to do it. WebSocket flips that rule: once the initial handshake completes, both sides can send messages independently, without asking permission.
This matters because much of the modern web reacts in real time: chats, trading dashboards, push notifications, collaborative cursors in documents, browser-based multiplayer. Building that with plain HTTP forces you into techniques like polling (asking every N seconds) or long polling (leaving the request open until there’s news), both slower and more costly on the server than an open WebSocket.
How the Handshake and Frames Work
It all starts with a normal HTTP request asking for a protocol upgrade. The client sends an Upgrade: websocket header along with a random key in Sec-WebSocket-Key:
GET /chat HTTP/1.1
Host: ejemplo.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server accepts, it responds with status code 101 (Switching Protocols) and confirms the key by concatenating it with a fixed GUID defined in RFC 6455 (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), computing a SHA-1 hash, and encoding it in base64:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The handshake can also negotiate a subprotocol with the Sec-WebSocket-Protocol header, useful when client and server need to agree on a specific message format, for example graphql-ws for GraphQL subscriptions over WebSocket.
From that point on, the same TCP connection stops speaking HTTP and starts exchanging binary frames in both directions. The following diagram shows the full cycle:
sequenceDiagram
participant N as Browser
participant S as Server
N->>S: GET /chat, upgrade to websocket
S-->>N: 101 Switching Protocols
Note over N,S: the TCP connection stays open
N->>S: sends text frame (hello)
S-->>N: responds with text frame (echo hello)
Note over N,S: either side sends frames without waiting for a turn
The handshake happens only once. After that, either side can send a frame at any time, without waiting its turn. That’s what makes it possible for a server to push a message to the client without the client having asked for anything.
Practical Examples
The simplest example is an echo: the server repeats back whatever the client sends. With Node.js’s ws library, a complete server fits in a few lines:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (socket) => {
socket.on('message', (data) => {
socket.send(`echo: ${data}`);
});
});
This server listens on port 8080 and, for every message it receives, responds with the same text prefixed by “echo: “. On the browser side you don’t need any library: the WebSocket API is already built in:
const socket = new WebSocket('ws://localhost:8080');
socket.addEventListener('open', () => {
socket.send('hello server');
});
socket.addEventListener('message', (event) => {
console.log('received:', event.data);
});
When you open this page with the server running, the browser console will print received: echo: hello server as soon as the connection completes.
A more realistic case is a chat where any incoming message gets forwarded to every connected client, not just the sender. The difference from the previous example is that the server needs to keep track of the active sockets:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clientes = new Set();
wss.on('connection', (socket) => {
clientes.add(socket);
socket.on('message', (data) => {
for (const cliente of clientes) {
if (cliente.readyState === cliente.OPEN) {
cliente.send(data.toString());
}
}
});
socket.on('close', () => clientes.delete(socket));
});
Here, clientes is a Set that grows with every new connection and gets cleaned up when someone disconnects. The condition readyState === cliente.OPEN prevents the classic error of trying to write to a socket that’s already closed: without it, the server throws an uncaught exception the first time a client closes the tab mid-send.
How to Get Started Step by Step
To try this on your machine, all you need is Node.js installed. The steps:
First, create a project and install the library:
mkdir websocket-demo && cd websocket-demo
npm init -y
npm install ws
Next, save the echo server code from above into servidor.js and start it:
node servidor.js
Finally, verify the connection without writing any HTML, using the wscat command-line tool:
npx wscat -c ws://localhost:8080
When you type a message in that terminal and press Enter, wscat should show the response echo: (your message) almost immediately. If instead you get a connection refused error, confirm the server is still running in the other terminal and that port 8080 isn’t taken by another process.
You can also confirm the protocol upgrade directly with curl, without any WebSocket library:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" http://localhost:8080/
A response with HTTP/1.1 101 Switching Protocols confirms the server completed the upgrade. In the browser devtools, the Network tab filtered by “WS” shows the same 101 code and, below it, each individual frame sent or received during the session.
Real-World Use Cases
WebSocket handles well a handful of scenarios where per-message latency matters more than implementation simplicity:
- Chat and messaging: apps like Slack or Discord use persistent connections to deliver messages without the client having to ask.
- Financial dashboards: trading platforms push price changes in real time, where a delay of seconds affects decisions.
- Collaborative editing: shared text editors transmit every cursor change and every edit to the rest of the connected participants.
- Browser-based multiplayer: games that sync position and state between players without reloading the page.
- Monitoring dashboards: panels that reflect alerts or metrics as soon as they change, without manual refreshing.
In all these cases, the cost of opening and closing an HTTP connection for every update, with its headers, its TLS negotiation if applicable, and its full round trip, exceeds the cost of keeping a socket open, especially when updates arrive several times per second.
Common Mistakes and Best Practices
The most common mistake is not planning for reconnection. A WebSocket connection can drop because of a network change, a proxy closing idle connections, or simply because the server restarts. If the client doesn’t retry with exponential backoff, the application silently hangs without the user noticing.
⚠️ Heads up: many corporate proxies and load balancers close TCP connections that have been idle for more than 60 seconds, even though the WebSocket is technically still open. The standard solution is a heartbeat: the server sends a ping frame at a set interval and waits for the corresponding pong; if it doesn’t arrive, it closes the connection and the client reconnects.
Another typical problem shows up when scaling beyond a single server process. If you have two instances behind a load balancer, a message that arrives at instance A doesn’t automatically reach a client connected to instance B. The usual solution is a shared pub/sub channel, with Redis being the most common choice, where each instance publishes the messages it receives and subscribes to the ones the others publish.
It’s also easy to forget to validate the Origin header during the handshake. Unlike a normal fetch request, WebSocket doesn’t enforce CORS by default: any page can try to open a connection to your server. If the server doesn’t validate the origin, it’s exposed to what’s known as cross-site WebSocket hijacking, where a malicious page opens an authenticated connection using the user’s session cookies without the user noticing.
Finally, sending very large messages without fragmenting them creates memory pressure on both ends. If your application transmits files or heavy payloads, it’s better to split them into fragments and use the frame’s FIN bit to signal when the complete message ends, instead of sending everything at once.
Comparison with Alternatives
None of these options is universally better: the choice depends on whether you need the client to talk back too, and how much infrastructure you’re willing to maintain.
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| WebSocket | Frequent bidirectional communication (chat, games, trading) | True full-duplex, low per-message latency | Requires handling reconnection and scaling manually |
| HTTP polling | Infrequent updates, quick prototypes | Simple, no special infrastructure | High latency and unnecessary server load |
| Server-Sent Events | Only the server needs to push data (notifications, logs) | Automatic reconnection built into the browser | One-directional: the client can’t respond over the same channel |
| WebTransport | New apps already running on HTTP/3 | Multiple independent streams over QUIC | Browser support still more limited than WebSocket |
Going Deeper: How It Works Under the Hood
Under the hood, every WebSocket message travels as one or more frames with a fixed structure defined in RFC 6455: a FIN bit that indicates whether it’s the last fragment, a 4-bit opcode that says whether the payload is text (0x1), binary (0x2), or a control signal like close (0x8), ping (0x9), or pong (0xA), and a length field that can take up 7, 16, or 64 bits depending on the payload size.
A little-known detail: every frame traveling from client to server must be masked with a randomly generated 4-byte key, something that doesn’t apply in the opposite direction. This isn’t a security measure against attackers who already control the traffic, but a specific defense against misconfigured caching proxies: without the mask, an attacker could craft a payload that an intermediate proxy interprets as a valid HTTP request and improperly caches or forwards.
The protocol also defines standardized close codes in RFC 6455: 1000 means normal closure, 1006 indicates the connection was cut abnormally (no explicit close frame, typical of a network drop), and 1011 signals that the server ran into an unexpected condition. Checking the close code in the client’s close event is the right way to decide whether to reconnect automatically or show the user an error.
To cut down on traffic there’s the permessage-deflate extension, negotiated during the handshake, which compresses each message with DEFLATE before sending it. It’s useful for repetitive text payloads, like JSON with many identical keys, but it adds CPU cost per message, so it’s not always worth enabling for workloads with huge numbers of small messages.
💡 Tip: if your application already uses Socket.IO and you don’t need rooms or automatic fallback to HTTP, migrating to the browser’s native WebSocket removes an entire dependency and shrinks the client bundle size.
Plain WebSocket is different from libraries like Socket.IO, which layer on top of the protocol things like automatic reconnection, per-message acknowledgments, rooms for grouping clients, and a fallback mechanism to HTTP long polling when WebSocket isn’t available. The upside is productivity; the trade-off is that Socket.IO defines its own message format on top of the frames, so a Socket.IO client can’t talk directly to a server that only implements the native WebSocket API.
Architecture for Scaling to Multiple Servers
When a single server instance isn’t enough, adding more processes behind a load balancer breaks the assumption that all clients share memory. The following diagram shows the usual pattern with a shared pub/sub channel:
flowchart TD
A["Browser"] --> B["Load balancer"]
B --> C["WS Server 1"]
B --> D["WS Server 2"]
C --> E[("Redis pub-sub")]
D --> E
subgraph Backend
C
D
E
end
Each server publishes to Redis the messages it receives from its own clients and subscribes to the same channel to receive what the other instances publish. This way, a message that comes in through server 1 can reach a client connected to server 2 without the two processes sharing memory directly.
📖 Summary on Telegram: View summary
Your next step: clone this article’s echo server, run node servidor.js, and connect with npx wscat -c ws://localhost:8080 to see the protocol working live in under five minutes.
Frequently Asked Questions
Does WebSocket run over HTTP or over TCP?
It starts as a normal HTTP request (the handshake), but once the server responds with status code 101, the connection switches to speaking the WebSocket protocol directly over the underlying TCP socket, with no more HTTP headers per message.
Do I need a special server to use WebSocket?
You don’t need a dedicated server: libraries like ws in Node.js, or native support in frameworks like Django Channels or Spring WebSocket, add the ability to handle the protocol on the same process that already serves HTTP.
What’s the difference between WebSocket and Server-Sent Events?
Server-Sent Events only lets the server send data to the client, over a normal HTTP connection with automatic reconnection built in. WebSocket is bidirectional: the client can also send messages over the same channel without opening a new connection.
Does WebSocket work with HTTPS?
Yes. The encrypted version is identified by the wss:// scheme instead of ws://, and it runs over TLS just like HTTPS. It’s the recommended option in production, especially if the connection travels over untrusted networks.
How do I scale WebSocket to thousands of simultaneous connections?
The practical limit is usually the process’s memory, since each open connection consumes file descriptors and buffers, more than the protocol itself. The usual strategy combines multiple server instances behind a load balancer with support for persistent connections, coordinated through a shared pub/sub channel like Redis.
What happens if the browser loses the connection mid-session?
The close event fires on the client with a code indicating the reason. A robust implementation listens for that event and retries the connection with exponential backoff, instead of assuming the user closed the tab intentionally.
References
- RFC 6455: the official WebSocket protocol specification published by the IETF in 2011.
- MDN: WebSocket API: reference documentation for the browser’s native API.
- ws on GitHub: the WebSocket server and client implementation for Node.js used in this article’s examples.
- Socket.IO on GitHub: a library that adds reconnection, rooms, and fallback on top of WebSocket.
📱 Do you like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
0 Comments