jsguides

Browser WebSockets: Real-Time Bidirectional Communication

Browser WebSockets provide a persistent, full-duplex connection to a server. Unlike traditional HTTP requests where the client always initiates communication, WebSockets in the browser let both sides send messages at any time, making them ideal for real-time applications like chat, live dashboards, and collaborative tools.

Why WebSockets?

HTTP is inherently request-response based. To get updates from a server, your client must repeatedly poll for changes:

// Polling approach - inefficient
async function checkForUpdates() {
  const response = await fetch('/api/status');
  const data = await response.json();
  updateUI(data);
}

setInterval(checkForUpdates, 2000); // Check every 2 seconds

This wastes bandwidth and introduces latency because the client must keep asking even when nothing has changed. Every request carries HTTP headers, and the server must process each one regardless of whether there is new data to return. WebSockets solve this by keeping a single connection open so the server can push updates the moment they happen:

// WebSocket - server pushes updates instantly
const socket = new WebSocket('wss://example.com/ws');

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data); // Immediate update
};

WebSocket vs HTTP Comparison

AspectHTTP PollingWebSockets
Latency0-2 seconds delayInstant
OverheadNew connection each requestSingle persistent connection
Server loadHigh (many requests)Low (one connection)
Bi-directionalNo (client initiates)Yes (both can send)
Browser supportAll browsersAll modern browsers

Creating a WebSocket connection

The WebSocket API is straightforward:

// Connect to a WebSocket server
const socket = new WebSocket('wss://example.com/ws');

// Connection opened
socket.addEventListener('open', (event) => {
  console.log('Connected to WebSocket server');
  socket.send(JSON.stringify({ type: 'hello' }));
});

// Receive messages
socket.addEventListener('message', (event) => {
  console.log('Message from server:', event.data);
});

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

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

Notice the URL scheme: ws:// for unencrypted or wss:// for encrypted connections. Always use wss:// in production: it encrypts your data and works with modern browsers without warnings.

Sending and receiving data

WebSockets send raw text or binary data. JSON is the common choice:

// Send a JSON message
function sendMessage(type, payload) {
  socket.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
}

// Different message types
sendMessage('chat', { text: 'Hello, world!' });
sendMessage('cursor', { x: 100, y: 200 });
sendMessage('ping', {});

On the receiving end, parse incoming data into structured messages. A switch on the message type keeps handler logic clean and makes it easy to add new event kinds later. Always wrap parsing in a try/catch since WebSocket messages can contain raw text that is not valid JSON:

socket.onmessage = (event) => {
  try {
    const message = JSON.parse(event.data);

    switch (message.type) {
      case 'chat':
        displayChatMessage(message.payload);
        break;
      case 'update':
        handleStateUpdate(message.payload);
        break;
      case 'pong':
        console.log('Server acknowledged our ping');
        break;
    }
  } catch (e) {
    // Handle non-JSON messages
    console.log('Raw message:', event.data);
  }
};

Handling connection state

The WebSocket has a readyState property that tells you whether the connection is established, closing, or already closed. Checking this before calling send() prevents errors that would otherwise crash silently and leave messages lost. The four possible states map to numeric constants on the WebSocket object:

console.log(socket.readyState);

// Values:
WebSocket.CONNECTING // 0 - Connection not yet established
WebSocket.OPEN      // 1 - Connection established, ready to communicate
WebSocket.CLOSING   // 2 - Connection closing
WebSocket.CLOSED    // 3 - Connection closed

This helps you avoid sending messages when the connection is not ready, which prevents runtime errors and lost data. When the socket is still connecting or already closed, queue the message and retry once the connection opens. The helper below gates every send through a readyState check so callers never need to worry about timing:

function safeSend(data) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(data);
  } else {
    // Queue message or retry later
    pendingMessages.push(data);
  }
}

Automatic Reconnection

Network interruptions happen. Wi-Fi drops, laptops sleep, and servers restart. Rather than leaving your user staring at a broken app, wrap your WebSocket connection in a class that automatically reconnects with exponential backoff. This prevents flooding the server during outages while still recovering quickly when the network returns:

class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.connect();
  }

  connect() {
    this.socket = new WebSocket(this.url);

    this.socket.onclose = (event) => {
      console.log('Connection lost, reconnecting...');
      // Exponential backoff: 1s, 2s, 4s, 8s...
      setTimeout(() => this.connect(), this.retryDelay || 1000);
      this.retryDelay = Math.min((this.retryDelay || 1000) * 2, 30000);
    };

    this.socket.onopen = () => {
      console.log('Reconnected');
      this.retryDelay = 1000; // Reset backoff
      this.flushPending();
    };
  }

  send(data) {
    if (this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(data);
    } else {
      this.pending = this.pending || [];
      this.pending.push(data);
    }
  }

  flushPending() {
    if (this.pending) {
      this.pending.forEach(msg => this.socket.send(msg));
      this.pending = [];
    }
  }
}

const client = new WebSocketClient('wss://example.com/ws');

This client automatically reconnects with exponential backoff, preventing server overload during outages. The backoff starts at 1 second and doubles after each failure up to 30 seconds, then resets on a successful connection. Pending messages are flushed once the connection comes back so no data is lost during the interruption.

Building on that reconnection foundation, here is a complete chat client that handles message routing, user lists, and error display. The class wraps a WebSocket connection with typed message handling so the UI layer stays decoupled from the transport layer:

Real-world example: chat application

Here’s a practical chat implementation:

class ChatClient {
  constructor(serverUrl) {
    this.serverUrl = serverUrl;
    this.connect();
  }

  connect() {
    this.socket = new WebSocket(this.serverUrl);

    this.socket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };

