Client-Side WebSockets

WebSockets & Real-Time Communication — Client-Side WebSockets
WebSockets & Real-Time Communication
Chapter 5 · Client-Side WebSockets

🌐 Client-Side WebSockets

Chapters 2 through 4 were all server-side. This chapter moves to the browser — and immediately runs into the exact gap Chapter 4 flagged: the native WebSocket API has no automatic reconnection. If the connection drops, it's gone, and building something better is entirely this chapter's job.

The Native API

const socket = new WebSocket("wss://example.com/chat"); socket.addEventListener("open", () => { console.log("Connected."); }); socket.addEventListener("message", (event) => { const data = JSON.parse(event.data); renderMessage(data); }); socket.addEventListener("close", (event) => { console.log(`Closed: ${event.code} ${event.reason}`); }); socket.addEventListener("error", (err) => { console.error("Socket error:", err); });

The same four events from Chapter 3's server-side ws library, mirrored on the client: open, message, close, error. Sending is the same one method too — socket.send(...), guarded by the same readyState check from Chapter 3.

🔁 Building Reconnection By Hand

Since nothing does this automatically, a reconnecting client needs its own retry loop — and a naive one causes real problems:

Naive Retry vs Backoff + Jitter

Naive: Retry Immediately

If the server restarts and drops every connection at once, every client reconnects in the same instant — a thundering herd that can overwhelm the server right as it's coming back up.

Backoff + Jitter

Each retry waits longer than the last (backoff), and the exact delay is randomized a little (jitter) — spreading reconnection attempts out instead of all landing at once.

A Small Reconnecting Wrapper

class ReconnectingSocket { constructor(url) { this.url = url; this.attempt = 0; this.connect(); } connect() { this.socket = new WebSocket(this.url); this.socket.addEventListener("open", () => { this.attempt = 0; // reset backoff once we're actually connected }); this.socket.addEventListener("close", () => { const delay = Math.min(1000 * 2 ** this.attempt, 30000); // exponential, capped at 30s const jitter = Math.random() * 0.3 * delay; this.attempt++; setTimeout(() => this.connect(), delay + jitter); }); } }

🟢 Reflecting Connection State in the UI

Users need to know when the connection isn't live — a small state machine drives that:

StateUI Treatment
ConnectingNeutral indicator ("Connecting...")
ConnectedNormal UI, indicator hidden or green
ReconnectingVisible warning ("Reconnecting..."), messages queued or disabled
Disconnected (gave up)Clear error state, manual retry option

What to Do With Messages Sent While Disconnected

Either queue them to send once reconnected, or reject them immediately with clear UI feedback — silently dropping a message the user thinks was sent is the worst option.

Cleaning Up

Close the socket explicitly on page unload or when a component unmounts in a single-page app — an abandoned open socket with live listeners is a real memory/connection leak.

💻 Coding Challenges

Challenge 1: Add a Maximum Retry Count

Extend the ReconnectingSocket class above so that after 10 failed reconnection attempts in a row, it stops retrying and instead exposes a way for the UI to detect the connection has been permanently given up on.

Goal: Practice turning an infinite retry loop into one with a defined, observable end state.

→ Solution

Challenge 2: Queue Messages While Disconnected

Add a send(data) method to ReconnectingSocket that queues messages if the socket isn't currently OPEN, and flushes the queue the moment the connection re-opens.

Goal: Practice handling the "user sends something while disconnected" case discussed in this chapter.

→ Solution

Challenge 3: Explain a Duplicate-Handler Bug

A developer's reconnection code calls this.connect() on every retry but never removes the event listeners attached to the previous (now-closed) socket. Explain what goes wrong as reconnections accumulate, and how to fix it.

Goal: Practice reasoning about listener lifecycle across multiple socket instances.

→ Solution

⚠️ Gotcha: Stale Listeners on a New Socket

Every reconnect in the pattern above creates a brand-new WebSocket object — a completely fresh instance, not the old one somehow reopened. If code elsewhere holds a reference to the old socket (say, a variable captured before the first disconnect) and keeps calling .send() on it, those calls fail silently or throw, because that specific object is permanently closed — the "live" connection is a different object entirely now. Always read the current socket through the wrapper (e.g. this.socket) rather than caching a raw reference to one particular WebSocket instance.

🎯 What's Next

With a resilient client in place, the next chapter goes back to the server to build out what a real application actually needs: Broadcasting, Rooms & Presence — grouping connections, targeted messaging, and tracking who's currently online.