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

> Gortex's WebSocket Hub is the actor model in Go: one goroutine owns all state, callers reach it over channels instead of locks, and slow consumers are dropped.

Source: https://sheng.page/en/posts/gortex-websocket-actor-hub/ · Published: 2026-05-31 · Author: Sheng

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.

> Prerequisite: [Goroutines and Channels](/en/posts/go-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:

```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:

```go
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](/en/posts/gortex-metrics-and-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:

```go
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 must let in-flight messages land 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 — 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.

The hub also has two security defaults (details in [The Security Middleware Suite: CORS / CSRF / JWT / Rate Limit / Binder](/en/posts/gortex-security-middleware-suite/)): a 64 KiB per-message cap against oversize-message DoS, and the `Authorizer` — 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.

Source: [yshengliao/gortex](https://github.com/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.