    this.socket.onopen = () => {
      this.sendJoin();
    };
  }

  sendJoin() {
    this.send({ type: 'join', username: this.username });
  }

  sendMessage(text) {
    this.send({
      type: 'message',
      text: text,
      timestamp: Date.now()
    });
  }

  send(type, payload) {
    this.socket.send(JSON.stringify({ type, ...payload }));
  }
}

// Usage
const chat = new ChatClient('wss://chat.example.com');

The send() helper wraps every message with a type field using spread syntax, which keeps the protocol consistent without duplicating boilerplate. The chat client sends a join message on connect and routes incoming messages by type through handleMessage():

  handleMessage(message) {
    switch (message.type) {
      case 'message':
        this.displayMessage(message);
        break;
      case 'users':
        this.updateUserList(message.users);
        break;
      case 'error':
        this.showError(message.text);
        break;
    }
  }

  displayMessage(msg) {
    const chat = document.getElementById('chat');
    const div = document.createElement('div');
    div.textContent = `${msg.username}: ${msg.text}`;
    chat.appendChild(div);
  }
}

// Send a message
chat.sendMessage('Hello, everyone!');

If you add new message types later, you only need a new case branch in handleMessage() and a matching display method. The protocol stays small while the app can grow to handle specialized events.

Secure WebSocket considerations

When deploying WebSockets in production:

  • Always use wss://: encrypts the connection
  • Validate origin: check the Origin header on the server to prevent cross-site attacks
  • Authenticate during handshake: pass tokens as query parameters or headers during connection:
    const socket = new WebSocket('wss://api.example.com/ws?token=YOUR_JWT');
  • Handle sensitive data carefully: WebSocket connections do not automatically include cookies; use explicit authentication

Browser compatibility

WebSockets are supported in all modern browsers:

  • Chrome 4+
  • Firefox 11+
  • Safari 4+
  • Edge 12+
  • IE 10+ (with limitations)

For older browsers, libraries like socket.io provide fallbacks, though they add overhead.

Protocol shape and state

When you build with the browser WebSocket API, it helps to treat the connection like a small state machine. The browser gives you CONNECTING, OPEN, CLOSING, and CLOSED, but your app often has its own states too, such as unauthenticated, joined, reconnecting, or paused. Mapping those ideas clearly keeps the UI from drifting out of sync with the socket. A button should not say “Send” when the socket is still connecting, and a chat input should not look active if the app has already given up on reconnecting.

Message shape matters just as much as state. A predictable envelope with type, payload, and maybe timestamp makes handlers easier to write and easier to test. It also makes it easier to add new event kinds later without changing every branch of UI code. If the server can send both data and control messages, keep them distinct so the client can tell a state update from a keepalive or an error notice. That small bit of structure pays off as soon as more than one screen uses the same socket.

Failure and recovery

WebSocket code should expect the network to be messy. A connection can drop because the laptop slept, Wi-Fi changed, the tab was suspended, or the server restarted. If the app assumes an always-on path, the user experience gets brittle fast. A more careful approach is to show connection status, retry when the app still cares, and stop retrying when the user has moved on. That keeps the UI honest and avoids burning resources on a screen no one is using.

Recovery also needs a plan for missed messages. If the server sends live updates, a reconnect may need a fresh snapshot before incremental events make sense again. Many apps solve that by requesting the latest state after open, then applying live updates only after the snapshot arrives. That keeps the client from rendering a half-updated view after a brief disconnect. It also gives you a cleaner place to validate whether the socket is still authorized for the current user.

Choosing WebSockets deliberately

The browser API is powerful, but it is not the right choice for every network task. If the app only needs one request and one response, fetch() is simpler and easier to cache. If the app needs server push but not a bidirectional conversation, SSE may be a better fit. WebSockets shine when the client and server both need to speak often, the connection stays active, and the user benefits from near-real-time updates.

That is why it helps to define the feature first and the transport second. Start with the behavior the user should see, then ask whether the socket actually solves the problem. When the answer is yes, keep the protocol small, the state clear, and the reconnect logic deliberate. That keeps the app easy to understand long after the first demo is over.

Practical WebSocket state

It helps to treat the WebSocket connection as one part of a bigger UI state machine. The browser tells you when the socket is connecting, open, closing, or closed, but the interface may also need to know whether the user is signed in, whether a room has been joined, or whether reconnect attempts are still in progress. When those ideas line up, the screen feels much more stable because the UI and the transport are describing the same reality.

This also makes debugging easier. If a message appears late or the connection drops, you can look at the UI state and the socket state separately instead of guessing. A small status indicator or log message can save a lot of time when the network is unreliable. The simpler the state story, the easier it is to recover gracefully when the browser or server behaves in a way you did not expect.

Avoiding common pitfalls

When the server restarts, the WebSocket closes and your client should handle that gracefully. Always listen for the close event and decide whether to reconnect based on the close code: code 1000 means a normal closure, while 1006 signals an abnormal disconnect. Reconnect for the latter, not the former.

WebSocket messages arrive in order, but processing can fall behind if the handler is slow. If your handler does heavy work, consider offloading to a Web Worker so the socket stays responsive. Also, never assume the server will process messages in the order you sent them if the server is multi-threaded: include a sequence number in your payload if ordering matters.

Next steps

Now that you can build real-time browser communication, apply these patterns:

  1. Create connections with new WebSocket(url) using wss:// for production
  2. Send messages with .send(): use JSON for structured data
  3. Check state before sending to avoid errors on closed connections
  4. Implement reconnection with exponential backoff for network resilience
  5. Authenticate during the WebSocket handshake to control access

In the next tutorial, you will learn about IndexedDB for storing data client-side, another powerful browser API for building offline-capable applications.

See Also