Most web apps work fine with regular HTTP requests. Client asks for something, server responds, done. But sometimes you need the server to push data to the client without being asked. Live notifications, chat messages, real-time dashboards, collaborative editing. That’s where WebSockets come in.
I avoided WebSockets for a long time because they seemed complicated. Persistent connections, heartbeats, reconnection logic, all stuff I didn’t want to deal with. But once I actually built something with them, I realized the basics are surprisingly simple.
Here’s how I set up WebSockets in Node.js, starting from scratch.
What WebSockets actually are
A regular HTTP request is one-and-done. The client sends a request, the server responds, and the connection closes. If the client wants new data, it has to ask again.
A WebSocket starts as an HTTP request but then “upgrades” to a persistent connection. Once open, both the client and server can send messages to each other at any time. No polling, no repeated requests.
That’s it. It’s just a two-way pipe between the client and the server.
Setting up a basic WebSocket server
I use the ws library. It’s the most popular WebSocket library for Node.js, it’s fast, and it doesn’t try to do too much.
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws: WebSocket) => {
console.log("Client connected");
ws.on("message", (data: Buffer) => {
const message = data.toString();
console.log("Received:", message);
// echo it back
ws.send(`You said: ${message}`);
});
ws.on("close", () => {
console.log("Client disconnected");
});
});
console.log("WebSocket server running on ws://localhost:8080");
That’s a working WebSocket server. A client connects, sends a message, and gets a response. Nothing fancy.
Connecting from the browser
The browser has WebSocket support built in. No library needed:
const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => {
console.log("Connected");
ws.send("Hello from the browser");
};
ws.onmessage = (event) => {
console.log("Server says:", event.data);
};
ws.onclose = () => {
console.log("Disconnected");
};
Open the browser console, paste this in, and you’ve got a WebSocket connection. That’s how simple the client side is.
Broadcasting to all clients
The echo server is nice but the real power is broadcasting. When one client sends a message, every connected client receives it. This is the foundation of chat apps, live feeds, and real-time dashboards.
wss.on("connection", (ws: WebSocket) => {
ws.on("message", (data: Buffer) => {
const message = data.toString();
// send to every connected client
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
});
wss.clients is a Set of all connected WebSocket instances. Loop through them, check if they’re still open, and send the message. That’s broadcasting.
Structured messages with JSON
In real apps you’ll want to send different types of messages. Don’t just send raw strings. Use JSON with a type field:
// server
ws.on("message", (data: Buffer) => {
const message = JSON.parse(data.toString());
switch (message.type) {
case "chat":
broadcast({ type: "chat", user: message.user, text: message.text });
break;
case "typing":
broadcast({ type: "typing", user: message.user });
break;
case "join":
broadcast({ type: "join", user: message.user });
break;
}
});
function broadcast(data: Record<string, unknown>) {
const payload = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
// client
ws.send(JSON.stringify({ type: "chat", user: "Baransel", text: "Hey!" }));
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "chat") {
displayMessage(message.user, message.text);
} else if (message.type === "typing") {
showTypingIndicator(message.user);
}
};
The type field acts as a router for your messages. Clean and easy to extend.
Running alongside Express
Most of the time you don’t want a standalone WebSocket server. You want it running alongside your existing Express API.
import express from "express";
import { createServer } from "http";
import { WebSocketServer, WebSocket } from "ws";
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
wss.on("connection", (ws: WebSocket) => {
ws.on("message", (data: Buffer) => {
// handle WebSocket messages
});
});
server.listen(3000, () => {
console.log("Server running on port 3000");
});
The key is creating an HTTP server from Express and passing it to WebSocketServer. Both HTTP and WebSocket connections go through the same port. API calls go to Express, WebSocket connections go to ws.
Handling disconnections and heartbeats
WebSocket connections can drop silently. The client might lose internet, close the laptop, or the connection might just die. Without heartbeats, your server thinks the client is still connected.
wss.on("connection", (ws: WebSocket & { isAlive?: boolean }) => {
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("close", () => {
console.log("Client disconnected");
});
});
// check every 30 seconds
const interval = setInterval(() => {
wss.clients.forEach((ws: WebSocket & { isAlive?: boolean }) => {
if (!ws.isAlive) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on("close", () => {
clearInterval(interval);
});
Every 30 seconds, the server pings all clients. If a client doesn’t respond with a pong before the next ping, it’s considered dead and gets terminated. This keeps your client list clean and prevents memory leaks from zombie connections.
Client-side reconnection
Connections will drop. Your client needs to handle that gracefully:
function createSocket(url: string) {
let ws: WebSocket;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => console.log("Connected");
ws.onmessage = (event) => {
// handle messages
};
ws.onclose = () => {
console.log("Disconnected, reconnecting in 3s...");
setTimeout(connect, 3000);
};
}
connect();
}
createSocket("ws://localhost:3000");
When the connection drops, wait 3 seconds and try again. For production you’d want exponential backoff (3s, 6s, 12s, etc.) so you don’t hammer the server. But this gets the job done for most cases.
WebSockets vs SSE vs polling
I get asked this a lot. Here’s how I decide:
Use WebSockets when you need two-way communication. Chat, gaming, collaborative tools. Both client and server need to send messages freely.
Use Server-Sent Events (SSE) when only the server needs to push data. Live notifications, dashboards, activity feeds. Simpler than WebSockets and works over regular HTTP.
Use polling when real-time isn’t critical. Checking for new emails every 30 seconds, updating a feed periodically. The simplest option and sometimes that’s enough.
I start with polling, move to SSE if I need server push, and only use WebSockets when I genuinely need bidirectional communication. Most apps don’t need WebSockets. But when they do, nothing else works as well.
Keep it simple
WebSockets aren’t as scary as they seem. A basic setup is 20 lines of code. Add JSON message types, heartbeats, and reconnection logic and you’ve got a solid real-time system.
The hard part isn’t the WebSocket itself. It’s managing state across multiple connected clients and handling edge cases like reconnections and message ordering. Start small, get the basics working, and add complexity only when you actually need it.