Real-Time Apps With WebSockets in Go
WebSockets in Go enable real-time features — live collaboration, chat, notifications. Here's how to build a production WebSocket server with proper connection management.
WebSockets enable real-time bidirectional communication between client and server — essential for collaborative editing, live notifications, chat, and any feature where pushing data to the client matters. Here's how I implement WebSockets in Go for production workloads, including the connection management and scaling patterns that actually matter.
The basics: gorilla/websocket
Go's standard library doesn't include WebSocket support. The de-facto library is gorilla/websocket — battle-tested, well-documented, and feature-complete.
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://letx.app" // production: whitelist origins
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
defer conn.Close()
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
break
}
log.Printf("read error: %v", err)
break
}
if err := conn.WriteMessage(messageType, message); err != nil {
log.Printf("write error: %v", err)
break
}
}
}
CheckOrigin is critical in production — without it, any website can open a WebSocket connection to your server.
Connection management: the Hub pattern
For any server with multiple clients, you need centralized connection management. The Hub pattern is standard:
type Client struct {
conn *websocket.Conn
send chan []byte
roomID string
userID string
}
type Hub struct {
rooms map[string]map[*Client]struct{} // roomID → clients
register chan *Client
unregister chan *Client
broadcast chan Message
mu sync.RWMutex
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]struct{}),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan Message, 256),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
if h.rooms[client.roomID] == nil {
h.rooms[client.roomID] = make(map[*Client]struct{})
}
h.rooms[client.roomID][client] = struct{}{}
h.mu.Unlock()
case client := <-h.unregister:
h.mu.Lock()
if room, ok := h.rooms[client.roomID]; ok {
delete(room, client)
if len(room) == 0 {
delete(h.rooms, client.roomID)
}
}
h.mu.Unlock()
close(client.send)
case msg := <-h.broadcast:
h.mu.RLock()
clients := h.rooms[msg.RoomID]
h.mu.RUnlock()
for client := range clients {
select {
case client.send <- msg.Data:
default:
// Client send buffer full — disconnect
close(client.send)
h.mu.Lock()
delete(h.rooms[client.roomID], client)
h.mu.Unlock()
}
}
}
}
}
The Hub runs in its own goroutine. Clients register/unregister via channels (avoiding mutex contention on the hot path). Broadcast goes to all clients in a room.
Per-client write pump
Each client has a write goroutine that reads from the send channel:
func (c *Client) writePump() {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
// Ping to keep connection alive and detect dead clients
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
Ping every 54 seconds catches dead connections (TCP keepalive alone isn't reliable across proxies and load balancers). The 10-second write deadline prevents slow clients from blocking the write pump indefinitely.
Scaling across multiple servers
A single Go server handles ~10,000 concurrent WebSocket connections on a t3.medium. Beyond that, you need multiple instances — and messages must be routed between them.
Use Redis pub/sub as the inter-server message bus:
func (h *Hub) subscribeRedis(ctx context.Context, rdb *redis.Client) {
pubsub := rdb.Subscribe(ctx, "ws:broadcast")
ch := pubsub.Channel()
for msg := range ch {
var broadcast Message
json.Unmarshal([]byte(msg.Payload), &broadcast)
// Only deliver to clients connected to THIS server
h.mu.RLock()
clients := h.rooms[broadcast.RoomID]
h.mu.RUnlock()
for client := range clients {
select {
case client.send <- broadcast.Data:
default:
// handle full buffer
}
}
}
}
// Publishing to a room (from any server)
func (h *Hub) PublishToRoom(ctx context.Context, rdb *redis.Client, roomID string, data []byte) {
msg := Message{RoomID: roomID, Data: data}
payload, _ := json.Marshal(msg)
rdb.Publish(ctx, "ws:broadcast", payload)
}
Every server subscribes to the Redis channel. When a message is published to a room, all servers receive it but only deliver it to clients actually connected to that server.
Load balancer configuration for WebSockets
WebSockets require sticky sessions (or session-less design). For AWS ALB:
resource "aws_lb_target_group" "ws" {
name = "letx-collab-tg"
port = 8080
protocol = "HTTP"
stickiness {
type = "lb_cookie"
cookie_duration = 86400 # 24 hours
}
health_check {
path = "/health"
}
}
Sticky sessions ensure a client's WebSocket reconnects go to the same server (keeping in-memory state valid). With Redis-backed state, you can skip sticky sessions entirely — any server can resume a session.
FAQ
How many WebSocket connections can a single Go server handle? A t3.medium (2 vCPU, 4GB RAM) comfortably handles 10,000–20,000 concurrent WebSocket connections. Each connection uses ~4KB of memory for the goroutine stack; the limiting factor is usually RAM, not CPU.
How do WebSockets work with load balancers? WebSockets are long-lived TCP connections. ALBs support WebSockets natively — the HTTP Upgrade handshake passes through, and the connection remains open. Configure sticky sessions or design for stateless WebSocket handling via Redis.
Should I use gorilla/websocket or nhooyr/websocket? Both are production-grade. gorilla/websocket is older and more commonly documented. nhooyr/websocket is newer with better context cancellation support. Either works; gorilla/websocket has more Stack Overflow answers for debugging.
How do I handle WebSocket reconnection on the client? Implement exponential backoff with jitter on the client side. On reconnect, send a "catch-up" request with the last operation ID the client received, and the server replays any missed messages.
Written by Shihab Shahriar Antor — AI Engineer & Founder of Shahriar Labs. See also: How I Built LetX: Real-Time Collaborative LaTeX · CRDT vs OT: How Real-Time Collaboration Works.