Real-time multi-user diagram editor. Nodes, edges, and text sync over WebSocket. Dragging always feels local regardless of network latency.
make dev # Windows — opens two terminal windows automaticallyOr manually in two terminals:
# Terminal 1 — backend
cd backend
py -m uvicorn app:app --port 8000 --reload
# Terminal 2 — frontend
cd frontend
npm install # first time only
npm run devOpen http://localhost:5173 in two browser windows side by side.
Everything stored on the server is one of three types:
The unit of change is an op — a small JSON object describing one atomic mutation. There are six:
type |
Required fields |
|---|---|
add_node |
nodeId, x, y, text |
move_node |
nodeId, x, y |
edit_text |
nodeId, text |
delete_node |
nodeId |
add_edge |
edgeId, fromNodeId, toNodeId |
delete_edge |
edgeId |
Client → server (wrapped in a client envelope):
{
"clientId": "c1",
"localSeq": 7,
"op": { "type": "move_node", "nodeId": "n1", "x": 200, "y": 80 }
}Server → all clients (with a server-assigned sequence number):
{
"seq": 42,
"clientId": "c1",
"localSeq": 7,
"op": { "type": "move_node", "nodeId": "n1", "x": 200, "y": 80 }
}When a client receives a message whose clientId matches its own, it treats it as an ack — the op was already applied locally, so only the server seq is recorded. When clientId differs, the op is a remote change and is applied to the local state.
Every op is applied to the local Zustand store immediately (before the network round-trip). This means the UI is always responsive. The server's broadcast serves as the convergence point, not the trigger for local rendering.
Strategy: optimistic local apply + server-sequenced last-write-wins (LWW).
The server applies ops in arrival order, assigns a monotonically increasing seq, and broadcasts to all clients. No OT, no CRDT. The last op to reach the server on any given field wins. Conflicts produce a visible snap rather than silent data loss; both clients always converge to identical state.
Undo is local-only: each op pushes an inverse onto a per-client undo stack. Ctrl+Z applies the inverse and sends it to the server as a normal op.
Node dragging never touches the network mid-drag. handleMouseMove writes directly to the local store at 60 fps; a single move_node op fires only on mouse-up. Every other action (add, delete, text edit, connect) is applied optimistically before the round-trip. Network throttling only delays when the other client sees the result — local responsiveness is unaffected.
Holds up. ✓
On disconnect the client keeps its current optimistic state visible — the user continues to see their own unsent edits. Any ops made while offline are buffered in a sendQueue inside WSClient.
On reconnect the server sends a full full_state snapshot. The client calls mergeServerState:
- Snapshot the current
pendingOps(not yet acked). - Reset store to the server snapshot (includes remote changes made during downtime).
- Re-apply all pending ops on top — the user's offline work reappears immediately.
- Flush
sendQueueto the server.
The server applies the replayed ops and broadcasts acks. Both clients converge.
Edge cases:
- Remote client moved the same node → LWW; both snap to the same winner.
- Remote client deleted a node you edited offline → server ignores the stale
edit_text; node stays gone, no broken state. - Undo stack is cleared on reconnect (server state may have diverged; replaying stale inverses would corrupt the document — see Cut for time below).
Holds up; no silent data loss. ✓
Browsers do not suspend WebSocket connections for backgrounded tabs. Incoming ops are applied to the store as they arrive. On returning to the tab, React renders the already-up-to-date state — no stale snapshot, no refresh required. The stale-cursor cleanup timer (setInterval, 5 s) gets throttled to ~1 min by Chrome when backgrounded, which only affects the cursor-fade display, not diagram state.
Holds up. ✓
| Scenario | What happens |
|---|---|
| Both clients drag the same node | Each drag is local. On mouse-up both send move_node; LWW wins; both clients snap to the same final position. |
| Both clients edit the same text | Both send edit_text on blur/Enter; LWW wins; both clients see the same text. |
| Client A deletes while Client B is editing | applyServerOp detects delete_node for editingNodeId and exits edit mode on B. B's queued edit_text reaches the server and is silently ignored (node gone). Clean exit, no broken UI. |
None of these produce silent data loss or an inconsistent UI state.
Holds up. ✓
Sender side: all 50 ops are applied optimistically in rapid succession (instant) and queued. The send-queue flushes as fast as the socket allows. No jank on the sender.
Receiver side: incoming onmessage events are not processed immediately. Each message is pushed into a buffer; a single queueMicrotask callback drains the entire buffer synchronously. Because all Zustand set() calls happen within the same synchronous task, React 18's automatic batching collapses them into one render instead of 50. On localhost a burst of 50 ops appears as a single frame update.
Holds up. ✓
| Decision | Reason |
|---|---|
| No OT / CRDT | LWW is sufficient for a canvas tool where simultaneous edits to the exact same field are rare and a visible snap is an acceptable outcome. Full OT (like ShareDB) would add substantial complexity for marginal gain on a whiteboard. |
| Drag not streamed to other clients | The other client sees the node teleport on drop, not move smoothly during drag. Streaming 60 mouse-move ops/sec per client would flood slow connections and require server-side rate-limiting and client-side interpolation. One op on mouse-up is the right trade-off. |
| Simple Bézier edge routing | Edges connect right-center → left-center with a cubic curve. Auto-routing around obstacles and multi-port selection are UX polish, not correctness requirements. |
| Item | What a production version would do |
|---|---|
| No persistence | Persist the op-log to a database; replay on server startup. Current server restart resets everything. |
| No rooms / auth | Every client shares one global diagram. A real deployment needs session tokens, room IDs, and access control. |
| Undo stack lost on reconnect | The undo stack is cleared when mergeServerState runs because local inverse ops reference a pre-reconnect state that may have diverged. Correct behaviour would version each undo entry against the server seq and discard only entries that are now inconsistent. |
| No presence labels | Remote cursors are rendered (coloured arrow shapes, fade out after 10 s of inactivity) but carry no name or avatar. |
| No conflict UI | When LWW overwrites your edit, the snap is silent. A production tool would briefly highlight the node and indicate whose edit won. |