Building Real-Time Chat with Go and WebSockets
Back to blog

Building Real-Time Chat with Go and WebSockets

·Dan Castrillo

the hub that holds it all together

Chattorum is a real-time chat app in Go. users join chat rooms, send messages, see messages from everyone else in real time. building it taught me how Go's concurrency model maps onto the WebSocket problem.

the core is what i call the hub pattern. a single goroutine runs in the background and manages every active WebSocket connection. clients register, unregister, and broadcast messages through channels. no mutexes, no locks.

type Hub struct {
    clients    map[*Client]bool
    broadcast  chan []byte
    register   chan *Client
    unregister chan *Client
}
 
func (h *Hub) run() {
    for {
        select {
        case client := <-h.register:
            h.clients[client] = true
        case client := <-h.unregister:
            delete(h.clients, client)
            close(client.send)
        case message := <-h.broadcast:
            for client := range h.clients {
                client.send <- message
            }
        }
    }
}

this is the entire brain of the chat server. the select statement blocks until one of the channels has something, then handles it. new user connects: add them to the map. someone disconnects: remove them and close their send channel. message comes in: fan it out to every connected client. because only one goroutine touches the clients map, there's zero contention.

upgrading http to websockets

gorilla/websocket upgrades a regular HTTP connection. once upgraded, each client gets two goroutines: one for reading messages off the socket and one for writing messages back. the read pump pushes incoming messages to the hub's broadcast channel. the write pump pulls from the client's personal send channel.

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}
 
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }
    client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
    client.hub.register <- client
 
    go client.writePump()
    go client.readPump()
}

each connection is cheap. two goroutines per client, a buffered channel, and a pointer in a map. Go handles thousands of these. i load tested with a few hundred concurrent connections on my laptop and the memory usage barely moved.

the stock bot and rabbitmq

i wanted a /stock=AAPL command that fetches a real stock quote and posts it to the chat. the naive approach: handle it inline. parse the command, call the API, format the response, broadcast it. but if the stock API is slow or down, it blocks the message handler.

instead i went with RabbitMQ. when the chat server sees a /stock= command, it publishes a message to a queue and moves on. a separate microservice consumes from that queue, fetches the stock quote from an external API, and publishes the result back on a different queue. the chat server picks it up and broadcasts it like any other message.

the decoupling is the whole point. the stock bot service can crash, restart, or deploy independently. the chat keeps working. if the stock API rate limits us, messages queue up. adding a /weather= command means spinning up another consumer. the chat server doesn't change at all.

the stock bot crashed during testing because of a malformed API response. chat didn't hiccup. users kept sending messages. the bot came back up, processed the queued requests, and posted the quotes a few seconds late.

testing the whole thing

630+ unit tests and 63 end-to-end tests. the unit tests cover everything from message parsing to hub registration logic. the e2e tests spin up the full server, connect real WebSocket clients, send messages, and verify they arrive at the right destinations.

the e2e tests were the most valuable. there's a class of bugs that only show up with multiple concurrent connections: race conditions in the hub, messages arriving out of order, clients not getting cleaned up on disconnect. table-driven tests in Go made it easy to cover a ton of scenarios without the test file becoming unreadable.

what i took away

Go's concurrency primitives fit this problem well. the hub pattern with channels feels natural in a way that callback-based WebSocket servers in Node never did for me. the goroutine-per-connection model means straightforward sequential code instead of juggling event loops.

the RabbitMQ piece showed me that decoupling is a practical tool, not an architecture astronaut thing. having services that can fail independently changes how you think about error handling. you stop trying to prevent every failure and start designing for recovery.

Related Posts