Skip to main content

🔌 WebSocket Fundamentals

Every chat app, live scoreboard, and collaborative editor you've ever used shares one trick: the server can talk to the browser without being asked first. Plain HTTP can't do that. In this lesson you'll learn the protocol that can — the WebSocket — and build both a client and a server that hold an open, two-way conversation.

Week 10 · Tuesday: Real-time Features · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why the HTTP request/response model falls short for real-time features
  • Contrast polling, long-polling, Server-Sent Events, and WebSockets, and pick the right tool
  • Describe the HTTP Upgrade handshake that turns one TCP connection into a persistent WebSocket
  • Use the browser WebSocket API — open, message, close, error, and readyState
  • Build a Node WebSocket server with the ws library and broadcast to all clients
  • Add resilient reconnection with exponential backoff and know when to reach for Socket.IO instead

Estimated Time: 75 minutes

Practice: Build a minimal echo client, then a broadcast chat server with the ws library.

In This Lesson

The Real-time Problem

Ordinary HTTP is a request/response protocol: the browser asks a question, the server answers, and the connection closes. That's perfect for loading a page or submitting a form — but it has a fatal limitation for live features. Only the client can start a conversation. If a new chat message arrives on the server, the server has no way to hand it to the browser until the browser happens to ask again.

sequenceDiagram participant Browser participant Server Note over Browser,Server: Plain HTTP — client must always ask first Browser->>Server: Any new messages Server->>Browser: Nothing yet Note over Browser,Server: Connection closed Note over Server: A message arrives but cannot be pushed Browser->>Server: Any new messages Server->>Browser: Here is one message Note over Browser,Server: Delivered late — only because the client asked again

Real-time apps flip that dynamic. They need the server to push data the instant something happens. Consider where this matters:

  • Chat & messaging — messages must appear the moment they are sent
  • Live dashboards — metrics, prices, and sensor readings that update themselves
  • Collaborative editing — many people typing into one document at once
  • Multiplayer games — player positions flowing both directions many times a second
  • Notifications — instant alerts without a page refresh

🛣️ The toll-booth analogy

HTTP is like a highway toll booth: every car (request) must stop, pay, collect a ticket (response), and drive on. Fine for the occasional trip. But if a car needs to talk to the booth constantly, forcing it to exit and re-enter the highway each time is absurdly wasteful. A WebSocket is a transponder bolted into the car: once it's registered at the gate, the car and the toll system exchange signals freely, in both directions, without ever stopping again.

Polling, Long-Polling & SSE

Before WebSockets existed (they were standardized as RFC 6455 in 2011), developers faked real-time with a series of clever HTTP workarounds. It's worth knowing them, because each one teaches you what WebSockets improve on — and because SSE is still the right choice for some jobs.

graph TD A["Real-time techniques"] --> B["HTTP Polling"] A --> C["Long Polling"] A --> D["Server-Sent Events"] A --> E["WebSockets"] B --> B1["Ask on a timer — simple but wasteful"] C --> C1["Server holds the request until it has news"] D --> D1["One persistent stream, server to client only"] E --> E1["One persistent socket, full-duplex both ways"]

1. HTTP Polling

The client asks again on a fixed timer. Simple to write, but it wastes bandwidth on empty replies and always lags behind by up to one interval.

// Poll every 5 seconds — new data can be up to 5s stale
async function pollForUpdates() {
  try {
    const res = await fetch('/api/updates');
    const data = await res.json();
    updateUI(data);
  } catch (err) {
    console.error('Polling error:', err);
  } finally {
    setTimeout(pollForUpdates, 5000); // schedule the next poll
  }
}
pollForUpdates();

2. Long-Polling

The client sends a request and the server holds it open until it actually has something to send, then responds. The client immediately opens another. Latency drops close to real-time, but you still pay the full cost of a new HTTP request for every message.

3. Server-Sent Events (SSE)

SSE opens one long-lived HTTP connection over which the server streams events to the client. It has a lovely built-in API (EventSource) and automatic reconnection — but it is strictly one-way: server to client only.

