Persistent WebSocket connections: a complete guide
WebSockets provide a persistent, full-duplex communication channel between client and server. Unlike traditional HTTP requests that close after each response, a persistent WebSocket connection stays open after the initial handshake, making real-time two-way messaging practical for chat applications, live dashboards, collaborative tools, and real-time gaming. For complementary async patterns, see the Promises in Depth guide.
How WebSockets differ from HTTP
HTTP follows a request-response pattern. The client asks, the server answers, and the connection closes. Each request requires a new TCP handshake, adding latency for repeated communication:
// HTTP: New connection for each request
async function fetchUpdates() {
const response = await fetch("/api/notifications");
const data = await response.json();
return data;
}
The HTTP approach works for polling but forces the client to ask repeatedly even when nothing has changed. WebSockets solve this by upgrading the initial HTTP request into a persistent TCP connection. After the upgrade, both sides can send data at any time, the server pushes updates as they happen, and the client responds without waiting for a new request cycle. The code below opens a socket, registers event handlers for incoming data, and sends a message back to the server:
// WebSocket: Persistent connection, bidirectional
const ws = new WebSocket("wss://example.com/socket");
ws.onopen = () => {
console.log("Connected to server");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
ws.send(JSON.stringify({ type: "hello", message: "From client" }));
The wss:// protocol indicates a WebSocket connection over TLS. Always use this in production.
The WebSocket lifecycle
A WebSocket connection goes through distinct states:
- Connecting: The initial handshake is in progress
- Open: The connection is established and ready for communication
- Closing: The connection is being closed
- Closed: The connection has been closed
You can check the current state via the readyState property:
const ws = new WebSocket("wss://example.com/socket");
console.log(ws.readyState); // 0 = CONNECTING
ws.onopen = () => {
console.log(ws.readyState); // 1 = OPEN
};
ws.onclose = () => {
console.log(ws.readyState); // 3 = CLOSED
};
// Constants for readability
const State = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
};
The lifecycle states are simple to check, but you rarely inspect readyState directly in client code. Instead, you register event handlers for the transitions you care about — onopen runs when the connection is ready, onmessage fires for each incoming frame, and onclose gives you a chance to clean up or reconnect. The wrapper below groups these handlers into a single factory function that returns the socket, making it easy to create multiple connections with the same configuration:
Client-Side Implementation
Creating a Connection
function connectWebSocket(url) {
const ws = new WebSocket(url);
ws.onopen = function() {
console.log("WebSocket open");
};
ws.onmessage = function(event) {
// Handle incoming message
const message = JSON.parse(event.data);
handleMessage(message);
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
};
ws.onclose = function(event) {
console.log("WebSocket closed:", event.code, event.reason);
// Optionally attempt reconnection
};
return ws;
}
const socket = connectWebSocket("wss://api.example.com/ws");
Once the socket is open, you can send data as plain text, binary ArrayBuffer views, or Blob objects. The WebSocket API accepts all three formats without extra encoding — text messages are typically JSON strings, while binary payloads work well for typed numeric data or file transfers. Choose the format that matches what the server expects:
Sending Messages
// Send text
socket.send(JSON.stringify({ action: "subscribe", channel: "updates" }));
// Send binary (ArrayBuffer)
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setFloat64(0, 3.14159);
socket.send(buffer);
// Send binary (Blob)
const blob = new Blob([JSON.stringify({ type: "file", data: "..." })],
{ type: "application/json" });
socket.send(blob);
Sending is straightforward, but WebSocket connections do not survive network interruptions on their own. When the browser tab sleeps, the Wi-Fi drops, or the server restarts, the socket closes and your app stops receiving data. A reconnection wrapper restores the connection automatically with exponential backoff — each retry waits longer than the last, which prevents the server from being swamped by simultaneous reconnect attempts from every client that just lost its socket:
Handling Reconnection
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
this.reconnectDelay = options.reconnectDelay || 1000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
if (this.onopen) this.onopen();
};
this.ws.onmessage = (event) => {
if (this.onmessage) this.onmessage(event);
};
this.ws.onclose = (event) => {
if (this.onclose) this.onclose(event);
this.attemptReconnect();
};
this.ws.onerror = (error) => {
if (this.onerror) this.onerror(error);
};
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
setTimeout(() => this.connect(), delay);
}
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
}
The client-side wrapper handles reconnection gracefully. On the server, you need to understand the protocol layer first — the WebSocket handshake upgrades a standard HTTP connection, and the server must reply with a 101 status to complete the switch. The raw HTTP exchange below shows the Upgrade and Connection headers that trigger the protocol change:
Server-Side Considerations
Protocol Differences
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
In practice, you rarely write the raw handshake yourself. The ws library on npm handles the protocol upgrade, parses incoming frames, and exposes a simple event-driven API. Each new connection triggers a callback where you can register message handlers, set up periodic pings, and track per-client state for the lifetime of the socket:
Node.js WebSocket server
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
const clientIp = req.socket.remoteAddress;
console.log("Client connected:", clientIp);
ws.on("message", (message) => {
// Parse and handle incoming message
const data = JSON.parse(message);
if (data.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
}
});
ws.on("close", () => {
console.log("Client disconnected");
});
// Send periodic updates
const interval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "update",
time: new Date().toISOString()
}));
}
}, 5000);
ws.on("close", () => clearInterval(interval));
});
The server above handles one client at a time — it receives a message, processes it, and sends a reply. For chat rooms, dashboards, and multiplayer games, you need to fan out messages across every connected socket. The wss.clients set holds all active connections, and iterating over it lets you broadcast a single incoming message to everyone except the sender if needed:
Broadcasting to all clients
wss.on("connection", (ws) => {
ws.on("message", (message) => {
// Broadcast to all connected clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
});
Security Considerations
Validating Origins
Always validate the origin header on the server to prevent cross-site WebSocket hijacking:
Message Design
WebSocket messages work best when they have a clear shape and a clear job. A small type field plus a payload object is usually enough for most apps. That pattern lets the client route incoming events without guessing, and it lets the server evolve the protocol without breaking every consumer at once. It is also helpful to keep messages small. Large payloads are slower to serialize, slower to send, and harder to retry if the connection drops in the middle.
When you design a protocol, think about the life of a message after it leaves the sender. Does it need acknowledgement, or is it a one-way update? Can the receiver ignore unknown message types safely? Should older clients still understand the payload if a field is added later? These questions are worth answering before the first line of code is written. A little protocol planning up front saves a lot of churn once multiple screens or services depend on the same socket.
Connection Handling
The browser can close a WebSocket for normal reasons, not just failures. A tab may navigate away, the network may change, or the server may end the session. Code that treats every close event as an error becomes noisy fast. It is better to decide which closes should trigger a reconnect and which should simply end the session. That decision depends on whether the user expects a long-lived connection or a temporary one tied to a single screen.
Backoff is important when reconnecting. If many clients try again at once, the server can get hammered during an outage. A short delay on the first attempt, then a longer delay after each failure, gives the server room to recover. It also helps to keep unsent messages in a queue only when they really matter. Some updates are stale the moment they fail, while others should be retried once the socket returns. Treat those cases differently so the app stays responsive.
Choosing the right tool
WebSockets are a great fit when both sides need to talk often and the connection stays active for a while. They are less useful when you only need a single request and response, or when the server can simply push a few events through another channel. If the app mostly asks for snapshots on demand, plain HTTP may be simpler. If the app needs live presence, chat, collaborative editing, or dashboard updates, the persistent socket starts to pay off quickly.
It is also worth remembering that the protocol is only one part of the system. Authentication, authorization, and error handling still matter. A socket that stays open for a long time should still know who owns it, what messages are allowed, and how the app recovers if the other side disappears. When those basics are in place, WebSockets become a clean fit for real-time features instead of a source of extra complexity.
const ALLOWED_ORIGINS = ["https://app.example.com", "https://admin.example.com"];
wss.on("connection", (ws, req) => {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.includes(origin)) {
ws.close(1008, "Origin not allowed");
return;
}
// Proceed with connection
});
Validating the origin stops cross-site WebSocket hijacking, but it does not identify the user. WebSockets do not carry cookies or authentication headers the way HTTP requests do. Instead, the client sends a token immediately after the connection opens, and the server validates it before processing any other messages. This two-step flow keeps the handshake fast while still securing the socket:
Authentication after connect
// Client: Send token after connect
ws.onopen = () => {
ws.send(JSON.stringify({
type: "auth",
token: localStorage.getItem("authToken")
}));
};
// Server: Validate on each message
ws.on("message", (message) => {
const data = JSON.parse(message);
if (data.type === "auth") {
const user = validateToken(data.token);
if (!user) {
ws.close(4001, "Invalid token");
return;
}
ws.user = user;
}
});
Authentication confirms who owns the connection. Rate limiting protects the server from clients that send too many messages too quickly — whether by accident, a buggy loop, or deliberate abuse. The approach below tracks message counts in a one-second sliding window and closes the socket if the limit is exceeded. Adjust the threshold to match the traffic patterns your application expects:
Rate Limiting
wss.on("connection", (ws) => {
let messageCount = 0;
const resetTime = Date.now() + 1000;
ws.on("message", (message) => {
const now = Date.now();
if (now > resetTime) {
messageCount = 0;
}
messageCount++;
if (messageCount > 100) {
ws.close(4000, "Rate limit exceeded");
}
});
});
Rate limiting guards against floods. A different problem is silent disconnection: the socket stays open from the client’s perspective, but the server has gone away or a network middlebox has dropped the route. A ping-pong pattern sends a heartbeat at a fixed interval and expects a reply. If the reply does not return within a timeout, the client knows to tear down the socket and reconnect:
Common Patterns
Heartbeat / Ping-Pong
// Client: Send ping every 30 seconds
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "pong") {
console.log("Server responsive");
}
};
// Server: Respond to pings
ws.on("message", (message) => {
const data = JSON.parse(message);
if (data.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
});
Ping-pong keeps the transport healthy. The next pattern addresses user experience: optimistic updates. When a user sends a chat message, the UI should reflect it immediately rather than waiting for a server round trip. The client adds the message to the screen with a “sending” marker, fires it over the socket, and updates the status when the server confirms receipt or reports an error. If the socket is down, the message queues until reconnection:
Optimistic Updates
function sendMessage(text) {
// Optimistically add to UI
addMessageToUI({ text, status: "sending" });
// Send to server
ws.send(JSON.stringify({ type: "message", text }));
// Server confirms or corrects
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "message-ack") {
updateMessageStatus(data.id, "sent");
} else if (data.type === "message-error") {
updateMessageStatus(data.id, "error", data.reason);
}
};
}
When to use WebSockets
WebSockets excel for:
- Real-time chat applications
- Live notifications and feeds
- Collaborative editing (like Google Docs)
- Multiplayer games
- Live dashboards with frequent updates
Consider HTTP long-polling or Server-Sent Events when:
- Communication is mostly one-directional (server → client)
- Updates are infrequent
- You need maximum compatibility with proxies and firewalls
- Simple request-response workflows suffice
Keep the protocol small
The healthiest WebSocket apps keep the message format easy to explain. Each message should have a clear type, a small payload, and a predictable response on the other side. When the protocol stays narrow, it is much simpler to trace a problem from the browser console to the server log and back again. That matters because real-time features can fail in the middle of a session, not just on page load.
It also helps to treat reconnects as part of the design instead of a rescue trick. A socket may close because the network changed, the tab slept, or the server restarted. If the client already knows how to rejoin the conversation, the feature feels steady instead of fragile.
Keeping the feature honest
The best real-time features make their assumptions visible. If the connection is required, the UI should say so. If the app can keep working while disconnected, the code should show how it recovers. That honesty is helpful for users and for maintainers because it keeps the behavior predictable. A socket is just a transport, but the feature around it still needs a clear contract.
That contract is easier to maintain when the client and server share the same idea of message types and connection state. The more those ideas drift apart, the harder the system is to debug. A simple protocol, clear status handling, and a deliberate reconnect plan go a long way toward keeping the feature calm under real network conditions.
When you revisit the code later, the cleanest sign is that you can tell what the connection is for without tracing through several layers of helper functions. That kind of clarity is what keeps a live feature usable over time.
See Also
- Fetch and XHR: learn about traditional HTTP request patterns
- Service Workers and Caching: understand offline-first architectures
- Promises in Depth: master asynchronous JavaScript patterns