The WebSocket Hub: Go's Take on the Actor Model

Gortex’s WebSocket Hub is an event loop on its own goroutine; every access to the clients map goes through a channel. It’s the actor model in Go, communication instead of locks.

Correction (2 November 2026, 18:45 Taipei): the Hub.Run snippet is now marked as an excerpt with a link to the full function; shutdown is described as best effort, since broadcasts drained during the grace window are not guaranteed to arrive; Authorizer is described as an optional hook that is nil by default. Suggested by Codex (gpt-6-astra), checked by Claude.

Prerequisite: Goroutines and Channels

Hub.Run: a single-goroutine loop

The goroutines post described the actor mindset; here’s the implementation. Hub.Run() is a for { select } living permanently on its own goroutine, and all state (the clients map, messageTypes) is touched only by it. What follows is an excerpt that leaves out lifecycle details such as defer close(h.shutdownDone) and broadcast completion signals; the full function is in hub.go:

func (h *Hub) Run() {
    for {
        select {
        case req := <-h.register:    h.registerClient(req.client); req.result <- true
        case req := <-h.unregister:  h.unregisterClient(req.client); close(req.done)
        case op := <-h.broadcast:    h.broadcastMessage(op.msg)
        case req := <-h.clientCount: req.response <- len(h.clients)
        case req := <-h.metricsReq:  req.response <- h.snapshotMetrics()
        case <-h.shutdown:           h.runShutdown(); return
        }
    }
}

clients is a plain map[*Client]bool with no lock — because only the Run goroutine ever touches it.

Channels instead of locks

To do anything from outside, you send a request into a channel rather than touching the map directly. Want the connection count? Send a request carrying a response channel and wait:

func (h *Hub) GetConnectedClients() int {
    req := clientRequest{response: make(chan int)}
    select {
    case h.clientCount <- req:
        return <-req.response
    case <-h.shutdown:
        return 0
    }
}

RegisterClient goes further: it blocks until the hub has actually recorded the client (its result channel is buffered to 1), so callers don’t “sleep and pray”. Registration and shutdown are mutually exclusive, so a client’s send channel is closed exactly once. Fold the synchronisation into channels and the state needs no lock.

Note the cross-goroutine counters (totalConnections, messagesSent…) still use atomic.Int64 — any goroutine reads them, so atomics are required; only the messageTypes map, touched by one goroutine, goes lock-free. Even inside the actor, the tool is chosen by shape — the mirror image of Concurrent Counting and Resilience: Sharded Metrics and a Circuit Breaker.

Slow consumers: drop via select-default

The actor model’s biggest risk is a slow consumer stalling the whole loop. Gortex’s answer is “full means dropped”. On broadcast, for each client:

select {
case client.send <- message:
    h.messagesSent.Add(1)
default:
    h.forcedDisconnects.Add(1) // couldn't enqueue (buffer full)
    h.logger.Warn("client send full, closing", zap.String("client_id", client.ID))
    go h.removeClient(client)  // evict the slow one
}

Broadcast itself is fire-and-forget too: if the broadcast channel (buffered to 256) is full, the message is dropped and droppedBroadcasts++. The rule is that a slow consumer must never block a producer — and both drops and forced disconnects are recorded in metrics, instead of becoming invisible log-only events.

Graceful shutdown

Shutdown should notify clients as far as it can without freezing anyone. shutdownOnce sync.Once guarantees close(h.shutdown) happens exactly once. runShutdown runs on the hub goroutine: first it sends a 1001 (Going Away) close frame to every client, then, and this is what matters most, it uses serveDuringGrace(500ms) to keep servicing channels during the grace window rather than a bare time.Sleep, so concurrent Register / GetMetrics calls aren’t frozen. An empty hub skips the grace and shuts down at once; only after the window does it close every client’s send channel.

That is best effort. Messages already in a client’s send channel ahead of the close notice do go out, but broadcasts drained from the broadcast channel during the grace window are queued behind it, and the client’s WritePump sends the close frame and returns as soon as it reads the close notice, so they are never written. Guaranteeing delivery of accepted broadcasts would mean refusing new ones, draining, waiting for the writes to finish, and only then sending close.

The hub also has one security default and one optional hook (details in The Security Middleware Suite: CORS / CSRF / JWT / Rate Limit / Binder). The 64 KiB per-message cap against oversize-message DoS is on by default. The Authorizer is nil by default and has to be set by the application; without one, allowing the private message type lets any client message any user. Once it is set, a private message’s Target is resolved before the Authorizer runs, so the policy sees the final recipient.

Notes

  • Hub.Run() is a single-goroutine event loop; the clients map is touched only by it, so it needs no lock; that’s the actor model in Go.
  • Outside code interacts via “send a request + wait on a response channel”; RegisterClient reports synchronously, no sleep needed.
  • Cross-goroutine counters still use atomic; only the single-goroutine map goes lock-free (save only when the shape allows).
  • Slow consumers are “drop when full + force-disconnect”, never blocking producers; drops and disconnects both land in metrics.
  • Graceful shutdown uses sync.Once plus a grace window that keeps servicing channels (not a sleep); an empty hub shuts down instantly, and messages already queued ahead of the close notice are sent, while broadcasts drained during the grace window are not.

Source: yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.25 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.