net/go.book
All Parts Marketing

Chat Applications: Design, Protocols, and Go Implementation

"Imagine a group conversation where everyone can speak and listen in real time. Chat applications are the classic use case for WebSockets and real-time networking!"


Why Build a Chat Application?

  • Real-Time Communication: Users expect instant delivery and receipt of messages.
  • Multi-User: Many clients can join, send, and receive messages simultaneously.
  • Practical: Chat is a foundation for collaborative tools, games, support systems, and more.
  • Learning: Building a chat app teaches you about concurrency, broadcasting, and protocol design.
  • A concurrency crucible: Shared state, backpressure, graceful shutdown, and resource leaks all show up in a chat server, which is why it's such a popular teaching example.

A chat server is a distributed systems problem wearing a friendly disguise: dozens of independent goroutines all racing to agree on "who said what, in what order."


Chat Protocols and Architecture

  • WebSocket-Based: Most modern chat apps use WebSockets for low-latency, bidirectional messaging.
  • Message Format: Typically JSON (e.g., { "user": "Alice", "msg": "Hello!" }).
  • Broadcast: Server receives a message and sends it to all connected clients.
  • Rooms/Channels: Advanced systems support multiple chat rooms or private messages.

Typical Chat Server Architecture

flowchart TD
    Client1((Client 1))
    Client2((Client 2))
    Client3((Client 3))
    Server["WebSocket Chat Server"]
    Client1 -- Connects --> Server
    Client2 -- Connects --> Server
    Client3 -- Connects --> Server
    Server -- Broadcasts messages --> Client1
    Server -- Broadcasts messages --> Client2
    Server -- Broadcasts messages --> Client3

Why Not Just Loop Over Every Connection?

The diagram makes broadcasting look trivial: one message in, fan it out to everyone. The hard part is coordination — many goroutines (one per client) must agree on a single, consistent set of "who is currently connected" while clients keep joining and leaving. Go gives you two idiomatic ways to solve this. Option A is a package-level map[*Client]bool protected by a sync.Mutex — simple, but every goroutine touching the map must remember to lock it, or you get a data race. Option B is a single hub goroutine that owns the map privately, reachable only over channels (register <- client, broadcast <- msg); since one goroutine touches the map, a race is impossible by construction. The example server below picks Option A, so this book shows both approaches.

Locks protect shared memory; a hub goroutine deletes the sharing altogether by giving one goroutine sole ownership and a mailbox. Pick whichever removes more bugs from your specific design, not whichever is more fashionable.


Go in Action: Minimal WebSocket Chat Server (Gorilla)

This example shows a minimal chat server using gorilla/websocket. All messages from any client are broadcast to all connected clients.

How it works (step by step):

  1. Clients connect to /ws and upgrade to WebSocket.
  2. Each client is managed by a goroutine.
  3. When a client sends a message, the server broadcasts it to all clients.
  4. The server handles joining/leaving and message delivery.
sequenceDiagram
    participant ClientA
    participant ClientB
    participant Server
    ClientA->>Server: Connect (WebSocket)
    ClientB->>Server: Connect (WebSocket)
    ClientA->>Server: Send message
    Server-->>ClientA: Broadcast message
    Server-->>ClientB: Broadcast message
    Note over Server: Handles all clients and broadcasts

This server keeps a sync.Mutex-guarded map[*Client]bool (Option A above), plus a package-level broadcast chan []byte and a goroutine that ranges over the map for every incoming message. Each Client also owns a buffered send chan []byte, drained by its own writer goroutine. The connection's original goroutine only reads from the socket and pushes onto broadcast — it never writes to the connection directly.

Why the broadcast loop never calls conn.WriteMessage itself
It's tempting to have the broadcast goroutine call client.conn.WriteMessage(...) directly for every client. Don't: a *websocket.Conn is not safe for concurrent writes, and if a client's own goroutine writes back at the same moment, the frames can interleave and corrupt the connection. Routing every outbound message through a per-client channel, drained by exactly one writer goroutine, guarantees a single writer per connection.

