_SH Log's
Back to Root
EST: 5 min read

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.

#go#websockets#realtime#systems

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.