WebSockets: Real-Time Communication Without the Polling Nonsense
A practical guide to WebSockets for web developers. Covers how WebSockets work, when to use them over HTTP, implementing a WebSocket server and client, handling reconnection, and common pitfalls.
HTTP has a fundamental limitation: the client asks, the server answers. That's it. The server can't just... send you data whenever it wants. If you want live updates -- a chat message, a stock price change, a notification -- you have to keep asking the server "anything new? anything new? anything new?" like a kid in a car asking "are we there yet?"
That's polling. And it's terrible.
WebSockets fix this by establishing a persistent, two-way connection between the client and server. Once the connection is open, either side can send data at any time. No repeated requests. No wasted bandwidth. No artificial delay between when something happens and when you know about it.
How WebSockets Actually Work
A WebSocket connection starts as a regular HTTP request. The client sends a special "upgrade" request, and if the server supports WebSockets, it upgrades the connection from HTTP to WebSocket. After that handshake, the HTTP connection transforms into a persistent TCP connection.
The handshake looks like this:
Client → Server:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After this exchange, both sides communicate through WebSocket frames -- lightweight binary messages with minimal overhead (as little as 2 bytes of framing per message, compared to HTTP's headers that can be hundreds of bytes).
The connection stays open until either side explicitly closes it, or the network drops. No more request-response. Just a pipe that both ends can push data through.
When to Use WebSockets (and When Not To)
Use WebSockets for:- Chat applications (messages need to appear instantly)
- Live dashboards (stock prices, server metrics, sports scores)
- Collaborative editing (Google Docs-style real-time sync)
- Multiplayer games (position updates, game state)
- Notifications (the server needs to push to the client)
- Live feeds (social media streams, log tailing)
- Regular CRUD operations (HTTP is simpler and has better caching)
- Data that updates every few minutes (polling with a long interval is fine)
- One-off requests (fetching a user profile doesn't need a persistent connection)
- File uploads/downloads (HTTP handles these well with chunked transfer)
Alternatives to WebSockets
Before committing to WebSockets, know your options:
Server-Sent Events (SSE) -- a one-way channel where the server pushes updates to the client. Simpler than WebSockets, built on regular HTTP, works through most proxies and firewalls. Use SSE when you only need server-to-client updates (like a news feed or notification stream). The client can still use regular HTTP POST to send data back. Long polling -- the client makes an HTTP request, and the server holds it open until it has something to send (or the timeout expires). Then the client immediately makes another request. It works everywhere and doesn't need special server support, but it's less efficient than WebSockets or SSE. HTTP/2 Server Push -- lets the server push resources proactively, but it's designed for pushing assets (CSS, JS), not arbitrary data. Not a replacement for WebSockets.For most real-time needs where data flows both ways, WebSockets are the right choice. For server-to-client only, consider SSE first -- it's simpler.
Building a WebSocket Server (Node.js)
The ws library is the standard for Node.js WebSocket servers. It's fast, well-tested, and has no unnecessary abstractions.
npm install ws
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
console.log("Client connected");
// Send a welcome message
ws.send(JSON.stringify({ type: "welcome", message: "Connected!" }));
// Handle incoming messages
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
console.log("Received:", message);
// Echo back to the sender
ws.send(JSON.stringify({ type: "echo", data: message }));
});
// Handle disconnection
ws.on("close", () => {
console.log("Client disconnected");
});
// Handle errors
ws.on("error", (error) => {
console.error("WebSocket error:", error);
});
});
console.log("WebSocket server running on ws://localhost:8080");
Building a WebSocket Client (Browser)
The browser has a built-in WebSocket API. No libraries needed.
const ws = new WebSocket("ws://localhost:8080");
ws.addEventListener("open", () => {
console.log("Connected to server");
ws.send(JSON.stringify({ type: "chat", text: "Hello!" }));
});
ws.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
});
ws.addEventListener("close", (event) => {
console.log(Disconnected: code=${event.code}, reason=${event.reason});
});
ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error);
});
That's a working WebSocket client-server setup. The client connects, sends a message, and receives responses. Both sides can send data at any time.
Building a Chat Room
Let's build something more practical -- a chat server that broadcasts messages to all connected clients.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map();
function broadcast(message, exclude = null) {
const data = JSON.stringify(message);
for (const [client, info] of clients) {
if (client !== exclude && client.readyState === 1) {
client.send(data);
}
}
}
wss.on("connection", (ws) => {
// Assign a temporary name
const userId = user_${Math.random().toString(36).slice(2, 8)};
clients.set(ws, { userId, joinedAt: Date.now() });
// Notify everyone
broadcast({ type: "system", text: ${userId} joined the chat });
// Tell the new user their ID and who's online
ws.send(JSON.stringify({
type: "init",
userId,
onlineUsers: Array.from(clients.values()).map((c) => c.userId),
}));
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
if (message.type === "chat") {
broadcast({
type: "chat",
userId,
text: message.text,
timestamp: Date.now(),
});
}
if (message.type === "setName") {
const oldName = userId;
clients.get(ws).userId = message.name;
broadcast({
type: "system",
text: ${oldName} is now ${message.name},
});
}
});
ws.on("close", () => {
const info = clients.get(ws);
clients.delete(ws);
broadcast({ type: "system", text: ${info.userId} left the chat });
});
});
The key pattern here is the broadcast function. When one client sends a chat message, the server relays it to every other connected client. This is the foundation of any real-time multi-user application.
Handling Reconnection
Connections drop. Networks flicker. Servers restart. Your client needs to handle this gracefully.
function createWebSocket(url) {
let ws;
let reconnectAttempts = 0;
const maxReconnectDelay = 30000; // 30 seconds max
function connect() {
ws = new WebSocket(url);
ws.addEventListener("open", () => {
console.log("Connected");
reconnectAttempts = 0; // Reset on successful connection
});
ws.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
});
ws.addEventListener("close", (event) => {
if (event.code !== 1000) {
// 1000 = normal closure
scheduleReconnect();
}
});
ws.addEventListener("error", () => {
ws.close(); // Will trigger the close handler
});
}
function scheduleReconnect() {
// Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts),
maxReconnectDelay
);
console.log(Reconnecting in ${delay}ms...);
reconnectAttempts++;
setTimeout(connect, delay);
}
function send(data) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
} else {
console.warn("WebSocket not open, message dropped");
}
}
connect();
return { send };
}
Exponential backoff is essential. If the server is down, you don't want 10,000 clients hammering it with reconnection attempts every second. Start with a short delay and double it each time, up to a maximum.
Heartbeats: Detecting Dead Connections
TCP connections can silently die. The client thinks it's connected, but the packets are going nowhere. Heartbeats (pings/pongs) detect this.
Server-side heartbeat:
const wss = new WebSocketServer({ port: 8080 });
// Ping every client every 30 seconds
const heartbeatInterval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
return ws.terminate(); // Dead connection, kill it
}
ws.isAlive = false;
ws.ping(); // Send ping
}
}, 30000);
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true; // Client responded, connection is alive
});
});
wss.on("close", () => {
clearInterval(heartbeatInterval);
});
The logic: every 30 seconds, mark all connections as "not alive" and send a ping. When the pong comes back, mark it alive again. Next cycle, any connection still marked "not alive" didn't respond -- terminate it.
Message Protocols
Raw WebSocket messages are just strings or binary data. For real applications, you need a message protocol. JSON with a type field is the simplest approach:
// Define message types
const MessageType = {
CHAT: "chat",
JOIN: "join",
LEAVE: "leave",
TYPING: "typing",
ERROR: "error",
};
// Send a structured message
function sendMessage(ws, type, payload) {
ws.send(JSON.stringify({
type,
payload,
timestamp: Date.now(),
}));
}
// Handle messages by type
function handleMessage(ws, raw) {
const { type, payload } = JSON.parse(raw);
switch (type) {
case MessageType.CHAT:
broadcastChat(ws, payload);
break;
case MessageType.TYPING:
broadcastTypingIndicator(ws, payload);
break;
default:
sendMessage(ws, MessageType.ERROR, {
message: Unknown message type: ${type},
});
}
}
For higher-performance use cases, consider binary formats like Protocol Buffers or MessagePack instead of JSON. They're smaller on the wire and faster to parse, but add serialization complexity.
Scaling WebSocket Servers
A single WebSocket server can handle tens of thousands of concurrent connections. But when you need multiple server instances (for redundancy or capacity), you hit a problem: clients connected to Server A can't receive messages from Server B.
The solution is a pub/sub layer between servers:
Client A ↔ Server 1 ↔ Redis Pub/Sub ↔ Server 2 ↔ Client B
When Server 1 receives a message, it publishes it to Redis. Server 2 subscribes to the same channel and forwards it to its clients. This way, all servers share messages regardless of which server each client is connected to.
Libraries like Socket.IO have built-in Redis adapters for this. If you're using raw ws, you'll need to implement it yourself with a Redis client.
Common Mistakes
Not handling disconnection. Connections will drop. If your client doesn't reconnect and your server doesn't clean up dead connections, things break silently. Sending too much data. WebSockets are efficient, but sending 60 updates per second to a browser that only renders at 60fps is wasteful. Throttle or batch updates. Not validating messages. Never trust data from WebSocket clients. Validate and sanitize just like you would with HTTP requests. WebSocket messages can be crafted by anyone with a browser console. Using WebSockets for everything. Loading a user profile, fetching a list of products, submitting a form -- these are all HTTP requests. Don't shove everything through a WebSocket connection just because you have one open. Ignoring authentication. WebSocket connections need authentication too. Common approach: authenticate via HTTP first (get a token), then pass the token in the WebSocket connection URL or first message.// Client: connect with auth token
const ws = new WebSocket(ws://localhost:8080?token=${authToken});
// Server: verify on connection
wss.on("connection", (ws, req) => {
const url = new URL(req.url, "http://localhost");
const token = url.searchParams.get("token");
if (!verifyToken(token)) {
ws.close(4001, "Unauthorized");
return;
}
// Connection authenticated, proceed
});
Socket.IO: The High-Level Alternative
If managing raw WebSockets feels like too much plumbing, Socket.IO adds conveniences: automatic reconnection, rooms/namespaces, fallback to long polling, and acknowledgements. The tradeoff is that it's a custom protocol -- a Socket.IO client can't connect to a raw WebSocket server and vice versa.
// Server
import { Server } from "socket.io";
const io = new Server(3000);
io.on("connection", (socket) => {
socket.join("general");
socket.on("chat", (message) => {
io.to("general").emit("chat", {
user: socket.id,
text: message,
});
});
});
// Client
import { io } from "socket.io-client";
const socket = io("http://localhost:3000");
socket.on("chat", (data) => console.log(data));
socket.emit("chat", "Hello everyone!");
Use raw WebSockets when you want full control and minimal overhead. Use Socket.IO when you want convenience and don't mind the abstraction.
If you want to practice building real-time features and get comfortable with the event-driven patterns that WebSockets rely on, CodeUp can help you build the JavaScript and backend fundamentals that make this kind of development feel natural.