A blocking send to one slow client stalls everyone
An unbuffered client.send <- msg blocks indefinitely on one slow client — and since the broadcast loop iterates sequentially, every other client stops receiving too. Fix it with a bounded channel and a non-blocking send: select { case client.send <- msg: default: /* drop */ }. A full buffer marks the client too slow, so it gets disconnected rather than freezing the room. Buffer size is a real knob: too small triggers false disconnects on bursts, too large wastes memory.

Forgetting to unregister a client leaks memory forever
A client never removed from the map (on error, disconnect, or failed write) stays there forever in a long-running server, along with its reader and writer goroutines — a silent memory and goroutine leak that's easy to miss in development and painful in production. Always remove the client inside a defer, right after registration.


Example: Minimal Go WebSocket Chat Server (Gorilla)

type Client struct {
    conn *websocket.Conn
    send chan []byte
}

var (
    clients   = make(map[*Client]bool)
    broadcast = make(chan []byte)
    mu        sync.Mutex
)

func handleConnections(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("Upgrade error:", err)
        return
    }
    client := &Client{conn: conn, send: make(chan []byte, 16)}
    mu.Lock()
    clients[client] = true
    mu.Unlock()
    defer func() {
        mu.Lock()
        delete(clients, client)
        mu.Unlock()
        conn.Close()
    }()

    go func() {
        for msg := range client.send {
            if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                break
            }
        }
    }()

    for {
        _, msg, err := conn.ReadMessage()
        if err != nil {
            break
        }
        broadcast <- msg
    }
}

func handleBroadcast() {
    for msg := range broadcast {
        mu.Lock()
        for client := range clients {
            select {
            case client.send <- msg:
            default:
                close(client.send) // client too slow: drop it
                delete(clients, client)
            }
        }
        mu.Unlock()
    }
}

