39.4 Managing Multiple WebSocket Connections with a Hub
Right, so you’ve got a single WebSocket connection working. It’s cute. You can send “hello” and receive “world.” Now, let’s get real. The whole point of this technology is to handle many connections, all talking to each other in real-time. You’re not building a walkie-talkie for two people; you’re building a party line. And the moment you have more than a handful of clients, the naïve approach—a global slice of connections and a for loop to broadcast—will absolutely fall on its face. It’ll be slow, prone to race conditions, and about as elegant as a donkey on roller skates.
The industrial-strength solution, the one you actually use in production, is a central coordination point: a Hub. This isn’t just a pattern; it’s the pattern. It’s the grand central station for your messages, responsible for tracking who’s online, shoveling data to the right places, and ensuring everything happens without a catastrophic deadlock.
The Anatomy of a Hub
At its core, a Hub is just a glorified registry. But its power comes from how it uses Go’s concurrency primitives to manage that registry safely. Here’s the basic structure. We define types for our clients and the hub itself to keep things clean.
// Client represents a single WebSocket connection.
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub holds the set of active clients and broadcasts messages to them.
type Hub struct {
clients map[*Client]bool // Tracks registered clients
broadcast chan []byte // Inbound messages from clients
register chan *Client // Register requests from clients
unregister chan *Client // Unregister requests from clients
}
See what we did there? We didn’t just use a map and a mutex. We used channels for all the operations. This is the critical insight. Instead of having a hundred goroutines (one per client) all trying to lock a mutex to add or remove themselves from the map, they just shout their intent into a channel. A single, solitary goroutine—the Hub’s Run method—handles all these requests sequentially. This makes the whole thing inherently thread-safe. No locks, no fuss.
The Engine Room: The Hub’s Run Loop
This is where the magic happens. The hub’s Run method is a single goroutine that acts as the central nervous system, listening on all those channels and taking the appropriate action. It’s a thing of beauty.
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
// A new client wants to join the party.
h.clients[client] = true
case client := <-h.unregister:
// A client is leaving. Remove them and close their channel.
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send) // Important! Prevents goroutine leaks.
}
case message := <-h.broadcast:
// Someone sent a message. Time to fan it out.
for client := range h.clients {
select {
case client.send <- message:
// Message queued successfully.
default:
// Couldn't send? Buffer's full. Client is probably stuck.
// Unregister them to avoid blocking the whole hub.
close(client.send)
delete(h.clients, client)
}
}
}
}
}
The broadcast case has a crucial subtlety. We don’t just do client.send <- message directly in the loop. Why? Because if one client’s send channel is full (maybe their network is slow, or they’ve crashed), writing to it would block, which would block the entire hub for everyone. That’s a disaster. Instead, we use a select with a default case for a non-blocking send. If we can’t send immediately, we assume the client is deadweight and evict them. It sounds harsh, but it’s necessary for the health of the system.
The Client’s Lifecycle: Send and Receive
Meanwhile, each client runs two goroutines: one to read from the WebSocket and pump messages into the hub, and another to read from its own send channel and write out to the WebSocket.
// readPump pumps messages from the WebSocket to the Hub.
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c // Tell the hub we're leaving
c.conn.Close()
}()
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
// This is normal; it usually means the client disconnected.
break
}
// Format the message as you see fit, then broadcast it.
c.hub.broadcast <- message
}
}
// writePump pumps messages from the client's send channel to the WebSocket.
func (c *Client) writePump() {
defer c.conn.Close()
for message := range c.send { // This loop exits when c.send is closed.
err := c.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
break // Write failed, we're done.
}
}
}
The elegance here is in the cleanup. When readPump encounters an error (because the connection died), it defers an unregister request to the hub. The hub then closes the client’s send channel. And what happens in writePump? It’s ranging over that send channel. Closing the channel causes the range loop to exit, and the deferred Close() finishes the job. This is how you avoid goroutine leaks.
The Glue: Starting it All Up
Finally, you need to wire this all together in your HTTP handler.
// Initialize the hub and run it in its own goroutine.
hub := newHub()
go hub.Run()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade failed:", err)
return
}
// Create the client, register it, and start its pumps.
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} // Buffered channel!
client.hub.register <- client
go client.writePump()
go client.readPump()
})
Notice the buffered channel for client.send (256 is a reasonable buffer size). This buffer is your first line of defense against slow clients. It allows the hub to dump a message into the client’s queue without immediately blocking, so the hub can quickly move on to the next client instead of being held hostage by one slow connection.
This pattern is the bedrock. You’ll build on it—adding user awareness, rooms, more message types—but this hub-and-spoke model, with channels for communication and a single goroutine managing state, is what makes it all work without descending into a mutex-filled nightmare.