DEV Community

Cover image for Building a Real-Time Whiteboard with WebSockets and Go
Yar Khan
Yar Khan

Posted on

Building a Real-Time Whiteboard with WebSockets and Go

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:

  1. The client makes a normal authenticated HTTP request (POST /live/:id/ticket) with its JWT.
  2. 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.
  3. The client opens the WebSocket with ?ticket=<that>.
  4. 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)
}
Enter fullscreen mode Exit fullscreen mode

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.
  • OriginPatterns rejects 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
}
Enter fullscreen mode Exit fullscreen mode

Each connection runs two goroutines:

  • a read loop that blocks on conn.Read and processes incoming messages,
  • a write loop that drains the client's send channel 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Two more gotchas came out of this:

  • The 32 KB default read limit. Images are base64 data URLs — easily hundreds of KB. coder/websocket silently 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

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 participants message 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 ended message. 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)