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?