Creating Real-Time Applications with WebSockets in JavaScript: A Complete Guide

Creating Real-time Applications with WebSockets in JavaScript

The first time I tried to build a live chat feature, I did it with a setInterval polling every two seconds, hammering the server with requests that mostly returned “nothing new.” It worked, technically, but it felt wasteful and always had a noticeable delay. WebSockets changed that completely for me. In this article, I’ll walk through everything I’ve learned building real-time features with WebSockets in JavaScript — from the protocol basics to production patterns for chat apps, live dashboards, and collaborative tools.

Why WebSockets Exist

HTTP is a request-response protocol — the client asks, the server answers, and the connection typically closes (or stays open briefly for keep-alive, but the model is still fundamentally one request, one response). This makes true real-time, bidirectional communication awkward. Techniques like polling and long-polling exist to work around this, but they carry overhead and latency.

WebSockets solve this by upgrading a single HTTP connection into a persistent, full-duplex channel where either side — client or server — can send messages at any time, without a new request/response cycle for each one.

The WebSocket Handshake

A WebSocket connection starts as a normal HTTP request with special headers:

GE T /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server supports WebSockets, it responds with a 101 Switching Protocols status, and from that point on, the same TCP connection is used to send framed WebSocket messages in both directions — no more HTTP request/response overhead per message.

Client-Side Basics

const socket = new WebSocket('wss://example.com/chat');

socket.addEventListener('open', () => {
  console.log('Connected to server');
  socket.send(JSON.stringify({ type: 'join', room: 'general' }));
});

socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

socket.addEventListener('close', (event) => {
  console.log('Disconnected:', event.code, event.reason);
});

socket.addEventListener('error', (error) => {
  console.error('WebSocket error:', error);
});

I always use wss:// (WebSocket Secure) rather than ws:// in production — it’s the WebSocket equivalent of HTTPS, encrypting the connection.

Server-Side with Node.js (using the ws library)

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('New client connected');

  ws.on('message', (message) => {
    const data = JSON.parse(message);
    console.log('Received:', data);

    // Broadcast to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({ type: 'message', text: data.text }));
      }
    });
  });

  ws.on('close', () => console.log('Client disconnected'));
});

This simple broadcast pattern is the backbone of most chat and live-notification systems I’ve built.

Building a Simple Chat Room

Here’s a more complete example tracking rooms and users:

// Server
const rooms = new Map(); // room name -> Set of sockets

wss.on('connection', (ws) => {
  let currentRoom = null;

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);

    if (msg.type === 'join') {
      currentRoom = msg.room;
      if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set());
      rooms.get(currentRoom).add(ws);
    }

    if (msg.type === 'chat' && currentRoom) {
      for (const client of rooms.get(currentRoom)) {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify({ type: 'chat', user: msg.user, text: msg.text }));
        }
      }
    }
  });

  ws.on('close', () => {
    if (currentRoom && rooms.has(currentRoom)) {
      rooms.get(currentRoom).delete(ws);
    }
  });
});
// Client
socket.send(JSON.stringify({ type: 'join', room: 'general' }));

sendButton.addEventListener('click', () => {
  socket.send(JSON.stringify({ type: 'chat', user: 'Alex', text: messageInput.value }));
});

Handling Reconnection

Networks are unreliable, and WebSocket connections drop — a mobile user switching from WiFi to cellular is enough to kill the connection. I always implement automatic reconnection with exponential backoff:

function connect() {
  const socket = new WebSocket('wss://example.com/chat');
  let reconnectDelay = 1000;

  socket.addEventListener('open', () => {
    reconnectDelay = 1000; // reset backoff on successful connection
  });

  socket.addEventListener('close', () => {
    console.log(`Reconnecting in ${reconnectDelay}ms...`);
    setTimeout(connect, reconnectDelay);
    reconnectDelay = Math.min(reconnectDelay * 2, 30000); // cap at 30s
  });

  return socket;
}

let socket = connect();

Heartbeats: Detecting Dead Connections

