Most of the web still runs on a simple idea: the client asks, the server answers. That model works great for loading a page or fetching a list of products, but it starts to creak the moment your app needs to feel alive.
Think about a chat app where messages should appear the instant they're sent. A live dashboard that updates as new data comes in. A multiplayer game where every player's move needs to reach everyone else immediately. A food delivery app that shows the driver moving on the map in real time. None of these fit neatly into "client asks, server answers" the server needs to be able to speak first.
That's exactly the gap WebSockets fill.
In this guide, you'll learn what WebSockets are, how they actually work under the hood, and how they compare to alternatives like polling and Server-Sent Events. Then you'll get hands-on: we'll build a WebSocket server in Node.js, connect to it from the browser, turn it into a working chat app, and layer on the things production apps actually need structured messages, reconnection logic, heartbeats, authentication, and scaling.
By the end, you'll know not just how to use WebSockets, but when they're the right tool in the first place.
What Are WebSockets?
A WebSocket is a communication protocol that keeps a single connection open between a client and a server, allowing both sides to send messages to each other at any time.
That's the key difference from regular HTTP. Once the connection is established:
- It stays open there's no need to reconnect for every new piece of data.
- It's bidirectional - the client can send to the server, and the server can send to the client, without either side having to "ask" first.
- The server can push updates the moment something happens, instead of waiting for the client to request them.
You'll recognize WebSocket URLs by their scheme:
-
ws://- unencrypted, similar tohttp:// -
wss://- encrypted over TLS, similar tohttps://(always use this in production)
In short: HTTP is a conversation where you have to keep asking "anything new?" WebSockets are a phone call that stays connected until either side hangs up.
Why Do We Need WebSockets?
To understand the value of WebSockets, it helps to see what life looks like without them.
Imagine building a live chat feature using plain HTTP requests. The client has no way of knowing when a new message arrives, so it has to keep asking:
Client requests new messages
↓
Server returns available messages
↓
Client waits
↓
Client sends another request
This works, but it's wasteful. Most of those requests come back empty. You're paying the cost of a full HTTP request/response cycle headers, connection setup, latency just to hear "nothing new" most of the time. Make the polling interval short enough to feel "real-time," and you've multiplied your server load for no reason. Make it longer, and messages feel delayed.
Now compare that to a chat app built on WebSockets:
Client connects once
↓
Connection remains open
↓
Server sends messages immediately
The client opens one connection and just... waits. When a new message arrives, the server pushes it down the same connection instantly. No repeated requests, no wasted round trips, no artificial delay. This is the core reason WebSockets exist: they let the server initiate communication instead of only ever responding.
How WebSockets Work
A WebSocket connection doesn't start out as a WebSocket it starts as a regular HTTP request that asks to be "upgraded."
HTTP Upgrade Request
↓
WebSocket Handshake
↓
Persistent Connection
↓
Two-Way Message Exchange
↓
Connection Closed
Here's what happens at each stage:
- Initial HTTP handshake - the client sends a normal-looking HTTP request to the server, but with special headers.
-
Upgrade: websocketrequest - this header (along withConnection: Upgrade) tells the server "I'd like to switch this connection from HTTP to the WebSocket protocol." -
Server acceptance - if the server supports WebSockets and agrees, it responds with a
101 Switching Protocolsstatus. From this point on, the underlying TCP connection is no longer speaking HTTP - it's speaking the WebSocket protocol. - Message exchange - both sides can now send messages (called "frames") to each other at any time, in any order, without needing to open new connections.
- Closing the connection - either the client or the server can close the connection when it's no longer needed, using a proper close handshake so both sides know the conversation is over.
The clever part is that this whole handshake rides on top of standard HTTP, which is why WebSockets work through most existing infrastructure (proxies, load balancers, etc.) without special configuration - the connection just "upgrades" itself in place.
HTTP vs WebSockets
| Feature | HTTP | WebSockets |
|---|---|---|
| Communication | Request-response | Bidirectional |
| Connection | Usually short-lived | Persistent |
| Server-initiated updates | Not directly | Supported |
| Real-time performance | Requires polling | Native |
| Best for | APIs and page requests | Chat, games and live updates |
| Infrastructure complexity | Lower | Higher |
It's worth being clear about one thing: WebSockets don't replace REST APIs. They solve a different problem. Most real applications use both - REST (or GraphQL) for standard operations like fetching a user profile, submitting a form, or loading a product catalog, and WebSockets specifically for the parts of the app that need to feel live. You wouldn't build a WebSocket endpoint just to fetch a user's settings once on page load; a normal HTTP request is simpler and works just fine there.
WebSockets vs Polling vs Server-Sent Events
WebSockets are one option among a few for building real-time features. Here's how they stack up against the alternatives.
Polling
The client repeatedly asks the server "anything new?" on a timer. It's simple to implement and doesn't require any special server setup, but it can generate a lot of unnecessary traffic especially if updates are infrequent but you're polling every few seconds "just in case." It's a reasonable choice when real-time-ish is good enough and updates genuinely don't happen often.
Server-Sent Events (SSE)
With SSE, the server can push updates to the client over a single long-lived HTTP connection but only in one direction: server to client. The client can't send messages back over that same connection. This makes SSE a great fit for things like live news feeds, notification streams, or progress updates, where the client mostly just needs to receive.
WebSockets
WebSockets support true two-way communication the client and server can both send messages at any time over the same connection. This makes them the right choice when the client needs to actively participate, not just listen: chat apps, multiplayer games, collaborative editors, and similar interactive features.
| Method | Direction | Connection | Best Use Case |
|---|---|---|---|
| Polling | Client to server | Repeated requests | Occasional updates |
| SSE | Server to client | Persistent | Live feeds |
| WebSockets | Two-way | Persistent | Chat and collaboration |
Setting Up the Node.js Project
Let's get hands-on. First, create a new project:
mkdir websocket-demo
cd websocket-demo
npm init -y
Then install the ws package:
npm install ws
You might be wondering why we're starting with ws instead of the more popular Socket.IO. It's intentional: ws is a minimal, lightweight implementation of the raw WebSocket protocol with no extra abstractions. Building directly on top of it means you'll actually understand what's happening at the protocol level the handshake, the events, the message flow before reaching for a higher-level library that hides those details. Once you understand the fundamentals, deciding whether Socket.IO is worth its added convenience becomes a much easier call (more on that comparison later).
Practical Example 1: Create a Basic WebSocket Server
Let's build a simple server that can accept connections, receive messages, respond to them, and clean up after a client disconnects.
const WebSocket = require("ws");
const server = new WebSocket.Server({
port: 8080,
});
server.on("connection", (socket) => {
console.log("Client connected");
socket.send("Welcome to the WebSocket server!");
socket.on("message", (message) => {
console.log("Received:", message.toString());
socket.send(`Server received: ${message}`);
});
socket.on("close", () => {
console.log("Client disconnected");
});
socket.on("error", (error) => {
console.error("WebSocket error:", error);
});
});
console.log("WebSocket server is running on ws://localhost:8080");
Let's break down what's going on with each event:
-
connection- fires once per client, the moment their WebSocket handshake succeeds. Thesocketpassed in represents that specific client's connection. -
message- fires every time that client sends data. Note thatmessagearrives as aBuffer, so we call.toString()to read it as text. -
close- fires when the client disconnects, whether intentionally (they closed the tab) or unintentionally (network drop). -
error- fires when something goes wrong with that connection. Always handle this - an unhandlederrorevent on a socket can crash your Node process.
Run it with node server.js, and you've got a working WebSocket server listening on port 8080.
Practical Example 2: Connect from the Browser
Now let's build a client that connects to that server. Browsers have the WebSocket API built in no libraries required.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>WebSocket Demo</title>
</head>
<body>
<h1>WebSocket Demo</h1>
<input id="messageInput" placeholder="Enter a message" />
<button id="sendButton">Send</button>
<div id="messages"></div>
<script>
const socket = new WebSocket("ws://localhost:8080");
const messages = document.getElementById("messages");
socket.addEventListener("open", () => {
messages.innerHTML += "<p>Connected to server</p>";
});
socket.addEventListener("message", (event) => {
messages.innerHTML += `<p>${event.data}</p>`;
});
socket.addEventListener("close", () => {
messages.innerHTML += "<p>Connection closed</p>";
});
socket.addEventListener("error", () => {
messages.innerHTML += "<p>Connection error</p>";
});
document
.getElementById("sendButton")
.addEventListener("click", () => {
const input = document.getElementById("messageInput");
const message = input.value.trim();
if (message && socket.readyState === WebSocket.OPEN) {
socket.send(message);
input.value = "";
}
});
</script>
</body>
</html>
A few things worth calling out:
-
Creating the connection -
new WebSocket(url)immediately starts the handshake. You don't need to call.connect()separately. -
Listening for incoming messages - the
messageevent fires any time the server sends data to this client. -
Checking
socket.readyState- before sending, we confirm the socket is actuallyOPEN. Trying to send on a connection that isn't open yet (or has already closed) will throw an error. -
Sending messages to the server -
socket.send(message)pushes data down the wire; on the server side, this triggers that client'smessageevent.
Open the HTML file in a browser (with the server running), type a message, and you'll see the server echo it back.
Understanding WebSocket Connection States
Every WebSocket connection is always in one of four states:
WebSocket.CONNECTING // 0 - handshake in progress
WebSocket.OPEN // 1 - ready to send and receive
WebSocket.CLOSING // 2 - closing handshake in progress
WebSocket.CLOSED // 3 - fully closed
Checking readyState before sending a message matters more than it might seem. If you call .send() while the socket is still CONNECTING, you'll get a runtime error. If you call it after the socket has moved to CLOSING or CLOSED, the message silently fails to send (or throws, depending on the environment). Get in the habit of guarding every .send() call with a state check it prevents a whole category of "why didn't this message go through" bugs.
Practical Example 3: Build a Simple Chat Application
Right now, our server only replies to the client that sent a message. For a chat app, every connected client needs to receive every message. That means broadcasting.
server.on("connection", (socket) => {
socket.on("message", (message) => {
const data = message.toString();
server.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
});
});
What changed:
-
server.clients- thewsserver keeps aSetof every currently connected client. We loop over it whenever a message comes in. - Broadcasting - instead of replying only to the sender, we send the message to every open connection, including (in this simple version) the sender itself.
- One-to-many communication - this is the pattern behind chat rooms, live comment sections, and shared dashboards: one message in, many recipients out.
This gets a basic chat working, but sending raw text strings has an obvious limitation - how would a client tell the difference between a chat message, a "user joined" notice, and a system alert? That's where structured messages come in.
Sending Structured JSON Messages
Instead of sending plain strings, real applications send structured events - typically JSON objects with a type field that tells the receiver what kind of message it's looking at.
const payload = {
type: "chat_message",
username: "Shreya",
message: "Hello everyone!",
timestamp: new Date().toISOString(),
};
socket.send(JSON.stringify(payload));
On the receiving end, parse the JSON and branch based on type:
socket.addEventListener("message", (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "chat_message") {
console.log(`${data.username}: ${data.message}`);
}
} catch (error) {
console.error("Invalid message received");
}
});
Wrapping the parse in a try/catch matters you can't always guarantee that every message you receive is valid JSON (a malformed client, a bug, or in a worse case, a malicious payload), so failing gracefully instead of crashing is important.
Once you adopt a type field, your protocol becomes extensible. A typical app might define event types like:
chat_messageuser_joineduser_leftnotificationorder_updated
Each type can carry whatever data makes sense for it, and both client and server just need to agree on the shape of each event type.
Handling Disconnections and Reconnection
WebSocket connections don't always close gracefully. They can drop because of:
- Network interruptions
- Server restarts or deployments
- A laptop going to sleep
- Switching between Wi-Fi and mobile networks
- Proxy or load balancer timeouts
Because of this, any real client needs a reconnection strategy. Here's a simple one:
function connect() {
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => {
console.log("Connected");
});
socket.addEventListener("close", () => {
console.log("Disconnected. Reconnecting...");
setTimeout(connect, 3000);
});
}
connect();
This works for a demo, but it has a flaw: if the server is actually down, every client will hammer it with a reconnect attempt every 3 seconds, which can make an outage worse. Production applications should use exponential backoff instead waiting progressively longer between attempts (1s, 2s, 4s, 8s...) up to some maximum, so reconnect traffic tapers off instead of piling on.
Detecting Dead Connections with Heartbeats
Here's a subtle problem: not every disconnect triggers a clean close event. If a client's laptop loses its Wi-Fi mid-session, or a mobile device drops signal in a tunnel, the underlying TCP connection can go dead without either side being formally notified. From the server's perspective, that client might still look "connected" indefinitely quietly consuming a slot and memory for a connection nobody's using anymore.
This is where heartbeats come in, using the WebSocket protocol's built-in ping/pong frames:
- The server periodically sends a
pingframe to each client. - A healthy client automatically responds with a
pongframe (this happens at the protocol level, no app code needed on the client for a compliant implementation). - If the server doesn't receive a
pongwithin a reasonable window, it assumes the connection is dead and terminates it.
A minimal heartbeat implementation in ws looks like this:
function heartbeat() {
this.isAlive = true;
}
server.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", heartbeat);
});
const interval = setInterval(() => {
server.clients.forEach((socket) => {
if (socket.isAlive === false) {
return socket.terminate();
}
socket.isAlive = false;
socket.ping();
});
}, 30000);
server.on("close", () => {
clearInterval(interval);
});
This pattern mark alive as false, ping, wait for pong to flip it back to true, and terminate anything still false on the next round is a standard way to keep your server's connection pool honest and free up resources tied up by dead clients.
Authentication and Authorization
It's easy to assume that if a WebSocket connection succeeded, the user is who they claim to be. That's not automatically true opening a connection doesn't mean anything about who opened it unless you explicitly check.
Some ground rules for handling this properly:
- Authenticate during the connection handshake, not after reject unauthenticated connections before they're ever fully established.
- Validate session cookies or tokens as part of that handshake, the same way you would for a protected HTTP endpoint.
- Check permissions for every sensitive event, not just at connection time. A user's permissions can be valid for some actions and not others.
-
Never trust a user ID sent inside the message body. If a client can just say
{ "userId": "123" }and you believe it, anyone can impersonate anyone. Identity should come from the authenticated session, not from client-supplied data. - Close unauthorized connections immediately rather than leaving them open and just ignoring their messages.
A common pattern is to pass a token as part of the connection URL:
const socket = new WebSocket(
`wss://example.com/socket?token=${accessToken}`
);
This works, but it's worth knowing the tradeoff: tokens in query parameters can end up in server logs, browser history, or proxy logs. Where your infrastructure supports it, secure cookies or dedicated authentication headers during the handshake are generally a safer place to carry sensitive tokens than a URL parameter.
WebSocket Security Best Practices
Beyond authentication, treat WebSocket traffic with the same seriousness you'd apply to any API:
- Use
wss://in production never plainws://outside of local development - Validate every incoming message, including its structure and types, not just its
typefield - Limit message size to prevent abuse or memory exhaustion
- Add rate limiting so a single client can't flood the server with messages
- Sanitize any user-generated content before broadcasting or rendering it
- Restrict allowed origins so only your own frontend can connect
- Authenticate users at connection time
- Authorize each individual action, not just the initial connection
- Avoid exposing internal server errors in messages sent back to clients
- Log suspicious activity for later review
The underlying principle: a WebSocket message deserves exactly the same scrutiny as a REST API request. The fact that it arrives over a persistent connection instead of a one-off HTTP call doesn't make it any more trustworthy.
Scaling WebSocket Applications
Scaling persistent connections is a genuinely different problem than scaling stateless REST APIs, and it's one of the first things that trips people up when moving from a demo to production.
With REST, any server instance can handle any request there's no memory of what happened before. With WebSockets, each client maintains one specific, ongoing connection to one specific server process. If User A is connected to Server 1 and User B is connected to Server 2, Server 1 has no way of directly reaching User B to deliver a message they're not on the same process, let alone the same machine.
Redis Pub/Sub is a common solution to this. Instead of each server trying to track every client across the whole fleet, servers publish events to Redis, and every instance subscribes to relevant channels:
Client A → WebSocket Server 1
↓
Redis Pub/Sub
↓
Client B ← WebSocket Server 2
When Server 1 needs to deliver a message to a client that happens to be connected to Server 2, it publishes the event to Redis; Server 2 picks it up and forwards it to its own local connection. Neither server needs to know where any given client actually lives.
Other pieces of the scaling puzzle worth knowing about:
- Load balancers need to be WebSocket-aware, since they're routing long-lived connections, not one-off requests.
- Sticky sessions may be needed if you're not using a pub/sub layer, so a client's messages keep landing on the same server instance they originally connected to.
- Shared authentication state (e.g., sessions in Redis rather than in-memory) so any server instance can validate any client.
- Message brokers (Redis, Kafka, NATS) for coordinating events across instances.
- Connection limits - know how many concurrent connections a single instance can realistically handle, and plan capacity accordingly.
- Horizontal scaling - adding more instances to handle more concurrent connections, which is only possible once the above pieces are in place.
WebSockets vs Socket.IO
These two get confused constantly, so it's worth being precise: WebSockets are a protocol; Socket.IO is a library built on top of it (with fallbacks).
WebSockets:
- A communication protocol, standardized and supported by all modern browsers
- Lightweight - no extra abstraction layer
- Leaves things like rooms, reconnection, and acknowledgements for you to build yourself
Socket.IO:
- A JavaScript library, not a protocol itself
- Adds conveniences like rooms, event acknowledgements, and automatic reconnection out of the box
- Offers an event-based API that feels closer to
EventEmitterthan raw message handling - Can fall back to HTTP long-polling in environments where WebSockets aren't available
- Adds some overhead and requires the Socket.IO client on the frontend (it isn't compatible with plain WebSocket clients)
If you need something simple, want full control, or are optimizing for minimal overhead, native WebSockets (via ws or similar) are often enough. If you want rooms, namespaces, guaranteed delivery semantics, and battle-tested reconnection handling without building it yourself, Socket.IO's extra abstraction can save meaningful development time the tradeoff being less control and a heavier dependency.
Real-World WebSocket Use Cases
WebSockets show up anywhere an app needs to feel alive rather than static:
- Live chat applications
- Real-time notifications
- Order and delivery tracking
- Multiplayer games
- Collaborative document editing
- Live sports scores
- Trading and financial dashboards
- Server monitoring tools
- Customer-support systems
- Live auction platforms
The common thread across all of these: state changes on the server that users need to see immediately, without refreshing or re-requesting.
When You Should Not Use WebSockets
It's tempting to reach for WebSockets because they feel modern, but they're not always the right call. Skip them when:
- Data changes only occasionally, so there's little to "push" in real time
- Communication is fundamentally request-response in nature
- Standard REST APIs already solve the problem cleanly
- Only server-to-client updates are needed, and SSE would be simpler
- Your infrastructure can't easily support persistent connections at your expected scale
- Real-time behavior wouldn't meaningfully improve the user experience
The underlying rule: WebSockets should solve a real requirement genuine two-way, low-latency communication not be added just because they sound impressive on a resume or in a tech stack list.
Common WebSocket Mistakes
A few pitfalls that show up again and again in real projects:
- Not handling reconnections at all
- Sending messages before the connection has actually opened
- Trusting client-provided data (like a
userIdin the payload) without verifying it server-side - Forgetting authentication entirely on the WebSocket layer
- Not checking user permissions for individual actions
- Sending unstructured, untyped messages that become impossible to extend later
- Ignoring dead connections instead of implementing heartbeats
- Broadcasting sensitive data to every connected client instead of the intended recipient
- Using WebSockets for standard CRUD operations that would be simpler as REST calls
- Failing to plan for multiple server instances until it becomes a production incident
Best Practices for Production
Pulling everything together, here's a practical checklist for taking a WebSocket feature from demo to production-ready:
- Use REST APIs for standard CRUD operations
- Use WebSockets only for genuinely real-time events
- Define consistent, typed message structures
- Add schema validation for incoming messages
- Implement heartbeat checks to catch dead connections
- Track active connections and their state
- Use structured logging (not just
console.log) - Monitor message volume and connection counts
- Handle graceful server shutdowns, notifying clients before disconnecting them
- Test connection failure scenarios deliberately, not just the happy path
Final Thoughts
WebSockets exist to solve one specific problem well: persistent, two-way communication between a client and a server. They're the right tool when an application genuinely needs to feel live chat, collaborative tools, live dashboards, multiplayer experiences and they complement REST APIs rather than replacing them.
But the protocol itself is only the starting point. A production-ready real-time feature also needs authentication, message validation, reconnection handling, heartbeats, and a scaling strategy once you're running more than one server instance. None of that is optional if you want something that holds up outside a demo.
The good news: none of it is particularly hard once you understand the fundamentals which is exactly what you've walked through here, from the handshake all the way to Redis pub/sub.
Native WebSockets provide lightweight, two-way communication, while Socket.IO adds features such as automatic reconnection, rooms, acknowledgements, and transport fallbacks. To understand which option better fits your project, read Socket.IO vs WebSockets: Key Differences and Use Cases
Top comments (0)