A learning project exploring WebSocket concepts through a real-time sports match tracking system. This project demonstrates how WebSockets solve the problem of pushing live data from server to clients without constant polling.
Traditional HTTP follows a request-response model: the client asks, the server answers, the connection closes. For a live sports tracker, this means the client would need to poll the server every few seconds asking "any score updates?" -- wasting bandwidth and introducing latency.
WebSockets solve this by establishing a persistent, bidirectional connection. Once the handshake completes over HTTP, the protocol upgrades to WebSocket and both sides can send messages at any time with minimal overhead.
HTTP Polling (wasteful):
Client -> Server: "Any updates?" (every 3 seconds)
Server -> Client: "No"
Client -> Server: "Any updates?"
Server -> Client: "No"
Client -> Server: "Any updates?"
Server -> Client: "Yes! Score is 2-1"
WebSocket (efficient):
Client -> Server: [opens connection]
Server -> Client: [pushes updates instantly when they happen]
-
HTTP Upgrade Handshake: The client sends a regular HTTP request with an
Upgrade: websocketheader. The server responds with101 Switching Protocols, and the connection transitions from HTTP to WebSocket. -
Persistent Connection: After the upgrade, the TCP connection stays open. Both client and server can send frames (messages) independently.
-
Close: Either side can initiate a close handshake. The connection terminates after a brief exchange of close frames.
WebSocket data is sent in frames, not streams. Each frame contains:
- Opcode: What kind of data (text, binary, ping, pong, close)
- Payload: The actual message data
- Length: How big the payload is
- Masking: Client-to-server frames are masked for security
You never deal with frames directly using the ws library -- it handles all of this for you.
The WebSocket server attaches to the same HTTP server Express uses:
// ws/server.js
const wss = new WebSocketServer({ server, path: '/ws' });This works because WebSocket connections start as HTTP requests. By sharing the same http.createServer(), both Express routes and WebSocket upgrades are handled by one process.
To all connected clients (broadcast):
function broadcast(wss, payload) {
const message = JSON.stringify(payload);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}To specific clients (targeted broadcast):
function broadcastToSubscribers(wss, matchId, payload) {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN && client.subscriptions?.has(matchId)) {
client.send(JSON.stringify(payload));
}
}
}To a single client:
function sendJson(socket, payload) {
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(payload));
}Clients send JSON messages that the server parses and routes:
socket.on('message', (data) => {
const msg = JSON.parse(data.toString());
switch (msg.type) {
case 'subscribe': // Client wants match updates
case 'unsubscribe': // Client stops wanting updates
case 'ping': // Application-level keepalive
}
});Every socket has a readyState property:
| Value | Constant | Meaning |
|---|---|---|
| 0 | CONNECTING |
Handshake in progress |
| 1 | OPEN |
Connection active, can send |
| 2 | CLOSING |
Close handshake started |
| 3 | CLOSED |
Connection terminated |
Always check readyState === WebSocket.OPEN before sending. Messages to closed sockets throw errors.
Network issues can leave "zombie" connections -- the server thinks the client is connected, but the client is gone. Heartbeats detect this:
// Server pings every 30 seconds
const interval = setInterval(() => {
wss.clients.forEach((socket) => {
if (socket.isAlive === false) return socket.terminate();
socket.isAlive = false;
socket.ping();
});
}, 30000);
// Client responds with pong automatically (handled by ws library)
// If no pong received before next ping, connection is terminatedThis is different from the application-level ping/pong in the message handler. The protocol-level ping/pong is handled by the ws library and the browser automatically. The application-level one lets clients check if the server is reachable without opening a new connection.
Not every client needs every update. A subscription model lets clients choose what they care about:
// Client sends:
{ "type": "subscribe", "matchId": 42 }
// Server stores:
socket.subscriptions.add(42);
// Later, score update for match 42:
broadcastToSubscribers(wss, 42, { type: "score_updated", data: {...} });
// Only clients subscribed to match 42 receive thisThis is a simplified version of what services like Socket.IO call "rooms" or "channels."
- Broadcast: Send to every connected client (used for new match creation)
- Targeted broadcast: Send only to clients who subscribed to a specific match (used for score updates and commentary)
Targeted broadcast reduces unnecessary network traffic. A client tracking match 42 doesn't need to receive updates for match 99.
WebSocket itself is agnostic about message format -- it sends raw bytes. This project uses JSON for all messages:
// Client -> Server
{ "type": "subscribe", "matchId": 42 }
// Server -> Client
{ "type": "score_updated", "data": { "id": 42, "homeScore": 3, "awayScore": 2 } }Every message has a type field that tells the receiver how to handle it. This is a common pattern -- there's no standard WebSocket message format.
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1024 * 1024 });Prevents clients from sending massive messages that consume server memory. 1MB is generous for JSON messages but reasonable for this use case.
WebSocket errors don't work like HTTP errors. Once the connection is open, there's no status code to return. Errors are events:
socket.on('error', (err) => {
console.error('WebSocket error:', err);
// Connection will close automatically after most errors
});Always wrap send() calls in try-catch when sending to multiple clients -- one broken connection shouldn't crash the broadcast loop.
| Feature | WebSocket | SSE |
|---|---|---|
| Direction | Bidirectional | Server -> Client only |
| Protocol | ws:// or wss:// | HTTP |
| Auto-reconnect | Manual | Built-in |
| Browser support | All modern | All modern |
| Complexity | Higher | Lower |
| Max connections | ~6 per domain (HTTP/1.1) | ~6 per domain |
For this project, WebSocket is the right choice because clients might need to send commands back (subscribe/unsubscribe). For pure notification systems, SSE is simpler.
WebSocket connections can drop. The ws library doesn't auto-reconnect. Clients need:
function connect() {
const ws = new WebSocket('ws://localhost:8000/ws');
ws.onclose = () => {
setTimeout(connect, 3000); // Reconnect after 3 seconds
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Re-subscribe to matches after reconnect
};
}If you run multiple server instances, a client connected to server A won't receive broadcasts from server B. Solutions:
- Redis Pub/Sub: All servers publish to and subscribe from a shared Redis channel
- Sticky sessions: Route clients to the same server every time
- Managed service: Use Pusher, Ably, or similar
Event listeners and intervals can accumulate. Always clean up:
wss.on('close', () => {
clearInterval(interval); // Stop the heartbeat
});If a client can't keep up with messages, the send buffer grows. The ws library provides a bufferedAmount property to check if a socket is falling behind.
| Feature | ws (this project) |
Socket.IO |
|---|---|---|
| Transport | WebSocket only | WebSocket + HTTP fallback |
| Protocol | Standard WebSocket | Custom protocol |
| Reconnection | Manual | Built-in |
| Rooms/Channels | Manual (this project) | Built-in |
| Size | ~60KB | ~200KB+ |
| Compatibility | Standard WebSocket clients | Socket.IO clients only |
Socket.IO adds features on top of WebSockets but uses a proprietary protocol. The ws library gives you raw WebSocket access -- more control, less magic, closer to the metal.
You can test the WebSocket server using browser DevTools:
// In browser console
const ws = new WebSocket('ws://localhost:8000/ws');
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', matchId: 1 }));Or use a tool like wscat:
npx wscat -c ws://localhost:8000/ws
> {"type":"subscribe","matchId":1}