// SSE is great for server-push-only feeds (stock tickers, logs)
const source = new EventSource('/api/updates/sse');
source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};
source.onerror = () => console.log('SSE dropped — the browser will retry automatically');
TechniqueDirectionLatencyBest for
PollingClient pullsHighRare, non-urgent updates
Long-pollingClient pullsLowLegacy fallback
SSEServer → clientLowOne-way streams: feeds, logs, tickers
WebSocketBoth waysLowestChat, games, collaboration

💡 SSE isn't obsolete

If your data only ever flows server → client (a live feed of stock prices, a build log, a progress bar), SSE is simpler than a WebSocket and rides on plain HTTP, so it works through more proxies with less setup. Reach for a WebSocket when you genuinely need the client to push back too.

The WebSocket Handshake

A WebSocket connection is full-duplex (both sides can send at any time) and persistent (it stays open) over a single TCP connection. But it doesn't start life as a WebSocket — it starts as an ordinary HTTP request that asks to be upgraded.

The client sends a normal GET with a special Upgrade: websocket header and a random Sec-WebSocket-Key. If the server agrees, it replies with status 101 Switching Protocols. From that instant, both sides stop speaking HTTP and start speaking the WebSocket framing protocol over the same socket.

sequenceDiagram participant Client participant Server Note over Client,Server: Starts as an ordinary HTTP request Client->>Server: GET with Upgrade websocket header and a random key Server->>Client: HTTP 101 Switching Protocols Note over Client,Server: The socket is now a persistent WebSocket Client->>Server: Send a message any time Server->>Client: Send a message any time Server->>Client: Push without being asked Client->>Server: Close frame Server->>Client: Close acknowledgment Note over Client,Server: Connection terminated cleanly

Here is what the raw exchange looks like on the wire:

// Client request — a normal HTTP GET that asks to upgrade
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

// Server response — agreement to switch protocols
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

🔗 The two URL schemes

WebSocket URLs use their own schemes, mirroring HTTP:

  • ws:// — unencrypted, the WebSocket cousin of http://
  • wss:// — encrypted over TLS, the cousin of https://

Always use wss:// in production, exactly as you'd insist on HTTPS. Unencrypted WebSocket traffic is trivially readable and is often blocked by corporate proxies.

Key properties, at a glance

  • Bidirectional & full-duplex: either side sends whenever it wants, simultaneously
  • Persistent: one TCP connection stays open for the session's lifetime
  • Low overhead: after the handshake, a message frame adds only a few bytes — no repeated HTTP headers
  • Format-agnostic: send text (usually JSON) or binary (ArrayBuffer, Blob)

The Browser WebSocket API

Every modern browser ships a native WebSocket constructor. You create one with a URL, then attach event listeners. There are exactly four events to know: open, message, error, and close.

// 1. Open the connection (wss:// in production!)
const socket = new WebSocket('wss://echo.websocket.org');

// 2. Fires once the handshake completes
socket.addEventListener('open', () => {
  console.log('Connected');
  socket.send('Hello, server!'); // only safe to send after 'open'
});

// 3. Fires every time the server sends data
socket.addEventListener('message', (event) => {
  console.log('From server:', event.data); // event.data is a string or binary
});

// 4. Fires on protocol/network errors
socket.addEventListener('error', (event) => {
  console.error('WebSocket error', event);
});

// 5. Fires when either side closes — event.code tells you why
socket.addEventListener('close', (event) => {
  console.log(`Closed: code=${event.code} reason=${event.reason}`);
});

Sending structured data

WebSockets only carry strings and binary. To send an object, serialize it with JSON.stringify and parse it on the other side — the same pattern you already use with fetch.

const payload = {
  type: 'chat',
  user: 'ada',
  text: 'Hello everyone!',
  sentAt: new Date().toISOString(),
};
socket.send(JSON.stringify(payload)); // send text

socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data); // decode on arrival
  if (data.type === 'chat') renderMessage(data);
});

readyState — don't send into the void

A socket moves through four states. Calling send() before it is OPEN throws, so guard your sends.

ConstantValueMeaning
WebSocket.CONNECTING0Handshake in progress
WebSocket.OPEN1Ready — you can send
WebSocket.CLOSING2Close handshake underway
WebSocket.CLOSED3Closed or failed to open
function safeSend(socket, message) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(message);
  } else {
    console.warn('Socket not open yet — message dropped or should be queued');
  }
}

Console output

Connected
From server: Hello, server!   // echo.websocket.org bounces it back
Closed: code=1000 reason=Normal closure

A Node Server with ws

The browser is only half the story. On the server, Node has no built-in WebSocket server, so we use the tiny, battle-tested ws library. Attach it to an existing HTTP server so your Express routes and your WebSocket share one port.

// npm install ws express
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');

const app = express();
app.use(express.static('public'));

// Share one HTTP server between Express and the WebSocket server
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  console.log('Client connected from', req.socket.remoteAddress);

  // Greet the newcomer
  ws.send(JSON.stringify({ type: 'system', text: 'Welcome!' }));

  // Handle every message this client sends
  ws.on('message', (raw) => {
    let data;
    try {
      data = JSON.parse(raw);          // ws gives you a Buffer; parse it
    } catch {
      return ws.send(JSON.stringify({ type: 'error', text: 'Send valid JSON' }));
    }
    // Echo it back with a timestamp
    ws.send(JSON.stringify({ type: 'echo', data, at: Date.now() }));
  });

  ws.on('close', (code) => console.log('Client left, code', code));
  ws.on('error', (err) => console.error('Socket error:', err.message));
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`Listening on ${PORT}`));

Broadcasting to everyone

The whole point of chat is that one person's message reaches all the others. The server keeps a set of connected clients in wss.clients; loop over it and send to each open socket.

const { WebSocket } = require('ws');

function broadcast(data, except = null) {
  const message = JSON.stringify(data);
  for (const client of wss.clients) {
    // Only send to clients that are open, optionally skipping the sender
    if (client.readyState === WebSocket.OPEN && client !== except) {
      client.send(message);
    }
  }
}

wss.on('connection', (ws) => {
  broadcast({ type: 'system', text: 'A new user joined' }, ws); // skip the newcomer

  ws.on('message', (raw) => {
    const data = JSON.parse(raw);
    if (data.type === 'chat') {
      broadcast({ type: 'chat', user: data.user, text: data.text, at: Date.now() });
    }
  });
});

⚠️ Authenticate at connection time

