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):
- Clients connect to
/wsand upgrade to WebSocket. - Each client is managed by a goroutine.
- When a client sends a message, the server broadcasts it to all clients.
- 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.
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.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.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:
- Install gorilla/websocket:
go get github.com/gorilla/websocket - Run the server:
go run exercises/part2/13-chat-server-gorilla/main.go - Connect with multiple browser tabs,
wscat, or your own Go client tows://localhost:8080/ws. - Or run the bundled Go client:
go run exercises/part2/13-chat-client-gorilla/main.go - 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
broadcastchannel. - The broadcast goroutine loops over the client map, pushing to each client's channel with
a non-blocking
selectthat 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:
- The client is prompted for a username before connecting.
- When sending a message, the client sends a JSON object with the username and text.
- The server adds a timestamp and re-broadcasts the message to all connected clients.
- 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
- Install gorilla/websocket:
go get github.com/gorilla/websocket - Run the server:
go run exercises/part2/13-chat-server-advanced-gorilla/main.go - Run the client:
go run exercises/part2/13-chat-client-advanced-gorilla/main.go - Enter a username when the client prompts for one.
- 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 namemsg: message texttime: 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.
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
[]bytebroadcast instead of theMessagestruct. - 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.