🔌 WebSocket Fundamentals
Every chat app, live scoreboard, and collaborative editor you've ever used shares one trick: the server can talk to the browser without being asked first. Plain HTTP can't do that. In this lesson you'll learn the protocol that can — the WebSocket — and build both a client and a server that hold an open, two-way conversation.
Week 10 · Tuesday: Real-time Features · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why the HTTP request/response model falls short for real-time features
- Contrast polling, long-polling, Server-Sent Events, and WebSockets, and pick the right tool
- Describe the HTTP
Upgradehandshake that turns one TCP connection into a persistent WebSocket - Use the browser
WebSocketAPI —open,message,close,error, andreadyState - Build a Node WebSocket server with the
wslibrary and broadcast to all clients - Add resilient reconnection with exponential backoff and know when to reach for Socket.IO instead
Estimated Time: 75 minutes
Practice: Build a minimal echo client, then a broadcast chat server with the ws library.
In This Lesson
The Real-time Problem
Ordinary HTTP is a request/response protocol: the browser asks a question, the server answers, and the connection closes. That's perfect for loading a page or submitting a form — but it has a fatal limitation for live features. Only the client can start a conversation. If a new chat message arrives on the server, the server has no way to hand it to the browser until the browser happens to ask again.
Real-time apps flip that dynamic. They need the server to push data the instant something happens. Consider where this matters:
- Chat & messaging — messages must appear the moment they are sent
- Live dashboards — metrics, prices, and sensor readings that update themselves
- Collaborative editing — many people typing into one document at once
- Multiplayer games — player positions flowing both directions many times a second
- Notifications — instant alerts without a page refresh
🛣️ The toll-booth analogy
HTTP is like a highway toll booth: every car (request) must stop, pay, collect a ticket (response), and drive on. Fine for the occasional trip. But if a car needs to talk to the booth constantly, forcing it to exit and re-enter the highway each time is absurdly wasteful. A WebSocket is a transponder bolted into the car: once it's registered at the gate, the car and the toll system exchange signals freely, in both directions, without ever stopping again.
Polling, Long-Polling & SSE
Before WebSockets existed (they were standardized as RFC 6455 in 2011), developers faked real-time with a series of clever HTTP workarounds. It's worth knowing them, because each one teaches you what WebSockets improve on — and because SSE is still the right choice for some jobs.
1. HTTP Polling
The client asks again on a fixed timer. Simple to write, but it wastes bandwidth on empty replies and always lags behind by up to one interval.
// Poll every 5 seconds — new data can be up to 5s stale
async function pollForUpdates() {
try {
const res = await fetch('/api/updates');
const data = await res.json();
updateUI(data);
} catch (err) {
console.error('Polling error:', err);
} finally {
setTimeout(pollForUpdates, 5000); // schedule the next poll
}
}
pollForUpdates();
2. Long-Polling
The client sends a request and the server holds it open until it actually has something to send, then responds. The client immediately opens another. Latency drops close to real-time, but you still pay the full cost of a new HTTP request for every message.
3. Server-Sent Events (SSE)
SSE opens one long-lived HTTP connection over which the server streams events to the client. It has a lovely built-in API (EventSource) and automatic reconnection — but it is strictly one-way: server to client only.
// SSE is great for server-push-only feeds (stock tickers, logs)
const source = new EventSource('/api/updates/sse');
source.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};
source.onerror = () => console.log('SSE dropped — the browser will retry automatically');
| Technique | Direction | Latency | Best for |
|---|---|---|---|
| Polling | Client pulls | High | Rare, non-urgent updates |
| Long-polling | Client pulls | Low | Legacy fallback |
| SSE | Server → client | Low | One-way streams: feeds, logs, tickers |
| WebSocket | Both ways | Lowest | Chat, games, collaboration |
💡 SSE isn't obsolete
If your data only ever flows server → client (a live feed of stock prices, a build log, a progress bar), SSE is simpler than a WebSocket and rides on plain HTTP, so it works through more proxies with less setup. Reach for a WebSocket when you genuinely need the client to push back too.
The WebSocket Handshake
A WebSocket connection is full-duplex (both sides can send at any time) and persistent (it stays open) over a single TCP connection. But it doesn't start life as a WebSocket — it starts as an ordinary HTTP request that asks to be upgraded.
The client sends a normal GET with a special Upgrade: websocket header and a random Sec-WebSocket-Key. If the server agrees, it replies with status 101 Switching Protocols. From that instant, both sides stop speaking HTTP and start speaking the WebSocket framing protocol over the same socket.
Here is what the raw exchange looks like on the wire:
// Client request — a normal HTTP GET that asks to upgrade
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
// Server response — agreement to switch protocols
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
🔗 The two URL schemes
WebSocket URLs use their own schemes, mirroring HTTP:
ws://— unencrypted, the WebSocket cousin ofhttp://wss://— encrypted over TLS, the cousin ofhttps://
Always use wss:// in production, exactly as you'd insist on HTTPS. Unencrypted WebSocket traffic is trivially readable and is often blocked by corporate proxies.
Key properties, at a glance
- Bidirectional & full-duplex: either side sends whenever it wants, simultaneously
- Persistent: one TCP connection stays open for the session's lifetime
- Low overhead: after the handshake, a message frame adds only a few bytes — no repeated HTTP headers
- Format-agnostic: send text (usually JSON) or binary (
ArrayBuffer,Blob)
The Browser WebSocket API
Every modern browser ships a native WebSocket constructor. You create one with a URL, then attach event listeners. There are exactly four events to know: open, message, error, and close.
// 1. Open the connection (wss:// in production!)
const socket = new WebSocket('wss://echo.websocket.org');
// 2. Fires once the handshake completes
socket.addEventListener('open', () => {
console.log('Connected');
socket.send('Hello, server!'); // only safe to send after 'open'
});
// 3. Fires every time the server sends data
socket.addEventListener('message', (event) => {
console.log('From server:', event.data); // event.data is a string or binary
});
// 4. Fires on protocol/network errors
socket.addEventListener('error', (event) => {
console.error('WebSocket error', event);
});
// 5. Fires when either side closes — event.code tells you why
socket.addEventListener('close', (event) => {
console.log(`Closed: code=${event.code} reason=${event.reason}`);
});
Sending structured data
WebSockets only carry strings and binary. To send an object, serialize it with JSON.stringify and parse it on the other side — the same pattern you already use with fetch.
const payload = {
type: 'chat',
user: 'ada',
text: 'Hello everyone!',
sentAt: new Date().toISOString(),
};
socket.send(JSON.stringify(payload)); // send text
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data); // decode on arrival
if (data.type === 'chat') renderMessage(data);
});
readyState — don't send into the void
A socket moves through four states. Calling send() before it is OPEN throws, so guard your sends.
| Constant | Value | Meaning |
|---|---|---|
WebSocket.CONNECTING | 0 | Handshake in progress |
WebSocket.OPEN | 1 | Ready — you can send |
WebSocket.CLOSING | 2 | Close handshake underway |
WebSocket.CLOSED | 3 | Closed or failed to open |
function safeSend(socket, message) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(message);
} else {
console.warn('Socket not open yet — message dropped or should be queued');
}
}
Console output
Connected
From server: Hello, server! // echo.websocket.org bounces it back
Closed: code=1000 reason=Normal closure
A Node Server with ws
The browser is only half the story. On the server, Node has no built-in WebSocket server, so we use the tiny, battle-tested ws library. Attach it to an existing HTTP server so your Express routes and your WebSocket share one port.
// npm install ws express
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const app = express();
app.use(express.static('public'));
// Share one HTTP server between Express and the WebSocket server
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
console.log('Client connected from', req.socket.remoteAddress);
// Greet the newcomer
ws.send(JSON.stringify({ type: 'system', text: 'Welcome!' }));
// Handle every message this client sends
ws.on('message', (raw) => {
let data;
try {
data = JSON.parse(raw); // ws gives you a Buffer; parse it
} catch {
return ws.send(JSON.stringify({ type: 'error', text: 'Send valid JSON' }));
}
// Echo it back with a timestamp
ws.send(JSON.stringify({ type: 'echo', data, at: Date.now() }));
});
ws.on('close', (code) => console.log('Client left, code', code));
ws.on('error', (err) => console.error('Socket error:', err.message));
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`Listening on ${PORT}`));
Broadcasting to everyone
The whole point of chat is that one person's message reaches all the others. The server keeps a set of connected clients in wss.clients; loop over it and send to each open socket.
const { WebSocket } = require('ws');
function broadcast(data, except = null) {
const message = JSON.stringify(data);
for (const client of wss.clients) {
// Only send to clients that are open, optionally skipping the sender
if (client.readyState === WebSocket.OPEN && client !== except) {
client.send(message);
}
}
}
wss.on('connection', (ws) => {
broadcast({ type: 'system', text: 'A new user joined' }, ws); // skip the newcomer
ws.on('message', (raw) => {
const data = JSON.parse(raw);
if (data.type === 'chat') {
broadcast({ type: 'chat', user: data.user, text: data.text, at: Date.now() });
}
});
});
⚠️ Authenticate at connection time
The Upgrade request carries cookies and headers just like any HTTP request, so verify the user during the handshake — before connection fires. Use the verifyClient option (or, better, handle the server's upgrade event yourself) to check a session or a token, and reject unauthorized upgrades with a 401. Never trust a socket just because it connected.
Reconnection & Reliability
Real networks drop connections constantly — a phone changes towers, a laptop sleeps, a load balancer recycles. The native WebSocket does not reconnect for you. A robust client detects an abnormal close and retries with exponential backoff so it doesn't hammer a struggling server.
class ReconnectingSocket {
constructor(url) {
this.url = url;
this.attempts = 0;
this.maxDelay = 30000; // cap the wait at 30 seconds
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.attempts = 0; // reset backoff on success
};
this.ws.onmessage = (event) => this.onMessage?.(event.data);
this.ws.onclose = (event) => {
if (event.code === 1000) return; // 1000 = clean close, do not retry
this.scheduleReconnect();
};
this.ws.onerror = () => this.ws.close(); // force onclose to run
}
scheduleReconnect() {
this.attempts++;
// 1s, 1.5s, 2.25s, ... capped at maxDelay
const delay = Math.min(this.maxDelay, 1000 * 1.5 ** (this.attempts - 1));
console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempts})`);
setTimeout(() => this.connect(), delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(data);
}
}
const socket = new ReconnectingSocket('wss://example.com/chat');
socket.onMessage = (data) => console.log('Got:', data);
✅ This is exactly why Socket.IO exists
Reconnection, backoff, heartbeats to detect dead connections, buffering messages while offline, fallback transports for hostile proxies, rooms, and acknowledgements — you'd have to build all of that yourself on raw WebSockets. Socket.IO packages it up. It is not raw WebSocket (it has its own protocol layered on top), but it saves you from re-implementing everything above. That's the very next lesson.
Practice & Quiz
🏋️ Exercise 1: An echo client
Goal: Connect to the public echo server, send "ping" once the connection opens, and log whatever comes back.
// TODO: open a WebSocket to wss://echo.websocket.org
// TODO: on 'open', send the string "ping"
// TODO: on 'message', log the data
💡 Hint
Create the socket, then use addEventListener('open', ...) to send — you can't send before the handshake finishes. Listen for 'message' and read event.data.
✅ Solution
const socket = new WebSocket('wss://echo.websocket.org');
socket.addEventListener('open', () => {
socket.send('ping');
});
socket.addEventListener('message', (event) => {
console.log('Echo:', event.data); // "ping"
});
🏋️ Exercise 2: A broadcast chat server
Goal: Using ws, write a server that relays every message it receives to all other connected clients (not back to the sender).
💡 Hint
Iterate wss.clients. Send only when client.readyState === WebSocket.OPEN and client !== ws (the sender).
✅ Solution
const { WebSocketServer, WebSocket } = require('ws');
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
for (const client of wss.clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(raw.toString());
}
}
});
});
🎯 Quick Quiz
Question 1: What HTTP status code does the server return to accept a WebSocket upgrade?
Question 2: Which statement about WebSockets is true?
Question 3: Before calling socket.send(), what should you check?
Best Practices & Pitfalls
✅ Do
- Use
wss://everywhere in production — encrypt the traffic - Authenticate during the upgrade handshake, before accepting the connection
- Send structured data as JSON with a
typefield so the receiver can route it - Add reconnection with exponential backoff; reset the delay after a successful connect
- Guard every
send()with areadyState === OPENcheck - Validate and sanitize every message — a socket is untrusted input just like a form
❌ Don't
- Assume the connection stays up — networks drop sockets constantly
- Reconnect in a tight loop; that turns one outage into a self-inflicted DDoS
- Trust
event.datablindly — alwaysJSON.parseinside atry/catch - Reach for raw WebSockets when Socket.IO's reconnection, rooms, and fallbacks would save you days
⚠️ Close codes carry meaning
Code 1000 is a normal, deliberate close — don't reconnect after it. Codes like 1006 (abnormal closure) or 1011 (server error) signal trouble and should trigger a retry. Inspect event.code in your close handler rather than blindly reconnecting on every close.
Summary
🎉 Key Takeaways
- Plain HTTP is request/response — only the client can start a conversation, so it can't push
- A WebSocket is a full-duplex, persistent connection over a single TCP socket, opened by an HTTP Upgrade handshake that returns 101 Switching Protocols
- Use
ws://andwss://schemes; alwayswss://in production - The browser API is four events —
open,message,error,close— plusreadyState - On the server, the
wslibrary attaches to your HTTP server; broadcast by looping overwss.clients - Raw WebSockets don't reconnect — you add backoff yourself, which is a big reason Socket.IO exists
📚 Additional Resources
- MDN — The WebSocket API
- MDN — Writing WebSocket client applications
- ws — the Node WebSocket library
- RFC 6455 — The WebSocket Protocol
🚀 What's Next?
You now understand the raw protocol — and everything it makes you responsible for. Next, we hand that burden to a library that handles reconnection, rooms, namespaces, and fallbacks for you: Socket.IO Implementation.
🎉 The socket is open!
You've moved from "the client must always ask" to "the server can push." That single shift powers every live feature you'll build from here.