Live Cursor Tracker
Real-time shared cursor positions over WebSocket using Durable Objects and the Hibernation API
Broadcast cursor positions to every client in a shared room using a Durable Object and the WebSocket Hibernation API. Open multiple tabs to / and move your mouse to see colored cursors sync in real time.
Features
- GET / - Demo page with canvas and WebSocket client
- GET /ws/:room - WebSocket upgrade; one DO instance per room name
- Hibernation API -
acceptWebSocket,webSocketMessage,webSocketClose - Colored cursors - Each client gets a stable color and guest label
API Reference
GET /
Returns an HTML demo page with inline JavaScript that connects to /ws/:room.
Open http://localhost:8787/ in two browser tabs to test locally.
GET /ws/:room
Upgrades to WebSocket and joins the room's Durable Object.
room string (path, required)
Room name: letters, numbers, _, or - (max 64 characters).
Client messages (JSON)
Send cursor position updates:
{ "x": 120, "y": 340 }Server messages (JSON)
{ "type": "join", "id": "uuid", "color": "#3b82f6", "name": "Guest-a1b2" }
{ "type": "cursor", "id": "uuid", "x": 120, "y": 340, "color": "#3b82f6", "name": "Guest-a1b2" }
{ "type": "leave", "id": "uuid", "color": "#3b82f6", "name": "Guest-a1b2" }Error Codes
400- Invalid room name (INVALID_ROOM)426- Missing WebSocket upgrade header
Use Cases
- Learn Durable Object WebSocket hibernation patterns
- Prototype collaborative UI features (cursors, presence, cursors-on-canvas)
- Demo real-time broadcast without an external pub/sub service
Limitations
- No authentication; any client can join any room name
- Cursor state is ephemeral (not persisted to storage)
- Demo page only; production apps would use a custom frontend
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/live-cursor-tracker.
export const DEMO_HTML = `<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Live Cursor Tracker</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; } header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } #room { background: #1e293b; border: 1px solid #475569; border-radius: 6px; padding: 0.35rem 0.6rem; color: inherit; width: 10rem; } #status { font-size: 0.875rem; color: #94a3b8; } #canvas { position: relative; width: 100%; height: calc(100vh - 64px); overflow: hidden; cursor: crosshair; } .cursor { position: absolute; pointer-events: none; transform: translate(-50%, -50%); transition: left 80ms linear, top 80ms linear; } .cursor-label { font-size: 11px; padding: 2px 6px; border-radius: 4px; white-space: nowrap; margin-top: 4px; } .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff; margin: 0 auto; } </style></head><body> <header> <strong>Live Cursor Tracker</strong> <label>Room <input id="room" value="demo" /></label> <button id="connect">Connect</button> <span id="status">Disconnected</span> </header> <div id="canvas"></div> <script> const canvas = document.getElementById('canvas'); const statusEl = document.getElementById('status'); const roomInput = document.getElementById('room'); const connectBtn = document.getElementById('connect'); const peers = new Map(); let ws = null; let selfId = null; function renderPeer(id, data) { let el = peers.get(id); if (!el) { el = document.createElement('div'); el.className = 'cursor'; el.innerHTML = '<div class="dot"></div><div class="cursor-label"></div>'; canvas.appendChild(el); peers.set(id, el); } el.style.left = data.x + 'px'; el.style.top = data.y + 'px'; el.querySelector('.dot').style.background = data.color; el.querySelector('.cursor-label').textContent = data.name || id.slice(0, 6); el.querySelector('.cursor-label').style.background = data.color; } function removePeer(id) { const el = peers.get(id); if (el) { el.remove(); peers.delete(id); } } function connect() { if (ws) ws.close(); const room = roomInput.value.trim() || 'demo'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(protocol + '//' + location.host + '/ws/' + encodeURIComponent(room)); ws.onopen = () => { statusEl.textContent = 'Connected to room: ' + room; }; ws.onclose = () => { statusEl.textContent = 'Disconnected'; }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'join') { if (!selfId) selfId = msg.id; renderPeer(msg.id, { x: 40, y: 40, color: msg.color, name: msg.name }); } else if (msg.type === 'cursor') { renderPeer(msg.id, msg); } else if (msg.type === 'leave') { removePeer(msg.id); } }; canvas.onmousemove = (e) => { if (!ws || ws.readyState !== WebSocket.OPEN) return; const rect = canvas.getBoundingClientRect(); ws.send(JSON.stringify({ x: e.clientX - rect.left, y: e.clientY - rect.top })); }; } connectBtn.onclick = connect; connect(); </script></body></html>`;Deployment
Deploy
Durable Object migration is included in wrangler.json. No additional bindings required.
Local Development
cd apps/experiments/live-cursor-tracker
npm install
npm run devOpen http://localhost:8787/ in two tabs.
Configuration
| Binding | Purpose |
|---|---|
ROOMS | Durable Object namespace (CursorRoom class) |
Cloudflare Features Used
- Workers - Edge compute runtime
- Durable Objects - Room-scoped WebSocket coordination
- WebSocket Hibernation API - Efficient long-lived connections