hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Real-Time APIs with @hectoday/http

Beyond Request-Response

  • Why Real-Time Matters
  • Project Setup

Polling

  • Short Polling
  • Long Polling

Server-Sent Events

  • How SSE Works
  • Building an SSE Endpoint
  • Event Types and IDs
  • SSE in Practice

WebSockets

  • How WebSockets Work
  • Building a WebSocket Server
  • Rooms and Broadcasting
  • Authentication on WebSockets
  • Handling Disconnects and Reconnection

Patterns and Architecture

  • Pub/Sub
  • Presence
  • Scaling Real-Time

Putting It All Together

  • Choosing the Right Approach
  • Capstone: Live Task Board

Rooms and Broadcasting

The room model

Not every connected client should receive every event. A user viewing board-1 does not need events from board-2. Rooms group clients by context: all users viewing the same board are in the same room.

Board 1 room: [Alice, Bob]
Board 2 room: [Carol]

When a task is created on board 1, only Alice and Bob receive the event. Carol is in a different room.

Implementing rooms

// src/rooms.ts
import { WebSocket } from "ws";

const rooms = new Map<string, Set<WebSocket>>();

export function joinRoom(roomId: string, ws: WebSocket): void {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);
}

export function leaveRoom(roomId: string, ws: WebSocket): void {
  const room = rooms.get(roomId);
  if (room) {
    room.delete(ws);
    if (room.size === 0) rooms.delete(roomId);
  }
}

export function leaveAllRooms(ws: WebSocket): void {
  for (const [roomId, room] of rooms) {
    room.delete(ws);
    if (room.size === 0) rooms.delete(roomId);
  }
}

export function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket): void {
  const room = rooms.get(roomId);
  if (!room) return;

  const message = JSON.stringify(data);
  for (const ws of room) {
    if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
      ws.send(message);
    }
  }
}

export function getRoomSize(roomId: string): number {
  return rooms.get(roomId)?.size ?? 0;
}

Key details:

exclude parameter. When Alice moves a task, broadcast the update to Bob but not back to Alice — she already knows about her own action. The exclude parameter skips the sender.

readyState check. Only send to sockets that are open. A socket might be in the process of closing.

Cleanup. leaveAllRooms removes a socket from every room it joined. Called on disconnect.

Handling join and leave messages

Update handleMessage and handleDisconnect:

import { joinRoom, leaveRoom, leaveAllRooms, broadcastToRoom, getRoomSize } from "./rooms.js";

function handleMessage(ws: WebSocket, message: any): void {
  switch (message.type) {
    case "join": {
      if (!message.boardId) {
        ws.send(JSON.stringify({ error: "boardId is required" }));
        return;
      }
      joinRoom(message.boardId, ws);
      ws.send(
        JSON.stringify({
          type: "joined",
          boardId: message.boardId,
          viewers: getRoomSize(message.boardId),
        }),
      );
      // Notify others
      broadcastToRoom(
        message.boardId,
        {
          type: "user_joined",
          viewers: getRoomSize(message.boardId),
        },
        ws,
      );
      break;
    }

    case "leave": {
      if (message.boardId) {
        leaveRoom(message.boardId, ws);
        broadcastToRoom(message.boardId, {
          type: "user_left",
          viewers: getRoomSize(message.boardId),
        });
      }
      break;
    }

    case "ping":
      ws.send(JSON.stringify({ type: "pong" }));
      break;

    default:
      ws.send(JSON.stringify({ error: `Unknown type: ${message.type}` }));
  }
}

function handleDisconnect(ws: WebSocket): void {
  // Leave all rooms this socket was in
  leaveAllRooms(ws);
}

Broadcasting task events through rooms

Update the task routes to broadcast through rooms (in addition to SSE):

import { broadcastToRoom } from "../rooms.js";

// After creating a task:
broadcastToRoom(boardId, { type: "task_created", task });

// After moving a task:
broadcastToRoom(boardId, { type: "task_moved", task });

// After deleting a task:
broadcastToRoom(boardId, { type: "task_deleted", taskId: c.params.id });

Now both SSE clients and WebSocket clients receive updates. SSE for one-way subscriptions, WebSocket for interactive features.

Try it

# Terminal 1: Connect and join board-1
wscat -c ws://localhost:3000
> {"type":"join","boardId":"board-1"}
# < {"type":"joined","boardId":"board-1","viewers":1}

# Terminal 2: Connect and join board-1
wscat -c ws://localhost:3000
> {"type":"join","boardId":"board-1"}
# < {"type":"joined","boardId":"board-1","viewers":2}
# Terminal 1 also receives: {"type":"user_joined","viewers":2}

# Terminal 3: Create a task (REST)
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Live update!","listId":"list-todo"}'

# Terminals 1 and 2 both receive: {"type":"task_created","task":{...}}

Exercises

Exercise 1: Implement rooms. Join two clients to the same board, one to a different board. Create a task. Only the matching room should receive the event.

Exercise 2: Test the exclude parameter. When a client creates a task via WebSocket (if you implement that), it should not receive its own broadcast.

Exercise 3: Disconnect a client. Verify leaveAllRooms cleans up and the room size decreases.

Why does broadcastToRoom have an exclude parameter?

← Building a WebSocket Server Authentication on WebSockets →

© 2026 hectoday. All rights reserved.