Introduction
WebSocket connections let you push real-time updates to clients without polling, but building them at scale requires careful handling of concurrency, connection lifecycle, and message routing. WebSockets in Go: a practical guide for developers walks you through the decisions and patterns that teams use in production, from choosing a library to detecting dead connections and fanning out messages across multiple servers.
We've built real-time notification systems, live dashboards, and collaborative tools across several production services. This guide distills what actually works when you need hundreds or thousands of concurrent WebSocket connections.
Choosing Between coder/websocket and gorilla/websocket
Two libraries dominate the Go ecosystem: nhooyr.io/websocket (coder/websocket) and github.com/gorilla/websocket. Both are production-ready, but they reflect different design philosophies.
coder/websocket offers a cleaner, more ergonomic API. It handles context cancellation natively, uses Reader and Writer for I/O, and manages backpressure with message buffering. The library is actively maintained and aligns well with modern Go idioms.
gorilla/websocket is older and more widely deployed. It's battle-tested across thousands of applications, which means you'll find more Stack Overflow answers and blog posts. The API is slightly more verbose but straightforward.
For new projects, we recommend coder/websocket. It's simpler to reason about, easier to test, and plays nicely with context propagation. Here's a minimal server using coder:
package main
import (
"context"
"log"
"net/http"
"nhooyr.io/websocket"
)
func main() {
http.HandleFunc("/ws", handleWebSocket)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
log.Printf("accept error: %v", err)
return
}
defer conn.Close(websocket.StatusInternalError, "")
// Read and echo messages
for {
msg, err := conn.Reader(r.Context())
if err != nil {
return
}
w := conn.Writer(r.Context(), websocket.MessageText)
if _, err := msg.WriteTo(w); err != nil {
return
}
w.Close()
}
}Notice we don't manually manage goroutines for reading—the API guides you toward simpler, less error-prone code.
One Goroutine for Reads, One for Writes
WebSocket connections are not safe for concurrent reads or writes from different goroutines. The RFC doesn't prohibit it, but the underlying TCP stream doesn't handle simultaneous operations well. The standard pattern is to dedicate one goroutine to reading and one to writing, passing messages between them over channels.
This pattern prevents race conditions, ensures message ordering, and makes it easy to reason about connection state. Here's a realistic example with a connection manager:
type Client struct {
conn *websocket.Conn
send chan []byte
done chan struct{}
userID string
}
func (c *Client) readPump() {
defer func() {
c.conn.Close(websocket.StatusNormalClosure, "")
}()
c.conn.SetReadLimit(32 * 1024) // 32KB max message
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
msg, err := c.conn.Reader(context.Background())
if err != nil {
return
}
// Parse and validate message
var payload map[string]interface{}
if err := json.NewDecoder(msg).Decode(&payload); err != nil {
log.Printf("decode error for user %s: %v", c.userID, err)
continue
}
// Send to write pump or process locally
select {
case c.send <- marshal(payload):
case <-c.done:
return
}
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close(websocket.StatusInternalError, "")
}()
for {
select {
case msg := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.Write(context.Background(), websocket.MessageText, msg); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.Ping(context.Background()); err != nil {
return
}
case <-c.done:
return
}
}
}The readPump runs in one goroutine and drains messages from the client. The writePump runs in another and sends data back, interleaving application messages with ping frames. If either goroutine exits, we close the connection cleanly.
Ping/Pong to Detect Dead Connections
Network interruptions don't always close sockets immediately. A client might disappear, but the server won't know until it tries to write and times out—which could take minutes. Ping/pong frames solve this. They're lightweight control frames built into the WebSocket protocol, separate from application data.
Send a ping every 30 seconds from the write pump and expect a pong back within a reasonable window. If the client doesn't respond, the connection is dead and you should close it. Most clients respond to pings automatically, so you rarely need application logic for it.
In the writePump above, we call c.conn.Ping() every 30 seconds. We also set a read deadline and a pong handler so that any pong resets it. If 60 seconds pass without a pong, the read deadline expires and we exit the read pump.
This simple dual-timeout approach catches network hangs without requiring extra goroutines or complex state machines.
Redis Pub/Sub to Fan Out Across Instances
When you scale beyond a single server, WebSocket clients are distributed across multiple instances. If a user on server A needs to notify a user on server B, you need inter-server communication. Redis pub/sub is a natural fit: lightweight, fast, and already present in most stacks.
The pattern is straightforward: subscribe to Redis channels that match your event types, and when a local client wants to broadcast, publish to Redis. Other servers receive the message and deliver it to their connected clients.
type Hub struct {
clients map[string]*Client // userID -> *Client
mu sync.RWMutex
rdb *redis.Client
pubsub *redis.PubSub
}
func NewHub(rdb *redis.Client) *Hub {
h := &Hub{
clients: make(map[string]*Client),
rdb: rdb,
}
h.pubsub = h.rdb.Subscribe(context.Background(), "notifications", "updates")
go h.listenRedis()
return h
}
func (h *Hub) listenRedis() {
ch := h.pubsub.Channel()
for msg := range ch {
h.mu.RLock()
for _, client := range h.clients {
select {
case client.send <- []byte(msg.Payload):
default:
// Client buffer full, skip
}
}
h.mu.RUnlock()
}
}
func (h *Hub) registerClient(userID string, c *Client) {
h.mu.Lock()
h.clients[userID] = c
h.mu.Unlock()
}
func (h *Hub) unregisterClient(userID string) {
h.mu.Lock()
delete(h.clients, userID)
h.mu.Unlock()
}
func (h *Hub) broadcast(ctx context.Context, channel, message string) error {
return h.rdb.Publish(ctx, channel, message).Err()
}When a message arrives from your application (perhaps from an HTTP API handler), you publish to Redis. Other servers receive it via the pub/sub listener and forward it to local clients. This decouples message generation from delivery and keeps servers independent.
One nuance: ensure your message buffer in client.send is small enough to detect slow clients. If a client's receive buffer is full, the channel send will block (or be skipped with the default case above), signaling that the client isn't keeping up. You can then close the connection rather than letting memory grow unbounded.
Common Mistakes
- Sharing connection objects across goroutines without synchronization. Even with one read and one write goroutine, ensure no other code touches the connection object. Encapsulate it in a struct and protect it with a mutex if needed.
- Ignoring backpressure. If a client is slow to read, don't let the send channel grow infinitely. Set a reasonable buffer size and drop or close the connection if it overflows.
- Not setting read/write deadlines. Without them, a hung client or network partition can leave a goroutine blocked forever. Deadlines combined with ping/pong ensure you detect and clean up dead connections.
- Forgetting to drain the connection on close. When you close, drain any pending messages from
c.sendor you'll block the write pump during shutdown. - Publishing to Redis before validating the message. Malformed or malicious messages will propagate across the cluster. Validate and sanitize on the receiving side before broadcasting.
Key Takeaways
- Use
coder/websocketfor new projects; it's more ergonomic and context-aware than gorilla. - Separate reads and writes into dedicated goroutines communicating over channels to avoid race conditions and data corruption.
- Implement ping/pong with deadlines to detect and close dead connections quickly, preventing resource leaks.
- Fan out across multiple servers using Redis pub/sub, allowing independent instances to coordinate real-time delivery.
- Respect backpressure: don't buffer messages indefinitely for slow clients. Set channel sizes and close when they fall behind.
- Always validate and sanitize incoming messages before broadcasting them to other clients or systems.






Comments
Loading comments…