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

Presence

What presence means

Presence answers: “Who is looking at this right now?” Slack shows green dots. Google Docs shows colored cursors. GitHub shows “X people viewing this file.”

For our task board: when Alice opens board-1, Bob sees “Alice is viewing this board.” When Alice starts editing a task title, Bob sees “Alice is typing.”

Tracking who is in a room

Extend the room model to track user info, not just socket connections:

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

interface PresenceEntry {
  ws: WebSocket;
  userId: string;
  name: string;
  joinedAt: number;
}

const presence = new Map<string, PresenceEntry[]>(); // roomId → entries

export function addPresence(roomId: string, ws: WebSocket, userId: string, name: string): void {
  if (!presence.has(roomId)) presence.set(roomId, []);
  const entries = presence.get(roomId)!;

  // Prevent duplicate entries for the same user (multiple tabs)
  const existing = entries.find((e) => e.userId === userId && e.ws === ws);
  if (existing) return;

  entries.push({ ws, userId, name, joinedAt: Date.now() });
}

export function removePresence(
  roomId: string,
  ws: WebSocket,
): { userId: string; name: string } | null {
  const entries = presence.get(roomId);
  if (!entries) return null;

  const index = entries.findIndex((e) => e.ws === ws);
  if (index === -1) return null;

  const removed = entries.splice(index, 1)[0];
  if (entries.length === 0) presence.delete(roomId);

  return { userId: removed.userId, name: removed.name };
}

export function removePresenceFromAll(ws: WebSocket): void {
  for (const [roomId, entries] of presence) {
    const index = entries.findIndex((e) => e.ws === ws);
    if (index !== -1) {
      entries.splice(index, 1);
      if (entries.length === 0) presence.delete(roomId);
    }
  }
}

export function getPresence(roomId: string): { userId: string; name: string }[] {
  const entries = presence.get(roomId) ?? [];
  // Deduplicate by userId (a user with multiple tabs counts once)
  const seen = new Set<string>();
  return entries
    .filter((e) => {
      if (seen.has(e.userId)) return false;
      seen.add(e.userId);
      return true;
    })
    .map((e) => ({ userId: e.userId, name: e.name }));
}

Presence events

When a user joins or leaves, broadcast the updated presence list:

// In handleMessage — update the "join" handler
case "join": {
  joinRoom(message.boardId, ws);
  addPresence(message.boardId, ws, user.userId, user.name);

  const viewers = getPresence(message.boardId);

  // Tell the joining user who is here
  ws.send(JSON.stringify({
    type: "presence",
    boardId: message.boardId,
    viewers,
  }));

  // Tell everyone else that this user joined
  broadcastToRoom(message.boardId, {
    type: "user_joined",
    user: { id: user.userId, name: user.name },
    viewers,
  }, ws);
  break;
}

// In handleDisconnect
function handleDisconnect(ws: WebSocket): void {
  // Notify rooms about the departure
  for (const [roomId] of presence) {
    const removed = removePresence(roomId, ws);
    if (removed) {
      broadcastToRoom(roomId, {
        type: "user_left",
        user: { id: removed.userId, name: removed.name },
        viewers: getPresence(roomId),
      });
    }
  }
  leaveAllRooms(ws);
}

Typing indicators

Typing indicators are ephemeral presence events. The client sends “I am typing” when the user starts editing. The server broadcasts it to the room. No persistence needed.

// In handleMessage
case "typing": {
  if (!message.boardId) break;
  broadcastToRoom(message.boardId, {
    type: "user_typing",
    user: { id: user.userId, name: user.name },
  }, ws);
  break;
}

case "stop_typing": {
  if (!message.boardId) break;
  broadcastToRoom(message.boardId, {
    type: "user_stopped_typing",
    user: { id: user.userId, name: user.name },
  }, ws);
  break;
}

On the client, debounce the typing event — do not send it on every keystroke:

let typingTimeout: ReturnType<typeof setTimeout>;

input.addEventListener("input", () => {
  ws.send(JSON.stringify({ type: "typing", boardId: "board-1" }));

  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {
    ws.send(JSON.stringify({ type: "stop_typing", boardId: "board-1" }));
  }, 2000); // Stop typing after 2 seconds of inactivity
});

Multiple tabs

A user might have the same board open in multiple tabs. Each tab has its own WebSocket connection. The presence system should show the user once, not once per tab.

The getPresence function handles this with deduplication by userId. Internally, each tab is a separate PresenceEntry, but the public API returns unique users.

When the user closes one tab, the other tab’s connection keeps them present. Only when all connections for a user are closed do they disappear from the presence list.

Exercises

Exercise 1: Implement presence tracking. Join a board with two clients (as different users). Verify the presence list shows both users.

Exercise 2: Implement typing indicators. Start “typing” from one client. Verify the other client receives the event.

Exercise 3: Open two tabs as the same user. Verify the user appears once in the presence list. Close one tab. Verify the user is still present (the other tab’s connection is still open).

Why do typing indicators use a debounce instead of sending on every keystroke?

← Pub/Sub Scaling Real-Time →

© 2026 hectoday. All rights reserved.