WebSocket
WebSocket is a protocol that gives a browser (or any client) and a server a two-way connection that stays open. Either side can send a message whenever it wants, without waiting to be asked. It is what sits behind chat apps, live scoreboards, multiplayer games and collaborative editors. This lesson shows why it exists, how a normal HTTP request turns into a WebSocket, what the messages look like on the wire, and what to watch out for in a real project.
The Problem: Plain HTTP Cannot Push
As you saw in HTTP & HTTPS, the client always speaks first. A server has no way to tell a plain HTTP client "something just happened". So developers invented workarounds to get live data, and each one has a cost:
- Polling. The client asks "anything new?" every few seconds. It is simple, but most answers are empty, every request carries full headers and cookies, and news can arrive up to one interval late.
- Long polling. The client asks, and the server holds the request open until it has something to say (or a timeout hits). The client then immediately asks again. News arrives faster, but the server keeps many requests waiting and every message still costs a new request and response.
- Server-Sent Events (SSE). The browser opens one HTTP response that never ends, and the server writes events into it. It works well, but data only flows from the server to the client, and only as text.
What Is WebSocket?
WebSocket (standardised as RFC 6455, with a matching browser API) solves this by changing the rules after the first request:
- Full-duplex. Both sides can send at the same time, in any order.
- One long-lived connection. It runs on a single TCP connection that stays open for minutes, hours or days, so there is no new connection per message.
- Messages, not requests. After the handshake there are no methods, status codes or repeated headers. Each message carries only a few bytes of framing.
- Two URL schemes.
ws://is unencrypted and typically uses port80.wss://runs over TLS, just like HTTPS, and typically uses port443. Usewss://on any real site. - It starts life as HTTP. The first thing on the wire is an ordinary HTTP request that asks the server to switch protocols. That is why WebSocket can share ports 80 and 443 with a normal website.
The Opening Handshake
The client sends an HTTP/1.1 GET request with a few special headers. If the server agrees, it
answers 101 Switching Protocols. From then on the same TCP connection no longer speaks HTTP.
GET /chat HTTP/1.1
Host: example.com
Origin: https://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=
What each header is for:
| Header | Purpose |
|---|---|
Upgrade: websocket and Connection: Upgrade | "Please switch this connection to the WebSocket protocol." |
Sec-WebSocket-Key | A random 16-byte value, base64-encoded, chosen by the client for this one connection |
Sec-WebSocket-Version: 13 | The protocol version. 13 is the version everyone uses |
Origin | Which web page opened the connection. Browsers add it automatically (see the security section below) |
Sec-WebSocket-Accept | The server's proof that it understood the request (explained next) |
How Sec-WebSocket-Accept is built
The server takes the client's key, appends a fixed text value defined in the standard
(258EAFA5-E914-47DA-95CA-C5AB0DC85B11), computes the SHA-1 hash of the result and
encodes it as base64. You can reproduce it in Node.js:
const crypto = require('crypto');
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
console.log(accept); // s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
wss:// and from your own checks on the server.
Frames: What a Message Looks Like
After the handshake, data travels in small packets called frames. A message is one frame, or
several frames when it is split up (the first frame has the type, the later ones are continuation
frames, and the last one has FIN = 1). Every frame starts with a short header:
The opcode says what kind of frame this is:
| Opcode | Name | Meaning |
|---|---|---|
0x0 | Continuation | Another piece of a message that started in an earlier frame |
0x1 | Text | The payload is UTF-8 text |
0x2 | Binary | The payload is raw bytes (images, audio, your own format) |
0x8 | Close | "I am closing." Carries a status code and an optional reason |
0x9 | Ping | "Are you still there?" Either side may send it |
0xA | Pong | The answer to a ping, repeating the ping's data |
- Masking. Every frame a client sends must be masked: the payload is XORed with a random 4-byte key placed in the frame. Frames from the server must not be masked. Masking is not encryption (the key travels with the frame). It exists so that a badly behaved proxy cannot be tricked by data that looks like HTTP. Browsers and libraries do it for you.
- Control frames (close, ping, pong) are small: their payload is at most 125 bytes and they are never split into several frames.
- RSV1 to RSV3 are reserved for extensions. The common
permessage-deflateextension, which compresses messages, uses RSV1.
The Life of a Connection
In the browser the connection is always in one of four states, available as socket.readyState.
While it is open, the two sides can check that the line is still alive with ping and
pong frames, and either side ends the session with a close frame.
A close frame begins with a 2-byte status code. The ones you will see most often:
| Code | Meaning |
|---|---|
1000 | Normal closure: the work is done |
1001 | Going away, for example the server is shutting down or the user left the page |
1006 | Abnormal closure. Never sent on the wire: the browser reports it when the connection was lost without a close frame |
1008 | Policy violation, for example a failed Origin or authentication check |
1009 | Message too big |
1011 | The server hit an unexpected error |
Browser Example
Browsers have WebSocket built in. There is nothing to install:
const socket = new WebSocket('wss://example.com/chat');
socket.onopen = (event) => {
console.log('Connected');
socket.send(JSON.stringify({ type: 'hello', name: 'Asha' }));
};
socket.onmessage = (event) => {
const msg = JSON.parse(event.data); // event.data is a string for text frames
console.log('Received:', msg);
};
socket.onerror = (event) => {
console.log('Connection error'); // the event carries no details on purpose
};
socket.onclose = (event) => {
console.log('Closed with code', event.code, event.reason);
};
Call socket.send(...) only after onopen has fired (or when
socket.readyState === WebSocket.OPEN), and socket.close() when you are done.
The browser answers the server's pings automatically; your JavaScript cannot send pings itself, so
apps that need a heartbeat usually send a small message of their own every so often.
Node.js Server Example
One of the most popular libraries for Node.js is ws. This small server echoes every message it
receives to all connected clients, which is already a working chat room:
// npm install ws
const { WebSocketServer, WebSocket } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
console.log('A client connected');
ws.on('error', console.error);
ws.on('message', (data, isBinary) => {
// send the message to everyone who is still connected
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
}
});
ws.on('close', () => console.log('A client left'));
});
console.log('Listening on ws://localhost:8080');
node server.js), not on this website. Then connect to ws://localhost:8080 with the browser code above from a page you open locally. Plain ws:// is fine for local testing; use wss:// in production.Where WebSocket Is Used
- Chat and messaging. A message is delivered the moment someone sends it.
- Live dashboards and prices. Stock tickers, sports scores, monitoring graphs and order books update without reloading.
- Notifications. The server tells an open page about a new comment, a finished job or a payment.
- Multiplayer games. Many small, frequent messages in both directions with low delay.
- Collaborative editing. Every keystroke or cursor move is shared with the other people in the document.
If the data only flows one way and rarely, plain HTTP or SSE may be simpler. For tiny devices that publish sensor data, see MQTT, which solves a related problem with a lighter model.
WebSocket vs Polling vs Server-Sent Events
| WebSocket | Polling / long polling | Server-Sent Events | |
|---|---|---|---|
| Direction | Both ways, at any time | Client asks, server answers | Server to client only |
| Connection | One long-lived connection | A new request for every check (long polling holds each one open for a while) | One long-lived HTTP response |
| Overhead | A few bytes of framing per message | Full HTTP headers and cookies on every request | Small, plain text events |
| Reconnect after a drop | You write it yourself | Natural: the next poll simply tries again | Built into the browser's EventSource |
| Data | Text or binary | Anything HTTP can carry | Text only |
| Browser support | All modern browsers | Everywhere | All modern browsers |
| Proxies and firewalls | Usually fine over wss://; some old or strict proxies interfere | Works wherever HTTP works | Works over HTTP, but some proxies buffer the stream |
| Best for | Chat, games, collaboration | Simple, infrequent updates | Feeds, notifications, progress |
Production Issues and Common Mistakes
- Idle connections get dropped. Proxies and load balancers often close a connection after a period of silence (frequently around a minute, but it varies). Send a ping from the server, or a small message from the client, every 20 to 30 seconds, and close connections that stop answering.
- No reconnect logic. Networks drop, phones change from Wi-Fi to mobile data, servers restart. Reconnect automatically with exponential backoff (wait 1 s, then 2 s, 4 s and so on up to a limit, ideally with some randomness) so that thousands of clients do not hammer the server at the same moment. After reconnecting, subscribe again and ask for the messages you missed. The server has forgotten you.
- Using
ws://in production. Always usewss://. It protects the data and also passes through more networks. - Trusting the browser's origin policy. Browsers do not apply the same-origin rule to WebSocket. Any web page can try to open a connection to your server, and the browser will include your user's cookies. The server must check the
Originheader itself. - Skipping authentication. Authenticate in the handshake with a session cookie, or send a short-lived token as the first message. Browser JavaScript cannot add custom headers to a WebSocket handshake. Avoid putting tokens in the URL query string: URLs are written to logs and history.
- Unlimited message size. One huge message can use up the server's memory. Set a maximum (the
wslibrary has amaxPayloadoption), validate every message as untrusted input, and limit how many messages a client may send per second. - Forgetting that connections are stateful. Each open connection lives on one server. To scale out, use sticky sessions so a client keeps reaching the same server, and a shared message backend (for example a pub/sub system such as Redis) so that a message received by one server reaches clients connected to the others.
A simple Origin check with ws looks like this:
wss.on('connection', (ws, req) => {
if (req.headers.origin !== 'https://example.com') {
ws.close(1008, 'Origin not allowed'); // 1008 = policy violation
return;
}
// ... authenticate, then handle messages
});
And a minimal reconnect loop in the browser:
let delay = 1000; // start with one second
function connect() {
const socket = new WebSocket('wss://example.com/chat');
socket.onopen = () => {
delay = 1000; // connected again: reset the wait
// subscribe to your channels again here
};
socket.onclose = () => {
setTimeout(connect, delay);
delay = Math.min(delay * 2, 30000); // double the wait, at most 30 s
};
}
connect();
GET with Upgrade: websocket, the server answers 101 Switching Protocols with a
Sec-WebSocket-Accept value, and the same TCP connection then carries small frames in both directions.
Frames have an opcode (text, binary, close, ping, pong), and client frames are always masked. Use
wss://, send heartbeats, reconnect with backoff, check the Origin and authenticate on the server,
limit message sizes, and plan for sticky sessions or a pub/sub backend when you scale.