You need to push data to everyone at once
You're building a live leaderboard for a multiplayer game. One server tracks scores, and fifty browsers are waiting to see the updates. When a player jumps to first place, every connected browser needs to get the new ranking instantly. You can't make each client poll the server every second; that wastes bandwidth, adds latency, and puts unnecessary load on your database. You need the server to push the update to everyone at once. That's broadcasting.
Broadcasting is the backbone of chat apps, live dashboards, collaborative editors, and real-time notifications. In Go, the pattern is straightforward but has a few sharp edges. You need to track who is connected, send data to each connection, and handle the inevitable moment when a client disconnects without warning.
The megaphone and the crowd
Think of broadcasting like holding a megaphone in a crowded room. You stand in the center, shout a message, and everyone hears it. In code, the "crowd" is a collection of open WebSocket connections. The "shout" is writing data to each connection.
The tricky part isn't the shouting. It's managing the crowd. People leave the room. People join. Someone might drop their earplugs and stop listening. You need a way to track who is still there and skip the ones who aren't. If you try to shout to someone who left, you waste energy. Worse, if you shout to a ghost, you might trip over your own feet.
Go gives you the tools to manage this. You use a map to track connections, a mutex to protect the map from chaos, and a loop to deliver the message. The map keys are the connection pointers. The values are usually dummy booleans; the key does all the work.
Minimal broadcast pattern
Here's the core structure: a map of connections and a function that iterates through it to write data.
import (
"sync"
"github.com/gorilla/websocket"
)
// clients tracks active connections using pointers as keys.
var clients = make(map[*websocket.Conn]bool)
// mu prevents race conditions on the clients map.
var mu sync.Mutex
// broadcast writes a message to every active connection.
func broadcast(message []byte) {
// Lock the map to allow safe iteration and deletion.
mu.Lock()
defer mu.Unlock()
for client := range clients {
// WriteMessage blocks until the frame is sent or fails.
err := client.WriteMessage(websocket.TextMessage, message)
if err != nil {
// Close the socket and remove it from the map.
client.Close()
delete(clients, client)
}
}
}
Lock the map. Delete the dead. Repeat.
How the pieces fit together
The map holds *websocket.Conn pointers. The value is true just to satisfy the map type; Go maps require a value type, and a boolean is the cheapest option. When a client connects, you add the connection pointer as a key. When you broadcast, you lock the mutex, loop over the keys, and write the message.
The mutex is mandatory. Go maps are not safe for concurrent use. If one goroutine adds a client while another broadcasts, the program crashes. The compiler won't catch this. You'll see a runtime panic like fatal error: concurrent map writes. The race detector (go run -race) will also flag it, but the panic is the hard stop you need to avoid.
The if err != nil check after WriteMessage is verbose by design. The community accepts this boilerplate because it makes the failure path explicit. A write error usually means the client disconnected or the network dropped. You must close the connection and delete the key. If you skip the deletion, the map grows forever, and your broadcast loop slows down as it tries to write to dead connections. This is a memory leak and a performance killer.
WriteMessage blocks until the data reaches the kernel buffer. If a client's network is slow, the broadcast function hangs waiting for that one connection. This stalls the message for every other client. You'll fix this with write deadlines later.
Realistic handler with lifecycle management
A broadcast function is useless without a way to register clients. The HTTP handler upgrades the connection, adds it to the map, and keeps a read loop running to maintain the link.
Here's the upgrade and registration logic. The handler locks the map to add the connection, then defers cleanup.
// handleWebSocket upgrades the request and registers the client.
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "Upgrade failed", http.StatusInternalServerError)
return
}
// Lock the map before adding the connection.
mu.Lock()
clients[conn] = true
mu.Unlock()
// Defer cleanup to remove the connection when the handler returns.
defer func() {
mu.Lock()
delete(clients, conn)
mu.Unlock()
conn.Close()
}()
}
The defer block is the safety net. It runs when the handler function returns, whether that happens because the client disconnected, the server shut down, or a panic occurred. It locks the map, deletes the key, and closes the socket. This ensures you never leak a connection.
The read loop must run to keep the goroutine alive. If the handler returns immediately, the defer runs, and the client is removed before it can receive anything.
// The read loop keeps the goroutine alive; break on disconnect.
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
The read loop calls ReadMessage in a loop. This blocks until a message arrives or an error occurs. When the client closes the connection, ReadMessage returns an error, the loop breaks, the handler returns, and the defer cleanup runs. This is the standard pattern for keeping a WebSocket connection active.
The read loop is the heartbeat. If it stops, the client is gone.
Keeping connections alive with ping and pong
WebSockets have a keep-alive mechanism. If the server doesn't send a ping, the client might drop the connection. Or the network might drop silently, leaving both sides thinking the connection is alive. This is called a "half-open" connection.
You can configure the connection to handle pings automatically. The gorilla/websocket package provides SetPingHandler and SetPongHandler. You can also set a write deadline to timeout slow writers.
// Configure ping/pong to detect dead connections.
conn.SetPingHandler(func(appData string) error {
// Respond to client pings by sending a pong.
return conn.WriteMessage(websocket.PongMessage, []byte(appData))
})
// Set a write deadline to prevent blocking on slow clients.
conn.SetWriteDeadline(time.Now().Add(writeWait))
The ping handler runs in a separate goroutine managed by the library. When the client sends a ping, this function executes. You return an error if you want to close the connection, or nil to keep it alive. Setting a write deadline ensures that WriteMessage doesn't block forever. If the write takes longer than the deadline, it returns an error, and your broadcast loop can clean up the client.
Pitfalls and runtime errors
The most common crash comes from forgetting the mutex. If you add a client in one goroutine and broadcast in another without locking, the runtime panics with fatal error: concurrent map writes. The compiler won't catch this. You need the race detector or a crash to find it.
Another trap is the blocking write. WriteMessage blocks until the data is sent. If a client's network is slow, your broadcast function hangs. This slows down every other client. You must set a write deadline on the connection. If the write times out, you get an error, and you can delete the client.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. In WebSocket handlers, the leak usually comes from a read loop that never breaks. If you don't handle the error from ReadMessage, the loop runs forever, and the connection stays in the map. Always break on error.
The compiler complains with cannot use x (untyped int constant) as string value in argument if you pass the wrong type to a function. WebSocket messages are bytes. If you try to pass a string directly where []byte is expected, the compiler rejects it. Convert explicitly with []byte(message).
A blocked write stalls the broadcast. Set deadlines.
When to use this pattern
Use a shared map with a mutex when your client count stays under a few thousand and you want the simplest implementation. This works for single-server apps where all clients connect to the same process.
Use a per-client goroutine with a buffered channel when slow clients block writes and you need to protect the broadcast latency. Each client gets its own goroutine that reads from a channel and writes to the socket. The broadcast function pushes messages to all channels and moves on. This isolates slow writers.
Use a distributed pub/sub system like Redis when your application runs on multiple servers and clients connect to different instances. A local map only knows about clients on one server. Pub/sub lets servers exchange messages so every instance can broadcast to its own clients.
Use Server-Sent Events when the communication is strictly server-to-client and you want to avoid WebSocket overhead. SSE uses standard HTTP and handles reconnection automatically. It's simpler if you don't need bidirectional communication.
Simple map for one server. Pub/sub for many.