The Upgrade request carries cookies and headers just like any HTTP request, so verify the user during the handshake — before connection fires. Use the verifyClient option (or, better, handle the server's upgrade event yourself) to check a session or a token, and reject unauthorized upgrades with a 401. Never trust a socket just because it connected.

Reconnection & Reliability

Real networks drop connections constantly — a phone changes towers, a laptop sleeps, a load balancer recycles. The native WebSocket does not reconnect for you. A robust client detects an abnormal close and retries with exponential backoff so it doesn't hammer a struggling server.

class ReconnectingSocket {
  constructor(url) {
    this.url = url;
    this.attempts = 0;
    this.maxDelay = 30000; // cap the wait at 30 seconds
    this.connect();
  }

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

    this.ws.onopen = () => {
      console.log('Connected');
      this.attempts = 0; // reset backoff on success
    };

    this.ws.onmessage = (event) => this.onMessage?.(event.data);

    this.ws.onclose = (event) => {
      if (event.code === 1000) return; // 1000 = clean close, do not retry
      this.scheduleReconnect();
    };

    this.ws.onerror = () => this.ws.close(); // force onclose to run
  }

  scheduleReconnect() {
    this.attempts++;
    // 1s, 1.5s, 2.25s, ... capped at maxDelay
    const delay = Math.min(this.maxDelay, 1000 * 1.5 ** (this.attempts - 1));
    console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempts})`);
    setTimeout(() => this.connect(), delay);
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) this.ws.send(data);
  }
}

const socket = new ReconnectingSocket('wss://example.com/chat');
socket.onMessage = (data) => console.log('Got:', data);

✅ This is exactly why Socket.IO exists

Reconnection, backoff, heartbeats to detect dead connections, buffering messages while offline, fallback transports for hostile proxies, rooms, and acknowledgements — you'd have to build all of that yourself on raw WebSockets. Socket.IO packages it up. It is not raw WebSocket (it has its own protocol layered on top), but it saves you from re-implementing everything above. That's the very next lesson.

Practice & Quiz

🏋️ Exercise 1: An echo client

Goal: Connect to the public echo server, send "ping" once the connection opens, and log whatever comes back.

// TODO: open a WebSocket to wss://echo.websocket.org
// TODO: on 'open', send the string "ping"
// TODO: on 'message', log the data
💡 Hint

Create the socket, then use addEventListener('open', ...) to send — you can't send before the handshake finishes. Listen for 'message' and read event.data.

✅ Solution
const socket = new WebSocket('wss://echo.websocket.org');

socket.addEventListener('open', () => {
  socket.send('ping');
});

socket.addEventListener('message', (event) => {
  console.log('Echo:', event.data); // "ping"
});

🏋️ Exercise 2: A broadcast chat server

Goal: Using ws, write a server that relays every message it receives to all other connected clients (not back to the sender).

💡 Hint

Iterate wss.clients. Send only when client.readyState === WebSocket.OPEN and client !== ws (the sender).

✅ Solution
const { WebSocketServer, WebSocket } = require('ws');
const wss = new WebSocketServer({ port: 3000 });

wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    for (const client of wss.clients) {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(raw.toString());
      }
    }
  });
});

🎯 Quick Quiz

Question 1: What HTTP status code does the server return to accept a WebSocket upgrade?

Question 2: Which statement about WebSockets is true?

Question 3: Before calling socket.send(), what should you check?

Best Practices & Pitfalls

✅ Do

  • Use wss:// everywhere in production — encrypt the traffic
  • Authenticate during the upgrade handshake, before accepting the connection
  • Send structured data as JSON with a type field so the receiver can route it
  • Add reconnection with exponential backoff; reset the delay after a successful connect
  • Guard every send() with a readyState === OPEN check
  • Validate and sanitize every message — a socket is untrusted input just like a form

❌ Don't

  • Assume the connection stays up — networks drop sockets constantly
  • Reconnect in a tight loop; that turns one outage into a self-inflicted DDoS
  • Trust event.data blindly — always JSON.parse inside a try/catch
  • Reach for raw WebSockets when Socket.IO's reconnection, rooms, and fallbacks would save you days

⚠️ Close codes carry meaning

Code 1000 is a normal, deliberate close — don't reconnect after it. Codes like 1006 (abnormal closure) or 1011 (server error) signal trouble and should trigger a retry. Inspect event.code in your close handler rather than blindly reconnecting on every close.

Summary

🎉 Key Takeaways

  • Plain HTTP is request/response — only the client can start a conversation, so it can't push
  • A WebSocket is a full-duplex, persistent connection over a single TCP socket, opened by an HTTP Upgrade handshake that returns 101 Switching Protocols
  • Use ws:// and wss:// schemes; always wss:// in production
  • The browser API is four events — open, message, error, close — plus readyState
  • On the server, the ws library attaches to your HTTP server; broadcast by looping over wss.clients
  • Raw WebSockets don't reconnect — you add backoff yourself, which is a big reason Socket.IO exists

📚 Additional Resources

🚀 What's Next?

You now understand the raw protocol — and everything it makes you responsible for. Next, we hand that burden to a library that handles reconnection, rooms, namespaces, and fallbacks for you: Socket.IO Implementation.

🎉 The socket is open!

You've moved from "the client must always ask" to "the server can push." That single shift powers every live feature you'll build from here.