A subtle issue with WebSockets is that a connection can appear open on the client while the underlying TCP connection has silently died (common with certain proxies and NAT timeouts). I handle this with a ping/pong heartbeat:

// Server
wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

Browsers handle ping/pong frames automatically at the protocol level for the client side, so this pattern is mostly implemented server-side to detect and clean up stale connections.

The Event Loop and WebSockets in Node.js

WebSocket message handling in Node.js is entirely event-driven, integrated into the same event loop as everything else. This is efficient because a Node process can hold open thousands of WebSocket connections without blocking, as long as the message handlers themselves don’t do heavy synchronous work. If I need to do CPU-intensive processing on incoming messages (like image analysis or complex calculations), I offload that work to worker threads rather than doing it directly in the message handler, to avoid stalling all other connected clients.

Scaling Beyond a Single Server

A single Node.js process holds WebSocket connections in memory, which becomes a problem once you need multiple server instances behind a load balancer — a message from a client connected to Server A needs to reach a client connected to Server B. I typically solve this with a pub/sub layer like Redis:

const redis = require('redis');
const subscriber = redis.createClient();
const publisher = redis.createClient();

subscriber.subscribe('chat-channel');
subscriber.on('message', (channel, message) => {
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) client.send(message);
  });
});

// When a message arrives from a client, publish it instead of broadcasting directly
ws.on('message', (msg) => {
  publisher.publish('chat-channel', msg);
});

This way, every server instance subscribes to the same Redis channel and rebroadcasts to its own locally connected clients, achieving cross-server real-time delivery.

Security Considerations

  • Always validate and sanitize any data received over a WebSocket message — it’s just as untrusted as an HTTP request body.
  • Authenticate the WebSocket connection, typically by validating a token during the handshake (via query string or an Authorization-equivalent header, since custom headers aren’t always available depending on the client).
  • Enforce message size limits to prevent memory exhaustion from malicious payloads.
  • Use wss:// in production; never send sensitive data over unencrypted ws://.

Common Mistakes

MistakeConsequenceFix
No reconnection logicUsers silently disconnectedImplement exponential backoff reconnection
No heartbeat/ping-pongStale “zombie” connections accumulateUse ping/pong with isAlive tracking
Broadcasting to all clients regardless of roomData leaks between unrelated usersTrack room membership explicitly
Heavy synchronous work in message handlersBlocks all connections on that serverOffload to worker threads or a queue
Not scaling pub/sub across instancesMessages don’t reach users on other serversUse Redis or similar pub/sub broker

FAQs

Are WebSockets better than Server-Sent Events (SSE)? It depends. SSE is simpler and works well for one-directional server-to-client updates (like live feeds). WebSockets are the better choice when you need true bidirectional communication, like chat or collaborative editing.

Do WebSockets work through firewalls and proxies? Mostly, yes, since the handshake starts as a normal HTTP request, but some restrictive corporate proxies can still interfere — this is one reason a heartbeat mechanism and reconnection logic matter.

Can I use WebSockets with HTTP/2? WebSockets and HTTP/2 are separate protocols; browsers negotiate this automatically, and libraries like ws handle the underlying details for you.

Summary and Key Takeaways

WebSockets transformed how I build features that need to feel instantaneous. The key lessons:

  • WebSockets upgrade a single HTTP connection into a persistent, bidirectional channel — no more polling overhead.
  • Always implement reconnection logic and heartbeats; real networks are unreliable.
  • Keep message handlers lightweight; offload heavy work to avoid blocking the event loop.
  • Use a pub/sub layer like Redis to scale WebSocket servers horizontally.
  • Treat every incoming WebSocket message as untrusted input, just like an HTTP request body.

References

Total
1
Shares

Leave a Reply

Previous Post
Monitoring and Performance Optimization in JavaScript

Monitoring and Performance Optimization in JavaScript: A Practical Deep Dive

Next Post
Mastering Regular Expressions in JavaScript

Mastering Regular Expressions in JavaScript: From Beginner to Advanced

Related Posts