Skip to Content
Course content

88: Building a Simple Chat Server with WebSockets

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've been dealing with the standard request-response cycle. You send a request, the server sends a response, and the connection closes. But if we're building a chat server, that's useless. We can't have the client polling the server every 500ms asking, "Does anyone have a message for me?" That's a great way to kill your server's performance and frustrate your users.

The Handshake Struggle

I remember the first time I tried to do this using only the standard library. I spent an hour digging through net/http before realizing that while Go supports the underlying TCP connections, it doesn't provide a high-level WebSocket implementation out of the box. To keep our sanity, I'm using github.com/gorilla/websocket. It's the industry standard for a reason.

Let's start with a basic handler. My first instinct is usually to just accept the connection and try to write to it. But WebSockets start as an HTTP request that "upgrades" to a persistent connection. If we don't handle that handshake, the browser just sees a failed HTTP request.


var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true // Letting anyone connect for now; we'll tighten this later
    },
}

func handleConnections(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    
    for {
        messageType, p, err := conn.ReadMessage()
        if err != nil {
            log.Println(err)
            return
        }
        if err == nil {
            log.Printf("Received: %s", p)
        }
    }
}

I ran this, connected a browser client, and it worked—sort of. I could send a message, and the server would print it to the console. But this is a "black hole" server. The message goes in, the server sees it, and then it disappears. A chat server needs to broadcast that message to everyone else.

The Concurrency Wall

Here is where it gets tricky. If I just keep a list of connections in a slice and loop through them to send messages, I'll run into a race condition the moment two people send a message at the same time. Go maps and slices aren't thread-safe by default, and since every WebSocket connection runs in its own goroutine, we're headed for a crash.

I tried using a sync.Mutex around a slice of connections, but that felt clunky. It blocks the entire server while one message is being sent. Instead, let's use a "Hub"—a central coordinator that manages connections via channels. This is where Go's philosophy of "don't communicate by sharing memory; share memory by communicating" actually pays off.


type Hub struct {
    clients    map[*websocket.Conn]bool
    broadcast  chan []byte
    register   chan *websocket.Conn
    unregister chan *websocket.Conn
}

func newHub() *Hub {
    return &Hub{
        broadcast:  make(chan []byte),
        register:   make(chan *websocket.Conn),
        unregister: make(chan *websocket.Conn),
        clients:    make(map[*websocket.Conn]bool),
    }
}

func (h *Hub) run() {
    for {
        select {
        case client := <-h.register:
            h.clients[client] = true
        case client := <-h.unregister:
            if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                client.Close()
            }
        case message := <-h.broadcast:
            for client := range h.clients {
                err := client.WriteMessage(websocket.TextMessage, message)
                if err != nil {
                    log.Printf("error: %v", err)
                    client.Close()
                    delete(h.clients, client)
                }
            }
        }
    }
}

By moving the client management into a single run() loop, I've eliminated the need for a Mutex. Only one goroutine ever touches the clients map. The handleConnections function now just sends the connection to the register channel and pipes incoming messages into the broadcast channel.

Dealing with Ghost Connections

When I tested this with a few browser tabs, I noticed something annoying. If I closed a tab, the server would try to send a message to that closed connection and throw an error. The WriteMessage call in the loop above catches the error and deletes the client, but it's reactive—it only happens when someone else sends a message.

To fix this, I realized the reading loop in the handler needs to be the one to trigger the unregister process. The moment ReadMessage returns an error (which it does immediately when the socket closes), we tell the Hub to drop that client.

Here's the final logic for the handler integrating with the Hub:


func handleConnections(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }
    
    hub.register <- conn
    
    // We need to ensure the client is unregistered when the function exits
    defer func() {
        hub.unregister <- conn
    }()

    for {
        _, message, err := conn.ReadMessage()
        if err != nil {
            break // Exit loop and trigger defer
        }
        hub.broadcast <- message
    }
}

Now the flow is clean: HTTP Upgrade $\rightarrow$ Register with Hub $\rightarrow$ Read Loop $\rightarrow$ Broadcast $\rightarrow$ Unregister on disconnect. It's a tight loop that leverages Go's strengths in concurrency without getting bogged down in locking mechanisms.




📋 Practical Task

Exercise: Implementing User-Specific Nicknames in the Chat Hub

Right now, the chat server is anonymous—every message is just a string of text. Your task is to modify the server so that users can identify themselves.

  • Modify the Hub or create a new Client struct that associates a websocket.Conn with a Username string.
  • Update the handleConnections logic to expect the first message sent by the client to be their nickname (e.g., "Alice" or "Bob").
  • Ensure that all subsequent messages broadcast to the group are prefixed with that username (e.g., "Alice: Hello everyone!").
  • Handle the case where a user disconnects, ensuring their specific entry is removed from your tracking system.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.