On my learning platform, a teacher runs a live class: they draw on a shared whiteboard and up to a few dozen students watch it update in real time — strokes, shapes, pasted images, the works. The frontend is Excalidraw; the transport is plain WebSockets; the server is Go.
"Real-time collaborative whiteboard" sounds like it needs CRDTs, operational transforms, and a distributed backend. It doesn't — not for this shape of problem. This post walks through the actual architecture I shipped, the decisions that kept it simple, and the sharp edges nobody mentions (auth handshakes, image sync, backpressure, reconnects).
The key realization that simplified everything: only one person draws. The teacher is the single writer; students are read-only viewers. That one constraint deletes an entire category of hard problems.
Why WebSockets (and not polling or SSE)
Options for "push drawing updates to the browser":
- Polling — students ask "anything new?" every N ms. Wasteful, laggy, and bursty under a room of 50.
- Server-Sent Events — one-way server→client only. Would work for viewers, but I also need the teacher to send, and I want one mechanism.
- WebSockets — full-duplex, low overhead per message, one persistent connection. The teacher pushes scene updates up; the server fans them out to everyone. Natural fit.
I used the coder/websocket library on the Go side and the browser-native WebSocket on the client.
Problem #1: you can't authenticate a browser WebSocket the normal way
My API uses JWTs in an Authorization header. But the browser WebSocket constructor can't set headers. You get a URL and that's it. So how does the server know who is connecting and whether they're allowed into this class?
Putting the JWT in the query string (?token=...) is a bad idea — URLs land in logs, proxies, and browser history.
The pattern I used is a one-time ticket:
- The client makes a normal authenticated HTTP request (
POST /live/:id/ticket) with its JWT. - The server authorizes the join (is the class live? is this user the host, or a student enrolled in the class?), then mints a random 24-byte ticket, stores it in Redis with a 30-second TTL, keyed to the user + session.
- The client opens the WebSocket with
?ticket=<that>. - On connect, the server atomically consumes the ticket (Redis
GETDEL) and reads back who it belongs to.
// ServeWS — the WS upgrade handler (no JWT middleware; the ticket is the auth)
func (h *Handler) ServeWS(c *gin.Context) {
id, _ := uuid.Parse(c.Param("id"))
data, err := h.svc.ConsumeTicket(c.Request.Context(), c.Query("ticket"))
if err != nil || data.SessionID != id { // ticket must match this room
response.WriteError(c, http.StatusUnauthorized, "invalid or expired ticket")
return
}
conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{
OriginPatterns: h.originPatterns, // block cross-site WS hijacking
})
if err != nil {
return
}
h.hub.Serve(context.Background(), conn, data.SessionID, data.UserID, data.Name, data.IsHost)
}
Why this is nice:
- The ticket is single-use (consumed with
GETDEL, so two connections can't share one) and short-lived (30s — just long enough to open the socket). - It's bound to the session, so a ticket for class A can't open class B.
- The real authorization decision happens over authenticated HTTP, where it belongs. The WS handler just cashes the ticket.
-
OriginPatternsrejects WebSocket connections from origins I don't control — the WS equivalent of CSRF protection.
And critically, the ticket also carries isHost. The server decides who can draw; the client never gets to claim it.
Problem #2: the fan-out engine (rooms, goroutines, channels)
The server keeps an in-memory Hub with one room per live session. Each connected client is a small struct with a buffered send channel.
type client struct {
conn *websocket.Conn
isHost bool
send chan []byte // buffered; a writer goroutine drains it
// ...
}
type room struct {
clients map[*client]bool
scene json.RawMessage // latest full scene, replayed to joiners
files map[string]json.RawMessage // accumulated images
mu sync.Mutex
}
Each connection runs two goroutines:
- a read loop that blocks on
conn.Readand processes incoming messages, - a write loop that drains the client's
sendchannel and writes to the socket.
The read/write split matters: reads and writes on a WebSocket must not race, and a slow client's writes must never block the reader (or the whole room). The buffered channel decouples them.
Broadcasting is a fan-out over the room's clients:
func (r *room) broadcast(msg []byte, except *client) {
r.mu.Lock()
defer r.mu.Unlock()
for c := range r.clients {
if c == except {
continue
}
select {
case c.send <- msg: // enqueue
default: // buffer full → drop this frame (see backpressure)
}
}
}
Go's goroutines-and-channels model makes this genuinely pleasant: no callback spaghetti, no manual event loop — just blocking reads and a channel per client.
Problem #3: the sync model — why I don't need CRDTs
Here's the decision that keeps the whole thing simple. When the teacher draws, the client sends the entire current scene (all Excalidraw elements), not a diff:
sendScene(elements: unknown[]) {
this.send({ type: "scene-update", scene: elements });
}
The server stores that as the room's latest scene and rebroadcasts it. Students replace their canvas with it.
This is full-state, last-write-wins, and it works because there's a single writer. With only the teacher drawing:
- there are no conflicts to merge — nobody else is editing,
- a dropped frame doesn't corrupt anything — the next full scene supersedes it,
- a late joiner just needs the latest scene, not the history of operations.
No CRDT, no OT, no operation log. The trade-off is bandwidth (sending the whole scene each tick instead of a delta), which I control with throttling:
// Host: ~15 scene broadcasts/sec, with a trailing send so the final state always lands
const now = Date.now();
if (now - lastSent.current > 65) {
send();
} else {
clearTimeout(trailing.current);
trailing.current = setTimeout(send, 65);
}
Throttling to one update per ~65ms keeps it smooth without flooding the room, and the trailing timer guarantees the last stroke is always broadcast even if the teacher stops mid-throttle-window.
If I ever let multiple people draw at once, this model breaks and I'd reach for a CRDT. But I don't, so I don't.
Problem #4: late joiners and reconnects (server-authoritative snapshot)
A student opening the class 10 minutes in must see everything already drawn. A student whose wifi blips must not end up with a blank board.
Because the room holds the latest scene, this is trivial: on every connect, the server replays the current state as an init message before anything else.
// On connect, replay current files, then the scene
if f := r.filesJSON(); f != nil {
c.send <- mustJSON(wsOut{Type: "files", Files: f})
}
c.send <- mustJSON(wsOut{Type: "init", Scene: scene})
The client treats init exactly like a scene-update. So "join late" and "reconnect after a drop" are the same code path — the server is the source of truth, and it hands you the current picture the moment you arrive.
On the client, reconnect is exponential backoff, and each attempt fetches a fresh ticket (the old one is long expired):
private scheduleReconnect() {
const delay = this.backoff;
this.backoff = Math.min(this.backoff * 2, 15_000); // 1s → 2s → 4s … cap 15s
setTimeout(() => this.connect(), delay);
}
Since the server replays state on reconnect, a network blip is invisible to the student beyond a brief "reconnecting…" flicker.
Problem #5: images don't travel with the drawing
This one cost me an afternoon. In Excalidraw, when you paste an image, the element on the canvas only holds a file id — the actual image bytes live in a separate files map. If you sync only the scene elements, students see an image-shaped hole where the picture should be.
So images get their own message type. The teacher sends new files once, the moment they appear; the server accumulates them in the room and replays them to joiners before the scene (so the referenced file exists when the element arrives):
// Client: detect files not seen before and send them once each
const files = api?.getFiles() ?? {};
const fresh: Record<string, unknown> = {};
for (const [id, file] of Object.entries(files)) {
if (!sentFileIds.current.has(id)) {
fresh[id] = file;
sentFileIds.current.add(id);
}
}
if (Object.keys(fresh).length > 0) socketRef.current?.sendFiles(fresh);
Two more gotchas came out of this:
-
The 32 KB default read limit. Images are base64 data URLs — easily hundreds of KB.
coder/websocketsilently rejects messages over 32 KiB by default, so image messages just… vanished. The fix is one line:conn.SetReadLimit(16 << 20)(16 MB). Know your library's limits. - Re-sending on reconnect. If the host reconnects, the client re-sends all its files, so the server (and any viewer that missed them) is guaranteed to have them:
onStatus: (s) => {
if (s === "open" && isHost) {
const files = api.getFiles();
const ids = Object.keys(files);
if (ids.length > 0) socketRef.current?.sendFiles(files);
}
}
Problem #6: backpressure and safety
A room is only as fast as its slowest client. If one student is on terrible wifi, I can't let their clogged socket stall the teacher or everyone else. Three mechanisms handle this:
1. Drop, don't block. The broadcast uses a non-blocking select (shown earlier). If a client's send buffer is full, the frame is dropped rather than blocking the room. This is safe because scene updates are full-state — the next one supersedes the dropped one, and worst case the client catches up on the following tick.
2. Server-side write authorization. Students are viewers, full stop — and I don't trust the client to enforce that. The read loop rejects any draw/clear/image message from a non-host, no matter what their browser sends:
case "scene-update":
if !c.isHost {
continue // students cannot draw, whatever their client sends
}
c.room.setScene(in.Scene)
c.room.broadcast(...)
3. A per-room connection cap. Each connection is two goroutines plus a buffer; unbounded rooms are a memory/DoS risk. New non-host connections past a cap (300) are rejected before any goroutines spawn. The host is exempt so a teacher is never locked out of their own class.
Presence and auto-ending
Two smaller features fall out of the same room state:
-
Presence. On every join/leave the room broadcasts a
participantsmessage with a distinct user count and the de-duplicated list of student names, so the teacher sees who's in class. -
Auto-end. The room counts the host's connections. When the last one drops, it arms a 2-minute grace timer (to absorb refreshes and tab-switches). If the host doesn't come back, the class auto-ends and everyone gets an
endedmessage. A refresh cancels the timer, so it doesn't fire on a blip.
Being honest about the limits
This design is deliberately a single in-memory instance. The rooms live in one Go process's memory. That's perfect for the scale I need (dozens of students, one teacher per class), and it's simple to reason about. But it has a clear ceiling:
-
It doesn't scale horizontally as-is. If I ran two backend instances behind a load balancer, a teacher on instance A and a student on instance B wouldn't share a room. The fix is a Redis Pub/Sub fan-out: instead of
broadcast()writing straight to local clients, publish to a per-room channel that every instance subscribes to. I left a clean seam for that but haven't needed it. - Restart loses live rooms. In-memory state vanishes on deploy. I handle the DB side (a startup sweep ends orphaned "live" sessions) but any in-flight drawing is gone. Acceptable for my use; worth knowing.
Naming your limits up front is part of the design, not an afterthought.
Takeaways
- Find the constraint that deletes the hard problem. "Only one writer" turned a CRDT-shaped problem into last-write-wins full-state sync. Look for that constraint before reaching for heavy machinery.
- Authenticate WebSockets with a one-time ticket. Browsers can't set WS headers; do the auth over HTTP and hand back a short-lived, single-use, session-bound ticket.
- Make the server authoritative over state. Storing the latest scene makes late-join and reconnect the same trivial "replay on connect" path.
- Drop frames instead of blocking. With full-state messages, a dropped update is harmless and a slow client never stalls the room.
- Enforce permissions on the server. "Students can't draw" is a server rule, not a UI state.
- Know your library's defaults. A 32 KB read limit silently ate my images until I found it.
The whole thing is a few hundred lines of Go and TypeScript: one room per class, one writer, full-state broadcast, snapshot-on-connect. Boring in the best way — and boring is exactly what you want holding up a live classroom.
Top comments (0)