func main() {
    http.HandleFunc("/ws", handleConnections)
    go handleBroadcast()
    fmt.Println("Chat server running at ws://localhost:8080/ws ...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Full, runnable file: main.go.

How to use:

  1. Install gorilla/websocket: go get github.com/gorilla/websocket
  2. Run the server: go run exercises/part2/13-chat-server-gorilla/main.go
  3. Connect with multiple browser tabs, wscat, or your own Go client to ws://localhost:8080/ws.
  4. Or run the bundled Go client: go run exercises/part2/13-chat-client-gorilla/main.go
  5. Type messages in any client — everyone sees them in real time.

Understanding the code:

  • Each client connection has its own reader goroutine and its own writer goroutine.
  • All incoming messages are pushed onto a single broadcast channel.
  • The broadcast goroutine loops over the client map, pushing to each client's channel with a non-blocking select that drops clients who can't keep up.
  • On disconnect, a client is removed from the map and its channel closed, letting its writer goroutine exit cleanly — preventing a goroutine leak.

Advanced Example: Chat WebSocket with Username and Timestamp (Gorilla)

In this example, each user chooses a username before connecting. Messages sent include the sender's name and the time it was sent, which makes the chat feel more realistic and useful.

How it works:

  1. The client is prompted for a username before connecting.
  2. When sending a message, the client sends a JSON object with the username and text.
  3. The server adds a timestamp and re-broadcasts the message to all connected clients.
  4. Every client sees messages annotated with the sender's name and the time.
sequenceDiagram
    participant UserA
    participant UserB
    participant Server
    UserA->>Server: Connect (sends username)
    UserB->>Server: Connect (sends username)
    UserA->>Server: {"user": "Ana", "msg": "Hello!"}
    Server-->>UserA: {"user": "Ana", "msg": "Hello!", "time": "10:01"}
    Server-->>UserB: {"user": "Ana", "msg": "Hello!", "time": "10:01"}
    UserB->>Server: {"user": "Luis", "msg": "Hello Ana!"}
    Server-->>UserA: {"user": "Luis", "msg": "Hello Ana!", "time": "10:02"}
    Server-->>UserB: {"user": "Luis", "msg": "Hello Ana!", "time": "10:02"}

Exercise files

How to use it

  1. Install gorilla/websocket: go get github.com/gorilla/websocket
  2. Run the server: go run exercises/part2/13-chat-server-advanced-gorilla/main.go
  3. Run the client: go run exercises/part2/13-chat-client-advanced-gorilla/main.go
  4. Enter a username when the client prompts for one.
  5. Type messages and watch them appear with your name and the time on every connected client.

Message format

Client and server exchange JSON:

{
  "user": "Ana",
  "msg": "Hello!",
  "time": "2025-06-22T10:01:00"
}
  • user: sender's name
  • msg: message text
  • time: send time (ISO 8601 format)

Code explanation

  • The server expects each client's username as the very first message, a lightweight handshake before the normal read loop begins.
  • Every incoming message is stamped with the current server time and rebroadcast; the client displays it as [time] user: message.
  • The full, commented code lives in the exercise files linked above.

Never trust the username a client sends you
This handshake uses whatever string the client sends, verbatim, as the display name. A buggy or malicious client could send an empty string, thousands of characters, or control characters/HTML that break rendering if the message is ever shown in a browser without escaping. At minimum, trim whitespace, enforce a maximum length, and reject anything that isn't printable text before storing or broadcasting a username.


Extending the Example: Join/Leave System Messages

A chat room feels more alive when people can see others arrive and depart. This is a natural next feature for the advanced example, and it only touches code you already have: the Message struct and the connection handler's setup and cleanup.

// A flag marking system events (joins, leaves) for rendering.
type Message struct {
    User   string `json:"user"`
    Text   string `json:"msg"`
    Time   string `json:"time"`
    System bool   `json:"system,omitempty"`
}

// After the handshake succeeds, before the read loop:
broadcast <- Message{
    User:   username,
    Text:   username + " joined the chat",
    Time:   time.Now().Format(time.RFC3339),
    System: true,
}

// Deferred right after registering, so it always runs:
defer func() {
    clientsMu.Lock()
    delete(clients, client)
    clientsMu.Unlock()

    broadcast <- Message{
        User:   username,
        Text:   username + " left the chat",
        Time:   time.Now().Format(time.RFC3339),
        System: true,
    }
}()

The client side just checks the System field and renders those messages in a different style (dimmed, centered, no "reply" affordance) instead of as a normal chat bubble.

The difference between a toy echo broadcaster and a chat application is almost entirely in the edges — joins, leaves, slow clients, bad input — not in the happy path of "send a message, everyone gets it."


Scaling Beyond a Single Process

Everything in this chapter runs one hub in one process — right for learning and small deployments, but with a hard ceiling: a client on process A can never receive a broadcast from process B, since the hub's map and channels only exist in one process's memory.


Try It Yourself

Pick one of the exercise servers from this chapter and extend it with one of the following. Each is a small, self-contained change that exercises a real concurrency lesson:

  • Add join/leave system messages to the basic (non-advanced) server, adapting the pattern shown above to its plain []byte broadcast instead of the Message struct.
  • Make the client buffer size configurable and experiment: connect a client, stop reading from it, and watch how quickly it gets disconnected at different buffer sizes.
  • Write a unit test for the broadcast logic, independent of any real network connection, using an in-memory hub like the one below:
type Client struct {
    send chan []byte
}

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

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

func (h *Hub) run() {
    for {
        select {
        case c := <-h.register:
            h.clients[c] = true
        case c := <-h.unregister:
            if _, ok := h.clients[c]; ok {
                delete(h.clients, c)
                close(c.send)
            }
        case msg := <-h.broadcast:
            for c := range h.clients {
                select {
                case c.send <- msg:
                default:
                    delete(h.clients, c)
                    close(c.send)
                }
            }
        }
    }
}
func TestHubBroadcast(t *testing.T) {
    h := newHub()
    go h.run()

    c1 := &Client{send: make(chan []byte, 4)}
    c2 := &Client{send: make(chan []byte, 4)}
    h.register <- c1
    h.register <- c2

    h.broadcast <- []byte("hello")

    for _, c := range []*Client{c1, c2} {
        select {
        case msg := <-c.send:
            if string(msg) != "hello" {
                t.Fatalf("got %q, want %q", msg, "hello")
            }
        case <-time.After(time.Second):
            t.Fatal("timed out waiting for broadcast")
        }
    }
}

This Hub is the "Option B" design from earlier: a single goroutine owns the map, so the test never needs a mutex, a real socket, or a running server — just go test, which is why designing for testability often pushes you toward channel-owned state at component boundaries.


Frequently Asked Questions

Should my chat server use a mutex-guarded map or a channel-owned hub? It depends on which coordination style buys you more safety for your design. A mutex-guarded map[*Client]bool (Option A, used in the minimal Gorilla example) stays passively readable from anywhere, which is handy if an outside HTTP handler needs to answer "who's online right now." A hub goroutine (Option B, used in the "Try It Yourself" tests) makes the "only one goroutine touches this state" rule structural instead of something every caller has to remember to respect. Reach for the hub when you want that guarantee for free, and the mutex when simplicity and outside readability matter more.

Why does the broadcast loop push onto a per-client channel instead of just calling conn.WriteMessage for everyone? Because a *websocket.Conn isn't safe for concurrent writes, and a client's own writer goroutine could be writing back to the same connection at the same instant. Funneling every outbound message through a per-client send channel, drained by exactly one writer goroutine per connection, guarantees there's never more than one writer touching a given socket — the same rule covered in the previous WebSockets chapter, just now with many clients instead of one.

One slow client seems to freeze the whole chat room — why? This happens when client.send <- msg is an unbuffered or blocking send inside a loop that iterates over every client sequentially: one stuck client blocks that iteration, so every client after it in the map never gets the message either. The fix in this chapter is a bounded channel with a non-blocking send (select { case client.send <- msg: default: }), so a full buffer just marks that one client as too slow and disconnects it instead of stalling the broadcast for everyone else.

I forgot to remove a client from the map on disconnect — what actually breaks? The client's entry, its reader goroutine, and its writer goroutine all stay alive forever, since nothing ever tells the broadcast loop or the runtime that connection is gone — a silent memory and goroutine leak that's invisible in short-lived development runs but accumulates steadily in a long-running server. The chapter's fix is to always unregister inside a defer, placed immediately after registration, so cleanup runs no matter how the connection ends (clean close, error, or failed write).

My chat app works great on one server — why can't clients on two different instances see each other's messages? Because the hub's client map and broadcast channel only exist in that one process's memory — a message published to instance A's channel has no way to reach instance B's clients. Scaling past a single process means replacing the in-process channel with a shared external bus like Redis Pub/Sub, NATS, or Kafka: each instance keeps running its own local hub exactly as shown in this chapter, but also subscribes to and republishes through the shared bus, so the same design just federates outward instead of being rebuilt from scratch.

Key Takeaways

  • Chat apps are a classic real-time networking challenge — perfect for learning concurrency, broadcasting, and protocol design.
  • Use WebSockets for low-latency, bidirectional messaging.
  • Manage clients and broadcasts with goroutines and channels in Go, whether via a mutex-guarded map or a channel-owned hub — pick based on how much of the coordination logic you want serialized into one place.
  • Give every connection exactly one writer goroutine, fed by a buffered channel, so concurrent writes never corrupt the socket and one slow client never blocks the rest.
  • Always unregister clients on disconnect — in a defer — to avoid memory and goroutine leaks in a long-running server.
  • For production, add authentication, rooms, message history, presence, and a shared message bus if you need to scale across multiple server instances.