The feature that looks simple until two people use it
A whiteboard for one person is just a canvas with save/load. The moment a second person opens the same board, you have a distributed systems problem wearing a drawing-app costume: whose change wins, how fast does the other person see it, and — the part that's easy to skip — how do you make sure a cursor move in Project A's whiteboard doesn't leak into Project B's session on the same server.
What WKFGo reuses instead of building new
WKFGo already runs a WebSocket hub that pushes live board updates to every connected client. Whiteboards don't get a separate WebSocket server; they get a narrow, explicitly scoped slice of that same hub. Every whiteboard message is namespaced with a wb: prefix, and the hub's relay function only touches messages matching that prefix:
// relay re-broadcasts a client-sent message to everyone else in the sender's
// project room. Only whiteboard collaboration events ("wb:*" — scene deltas,
// pointer positions) are accepted; ProjectID/Actor are stamped server-side so
// a client can't spoof another room or user.
func (h *Hub) relay(sender *Client, raw []byte) {
if sender.projectID == 0 {
return
}
var ev Event
if err := json.Unmarshal(raw, &ev); err != nil || !strings.HasPrefix(ev.Type, "wb:") {
return
}
ev.ProjectID = sender.projectID
ev.Actor = sender.userID
...
}
Two details here are doing the actual security work. First, any message that isn't prefixed wb: is silently dropped — a client can't use this relay channel to smuggle arbitrary events into other clients' sessions. Second, ProjectID and Actor are overwritten server-side from the sender's authenticated connection, not read from whatever the client claims in the message body. A client sending a forged projectId in its payload gets ignored — the server already knows which room the connection belongs to, because that was established at connect time, not per-message.
Why relay, not persist-then-broadcast
Cursor positions and in-progress scene deltas are relayed directly to other clients in the same project room — they are deliberately not pushed through the same path as a task update, which also notifies webhooks and the external event log. The code comment is explicit about this: "cursor moves are not webhook material." A whiteboard session can generate dozens of pointer-position messages per second; treating each one as an auditable domain event would flood the notification pipeline with noise nobody wants a webhook for. Persistence of the actual board content happens separately, on save — the WebSocket relay is purely for the live, ephemeral "what is everyone doing right now" layer.
What happens to a slow client
If one collaborator's connection is lagging, the hub doesn't block everyone else waiting for that one client's send buffer to drain:
select {
case c.send <- data:
default:
// Slow/stuck client: drop the message rather than block the
// broadcaster. The client will resync on its next full fetch.
}
A dropped cursor-position message is invisible — the next one arrives a fraction of a second later and nobody notices a gap in a pointer trail. This only works because whiteboard relay messages are ephemeral positions, not authoritative state; dropping one doesn't corrupt anything, it just means the recipient's view of a fast-moving cursor is very slightly less smooth for one frame.
Why Excalidraw is pinned, not floating
The editor itself is Excalidraw, pinned at 0.17.6. That's not an oversight — 0.18 changed internals in a way that breaks under Create React App's build, and chasing the latest version isn't worth destabilizing collaboration for a canvas library upgrade nobody asked for. If you're integrating Excalidraw into your own CRA-based app, know that before you npm install the newest tag.
Common mistakes
Trusting client-supplied room or user IDs. Any relay design that reads "which room" from the message payload instead of the authenticated connection is one crafted message away from cross-project leakage.
Treating every real-time message as a persisted domain event. Pointer positions and provisional strokes don't belong in the same pipeline as things that trigger webhooks and notifications — that pipeline exists for state changes people and integrations care about, not 60-times-a-second cursor coordinates.
Blocking the broadcaster on a slow reader. A single stuck WebSocket client shouldn't be able to stall live updates for everyone else in the room; drop-and-resync is the right tradeoff for ephemeral data.
FAQ
Does this work across multiple backend replicas?
Live whiteboard relay stays local to whichever replica holds the WebSocket connections — cross-instance bridging is opt-in for board/notification events, but whiteboard scene deltas can run well over the 8 KB Postgres NOTIFY payload cap used for that bridge, so cross-instance whiteboard collaboration currently needs sticky sessions (the same client always routes to the same backend instance).
What happens if I lose connection mid-edit?
Relayed messages are lost, but the board's saved state isn't — reconnecting resyncs from the last save, not from the WebSocket stream.
Can someone outside the project see the whiteboard session?
No — the connection is scoped to a projectID established at connect time from the authenticated session, and relay only fans out within that same room.
Summary
Real-time collaboration on shared visual state isn't hard because of the drawing — it's hard because every message needs to be scoped to the right room, attributed to the right sender regardless of what the client claims, and cheap enough to drop without consequence when a reader falls behind.