Capstone: Live Task Board
What we built
A real-time collaborative task board with four layers of communication:
| Layer | Protocol | Direction | Purpose |
|---|---|---|---|
| CRUD | REST | Request-response | Create, update, delete tasks |
| Board updates | SSE | Server → client | Push task changes to viewers |
| Collaboration | WebSocket | Bidirectional | Presence, typing indicators |
| Fallback | Polling | Request-response | Environments without SSE/WS |
The complete architecture
Browser
│
├─ REST (fetch)
│ POST /tasks → create task → publish event
│ PATCH /tasks/:id/move → move task → publish event
│ DELETE /tasks/:id → delete task → publish event
│
├─ SSE (EventSource)
│ GET /boards/:boardId/events
│ ← task_created, task_moved, task_deleted
│ ← heartbeat comments (keep-alive)
│ → automatic reconnection with Last-Event-ID
│
└─ WebSocket (ws://)
→ join, leave, typing, stop_typing
← user_joined, user_left, user_typing
← presence (viewer list)
→ ping/pong (heartbeat)
Server
│
├─ Event Bus (pub/sub)
│ publish("task_created", { task }) → SSE subscriber, WebSocket subscriber
│
├─ Rooms
│ joinRoom(boardId, ws) → track connections per board
│ broadcastToRoom(boardId, data) → send to all in room
│
├─ Presence
│ addPresence(boardId, ws, userId) → track who is viewing
│ getPresence(boardId) → list of viewers
│
└─ SSE Client Registry
addClient(boardId) → track SSE connections
broadcast(boardId, data) → send to all SSE clients The event flow
When Alice creates a task:
- Alice’s browser sends
POST /tasks(REST) - Server creates the task in the database
- Server calls
publish(boardId, "task_created", { task })(event bus) - SSE subscriber receives the event → pushes to all SSE clients watching this board
- WebSocket subscriber receives the event → broadcasts to all WebSocket clients in this board’s room
- Bob’s browser receives the event (via SSE or WebSocket) and updates the UI
Alice’s REST response includes the created task (standard 201). Bob receives the task via the real-time channel. Both have the same state.
Project structure
src/
app.ts # Hectoday HTTP setup, REST routes
server.ts # HTTP + WebSocket server
db.ts # Schema, seed data
event-bus.ts # Pub/sub event bus
sse.ts # SSE client registry, broadcast, event buffer
ws-server.ts # WebSocket connection handler, auth
rooms.ts # Room join/leave/broadcast
presence.ts # User presence tracking
long-poll.ts # Long polling (fallback)
routes/
tasks.ts # CRUD + publish events
events.ts # SSE endpoint
boards.ts # Board listing Test the complete system
npm run dev
# === REST ===
curl http://localhost:3000/boards/board-1/tasks
# === SSE — Terminal 1 ===
curl -N http://localhost:3000/boards/board-1/events
# Receives: data: {"type":"connected"}
# === WebSocket — Terminal 2 ===
wscat -c ws://localhost:3000
> {"type":"join","boardId":"board-1"}
# Receives: {"type":"presence","viewers":[...]}
# === Create a task — Terminal 3 ===
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Live from the capstone!","listId":"list-todo"}'
# Terminal 1 (SSE): data: {"type":"task_created","task":{...}}
# Terminal 2 (WS): {"type":"task_created","task":{...}}
# === Typing indicator ===
# Terminal 2:
> {"type":"typing","boardId":"board-1"}
# Other WebSocket clients receive: {"type":"user_typing",...}
# === Move a task ===
curl -X PATCH http://localhost:3000/tasks/task-1/move \
-H "Content-Type: application/json" \
-d '{"listId":"list-done","position":0}'
# SSE and WS clients receive: {"type":"task_moved","task":{...}} What you understand now
Real-time communication comes down to a few patterns:
Polling is the simplest but least efficient. Use it as a fallback.
SSE is the right choice for server-to-client push. Automatic reconnection, event types, resumption — built into the browser.
WebSockets are for bidirectional communication. Chat, presence, typing indicators — features where both sides send messages.
Pub/sub decouples event producers from consumers. Add new delivery mechanisms (webhooks, push notifications) without changing existing code.
Rooms scope broadcasts to relevant clients. A user on board-1 does not receive board-2 events.
Presence tracks who is online. Deduplicate by user (multiple tabs), clean up on disconnect, broadcast join/leave events.
Heartbeats detect dead connections. Ping-pong for WebSockets, comment lines for SSE.
Reconnection handles network interruptions. Automatic for SSE, manual with exponential backoff for WebSockets. Resync state after reconnecting.
These patterns transfer to any real-time system: Slack, Discord, Figma, Google Docs. The protocols and libraries change. The patterns are the same.
Challenges
Challenge 1: Add cursor tracking. In a collaborative task editor, show where each user’s cursor is. Send cursor position via WebSocket, broadcast to the room, render colored cursors for each user.
Challenge 2: Add optimistic updates. When Alice creates a task, show it in her UI immediately (before the server responds). If the server rejects it, roll back. This makes the UI feel instant.
Challenge 3: Add offline support. Queue mutations when the user is offline. When the connection is restored, replay the queue. Resolve conflicts with server state.
Challenge 4: Add WebSocket-based CRUD. Instead of REST for mutations, send task operations via WebSocket. The server processes them and broadcasts the result. Compare the tradeoffs with the REST + pub/sub approach.
Why does the task board use REST for mutations instead of sending them via WebSocket?
What is the most important real-time pattern from this course?