Skip to content

About

Express + Postgres live match commentary with websocket

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Sportz WebSockets

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.

Why WebSockets?

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]

The WebSocket Protocol

Connection Lifecycle

  1. HTTP Upgrade Handshake: The client sends a regular HTTP request with an Upgrade: websocket header. The server responds with 101 Switching Protocols, and the connection transitions from HTTP to WebSocket.

  2. Persistent Connection: After the upgrade, the TCP connection stays open. Both client and server can send frames (messages) independently.

  3. Close: Either side can initiate a close handshake. The connection terminates after a brief exchange of close frames.

Frame Structure

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.

How This Project Uses WebSockets

Server Setup

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.

Sending Messages

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));
}

Receiving Messages

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
  }
});

Connection State

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.

Key WebSocket Concepts Demonstrated

1. Heartbeat (Ping/Pong)

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 terminated

This 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.

2. Subscription Model

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 this

This is a simplified version of what services like Socket.IO call "rooms" or "channels."

3. Broadcast vs Targeted Broadcast

  • 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.

4. JSON Message Protocol

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.

5. Max Payload Limit

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.

6. Error Handling

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.

WebSocket vs Server-Sent Events (SSE)

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.

Common WebSocket Pitfalls

1. Reconnection Logic

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
  };
}

2. Scaling Across Multiple Servers

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

3. Memory Leaks

Event listeners and intervals can accumulate. Always clean up:

wss.on('close', () => {
  clearInterval(interval); // Stop the heartbeat
});

4. Backpressure

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.

The ws Library vs Socket.IO

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.

Testing WebSockets

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}

Further Reading

About

Express + Postgres live match commentary with websocket

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages