WebSockets โ When Polling Stops Being Enough
Why I switched from polling to WebSockets, the reconnection logic nobody warns you about, and what happens when you try to scale beyond one server.

Built a dashboard that polled an API every 2 seconds. The API hit a database. Every 2 seconds, from every connected client. On a quiet Monday with 15 users, that was 450 database queries per minute just for the dashboard. Manageable. Then the team grew, the dashboard got popular, and one morning I watched the database connection pool drain to zero while 200 people stared at stale data wondering why the page wasn't updating.
The fix was WebSockets. Not because WebSockets are automatically better than polling โ for many use cases, polling is perfectly fine. But for real-time data with many concurrent users, the poll-every-N-seconds approach creates load that scales linearly with user count, and eventually that line crosses the threshold of what your backend can handle.
Switching wasn't as straightforward as the tutorials made it look. Going to cover the WebSocket lifecycle, reconnection handling, and the scaling challenges that showed up once we moved past a single server.
What Polling Actually Costs
To understand why WebSockets help, it's worth understanding what happens during polling.
Client sends an HTTP request. Server processes it, queries the database, returns a response. Connection closes. Two seconds later, the same thing happens again. And again. And again. Each request is independent โ full HTTP headers, TCP handshake (unless keep-alive is active), authentication check, database query, response serialization.
For 200 users polling every 2 seconds:
- 100 requests per second hitting your server
- 100 database queries per second
- Constant TCP connection churn
- Each response includes HTTP headers (~500 bytes) even if the data hasn't changed
Most of those requests return the exact same data as the previous one. The dashboard might update once every 30 seconds in practice, but you're querying 15 times in that window per user just to find out nothing changed. Wasteful by design.
Long polling is the halfway solution โ client sends a request, server holds it open until there's new data or a timeout expires. Fewer requests, but the connection management gets complicated, you still have HTTP overhead on every response, and many load balancers and proxies have opinions about long-lived HTTP connections that you'll need to work around.
The WebSocket Handshake
WebSockets start as a regular HTTP request with an upgrade header:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server responds with a 101 status code (Switching Protocols), and from that point the connection is no longer HTTP. It's a persistent, full-duplex TCP connection. Both sides can send messages at any time without the request-response pattern of HTTP. No headers on each message. No new connections. The overhead per message drops to a few bytes of framing instead of hundreds of bytes of HTTP headers.
For the dashboard scenario: one connection per client, held open indefinitely. When data changes, the server pushes it to all connected clients instantly. No polling. No wasted queries. The database gets queried once when data changes, and the result gets broadcast.
Server-Side Implementation
Node.js with the ws library. Straightforward compared to most networking code:
import { WebSocketServer } from 'ws';
import http from 'http';
const server = http.createServer();
const wss = new WebSocketServer({ server });
// Track connected clients
const clients = new Set();
wss.on('connection', (ws, request) => {
console.log(`Client connected from ${request.socket.remoteAddress}`);
clients.add(ws);
// Send initial state
ws.send(JSON.stringify({
type: 'init',
data: getCurrentDashboardData()
}));
ws.on('message', (data) => {
const message = JSON.parse(data);
handleClientMessage(ws, message);
});
ws.on('close', (code, reason) => {
console.log(`Client disconnected: ${code} ${reason}`);
clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(ws);
});
});
// Broadcast to all connected clients
function broadcast(data) {
const payload = JSON.stringify(data);
for (const client of clients) {
if (client.readyState === 1) { // OPEN
client.send(payload);
}
}
}
server.listen(8080);
The readyState === 1 check before sending is important. Without it, trying to send to a client that's in the process of disconnecting throws an error. Connections don't disappear cleanly โ there's a window where the client has dropped but the server hasn't received the close event yet.
That clients Set is naive โ works fine for a few hundred connections on a single server. For anything larger, you probably need more structure. Room-based grouping (only broadcast to clients subscribed to specific data), connection metadata (who is this client, what permissions do they have), and health tracking (when did we last hear from this client).
Client-Side with Reconnection
The browser WebSocket API is simple. Too simple, actually โ it gives you an open/close/message/error event model with zero built-in reconnection logic. Connection drops because the user's WiFi blipped? You get a close event and that's it. No automatic retry. The client just sits there, disconnected, showing stale data.
Here's what a production-ready client connection looks like:
class WebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 15;
this.listeners = new Map();
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0; // Reset on successful connection
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const handlers = this.listeners.get(message.type) || [];
handlers.forEach(handler => handler(message.data));
};
this.ws.onclose = (event) => {
if (event.code === 1000) return; // Normal closure, don't reconnect
this.reconnect();
};
this.ws.onerror = () => {
// Error event is always followed by close event
// Reconnection happens in onclose handler
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
this.emit('connectionFailed');
return;
}
// Exponential backoff with jitter
const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
const jitter = baseDelay * 0.3 * Math.random();
const delay = baseDelay + jitter;
console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
on(type, handler) {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type).push(handler);
}
send(type, data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, data }));
}
}
}
The exponential backoff with jitter is the critical piece. Without it, if your server goes down and 500 clients disconnect simultaneously, all 500 try to reconnect at the same time one second later. The server comes back up and immediately gets hammered by 500 simultaneous connection attempts โ which might take it down again. Exponential backoff spreads out the retries over time. The jitter randomizes the exact timing so clients don't synchronize their retry attempts.
First retry: ~1 second. Second: ~2 seconds. Third: ~4 seconds. Capped at 30 seconds. The jitter adds up to 30% randomness to each delay. After 15 failed attempts, the client gives up and shows an error state. These numbers aren't magic โ adjust them for your use case based on what seems reasonable.
The Close Code That Nobody Checks
WebSocket close events include a numeric code. Most tutorials ignore it. In practice, different codes mean different things and your reconnection logic should care:
ws.onclose = (event) => {
switch (event.code) {
case 1000: // Normal closure
// User navigated away or server shut down cleanly
break;
case 1001: // Going away
// Server shutting down or browser navigating
this.reconnect();
break;
case 1006: // Abnormal closure
// Connection lost without close frame โ network issue
this.reconnect();
break;
case 1008: // Policy violation
// Server rejected the connection โ don't retry
console.error('Connection rejected by server');
break;
case 1011: // Internal server error
this.reconnect(); // But maybe with a longer delay
break;
default:
this.reconnect();
}
};
Code 1006 is the most common one you'll see in practice. It means the connection was lost without a proper WebSocket close handshake โ the TCP connection just disappeared. Network interruption, server crash, load balancer timeout. Always reconnect on 1006. Code 1008 (policy violation) means the server intentionally rejected you โ maybe your auth token expired. Reconnecting without re-authenticating will just get rejected again.
Heartbeats and Dead Connection Detection
TCP connections can go silent without either side knowing the other is gone. User closes their laptop lid โ TCP connection is still "open" from the server's perspective. No close event arrives. The server keeps that connection in memory, sends messages to it, and the messages disappear into the void.
Heartbeats solve this. The server periodically sends a ping, the client responds with a pong. If the pong doesn't arrive within a timeout, the server assumes the connection is dead and cleans it up:
// Server-side heartbeat
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds to respond
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
// No pong received since last ping โ terminate
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
The ws library handles ping/pong frames at the protocol level. The browser WebSocket API doesn't expose ping/pong directly, but the browser responds to server pings automatically. If you need application-level heartbeats (useful when proxies strip WebSocket control frames), implement them as regular messages:
// Server sends
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
// Client responds
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', timestamp: message.timestamp }));
return;
}
// Handle other messages
};
Without heartbeats, zombie connections accumulate over time. On a server running for days, the number of dead connections can grow large enough to exhaust memory or file descriptors, from what I've seen. Learned this when a server ran fine for a week, then started refusing new connections. Memory was full of WebSocket objects for clients that had been gone for days.
Scaling Beyond One Server
Here's where it gets complicated. One server, one WebSocket server instance, all clients connected to the same process. Broadcasting is trivial โ iterate over the client set and send.
Add a second server behind a load balancer. Now half your clients are connected to server A and half to server B. When data changes, server A broadcasts to its clients. Server B's clients see nothing. They're connected to a different process that doesn't know about the update.
The standard solution is a pub/sub layer. Redis is the common choice:
import { createClient } from 'redis';
const publisher = createClient({ url: 'redis://redis:6379' });
const subscriber = createClient({ url: 'redis://redis:6379' });
await publisher.connect();
await subscriber.connect();
// When data changes, publish to Redis instead of broadcasting directly
async function publishUpdate(channel, data) {
await publisher.publish(channel, JSON.stringify(data));
}
// Each server subscribes and broadcasts to its own clients
await subscriber.subscribe('dashboard-updates', (message) => {
const data = JSON.parse(message);
broadcast(data); // Broadcast to THIS server's connected clients
});
Every WebSocket server subscribes to the same Redis channel. When any server needs to send an update, it publishes to Redis. Redis forwards the message to all subscribed servers. Each server broadcasts to its own clients. I went deeper on Redis patterns and pub/sub mechanics in my Redis caching patterns post.
This adds a dependency (Redis) and a failure mode (what if Redis goes down). But it's the most battle-tested approach for horizontal WebSocket scaling.
Load Balancer Configuration
Sticky sessions. This tripped me up for a while. WebSocket connections are long-lived. The initial HTTP upgrade request goes through the load balancer to a specific backend server. From that point on, all WebSocket frames need to reach the same server. If the load balancer routes subsequent frames to a different server, the connection breaks.
Nginx configuration for WebSocket proxying:
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server backend1:8080;
server backend2:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s; # 24 hours
proxy_send_timeout 86400s;
}
}
The proxy_http_version 1.1 and the Upgrade/Connection headers are required for the WebSocket handshake to pass through the proxy. Without them, Nginx treats it as a regular HTTP request and the upgrade fails. The proxy_read_timeout is set high because WebSocket connections are idle for long periods between messages โ the default 60-second timeout would kill idle connections.
ip_hash for sticky sessions is probably the simplest approach. It breaks if users are behind a corporate proxy sharing an IP (all of them land on the same backend). Cookie-based sticky sessions are more reliable but require the load balancer to inspect the initial request.
Authentication
Authenticating WebSocket connections is awkward. The browser WebSocket API doesn't let you set custom headers. No Authorization: Bearer token header like you'd use with HTTP. You have a few options.
Query parameter on the URL:
const ws = new WebSocket(`wss://example.com/ws?token=${authToken}`);
Works, but the token appears in server logs, proxy logs, and the browser's URL history. Not great for sensitive tokens.
Cookie-based authentication โ if the user already has an auth cookie from the HTTP site, the browser sends it during the WebSocket handshake automatically. Validate the cookie on the server:
wss.on('connection', (ws, request) => {
const cookies = parseCookies(request.headers.cookie);
const session = validateSession(cookies.sessionId);
if (!session) {
ws.close(1008, 'Unauthorized');
return;
}
ws.userId = session.userId;
});
Or authenticate after connection โ send a login message as the first thing after connecting:
// Client
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: getAuthToken()
}));
};
// Server
ws.on('message', (data) => {
const message = JSON.parse(data);
if (!ws.authenticated) {
if (message.type === 'auth' && validateToken(message.token)) {
ws.authenticated = true;
ws.userId = decodeToken(message.token).userId;
} else {
ws.close(1008, 'Unauthorized');
}
return;
}
// Handle authenticated messages
});
I use the post-connection auth approach in most projects. It's explicit, the token doesn't leak into logs, and it works consistently across proxies and load balancers.
When Not to Use WebSockets
WebSockets add complexity. Connection management, reconnection logic, scaling via pub/sub, load balancer configuration, authentication workarounds. If your use case is "show updated data within a few seconds," polling every 5 seconds is simpler and probably fine.
Server-Sent Events (SSE) are another option for server-to-client streaming. Simpler than WebSockets โ regular HTTP, works through proxies without special configuration, automatic reconnection built into the browser API. Limitation: one-directional. Server sends to client only. If you don't need the client to send messages back (which many dashboards don't), SSE is less code and fewer moving parts.
WebSockets make sense when you need bidirectional communication, low latency, or very high message frequency. Chat applications. Collaborative editing. Live gaming. Real-time trading. For anything else, consider whether the simpler option works first.
The dashboard that started all this? It could have been SSE. The data only flowed from server to client. But the same system later added a feature where users could trigger actions from the dashboard, and at that point WebSockets were the right choice. Sometimes the more complex solution is correct from the start because you can see where the product is heading. Sometimes it's premature. Hard to say โ it's a judgment call.
Debugging WebSocket Issues
A few debugging tips that would have saved me time.
Browser DevTools have a WebSocket inspector. In Chrome, open the Network tab, filter by "WS", click on a WebSocket connection, and the Messages tab shows every frame sent and received with timestamps. This is the first place to look when something isn't working โ you can see whether messages are being sent, what the payloads look like, and when the connection closed.
For server-side debugging, log connection events (open, close with code, error) and message counts rather than message contents. Logging every message payload fills disks fast on a busy server. But knowing that client X connected at 14:32, sent 47 messages, and disconnected at 14:58 with code 1006 gives you enough to diagnose most issues.
The wscat command-line tool is useful for testing WebSocket servers without building a client. Install it with npm install -g wscat and connect with wscat -c ws://localhost:8080. You can type messages and see responses interactively. I use it during development to verify the server is behaving correctly before writing any client code.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

HTTP/3 and QUIC โ Why HTTP/2 Wasn't the Final Answer
The protocol running a third of the web that most developers haven't thought about. Connection migration, 0-RTT handshakes, and why switching from TCP to UDP was the only way forward.

The JavaScript Event Loop, Explained By Working Through It
Walking through async JavaScript to show how the Event Loop decides what runs when.

WebAssembly Demystified โ It's Not Just 'Fast JavaScript'
What WebAssembly actually is under the hood, why calling it fast JavaScript misses the point, and the Rust-to-WASM pipeline I use in real projects.