How to Scale WebSocket Connections in Go

Web
Scale WebSocket connections in Go by running multiple instances behind a load balancer and tuning runtime settings.

The connection bottleneck

Your live dashboard handles fifty concurrent users without breaking a sweat. At two hundred, the CPU spikes and messages drop. At five hundred, the server refuses new connections entirely. The bottleneck is rarely the network. It is the way the application manages long-lived state. WebSockets turn your HTTP server into a persistent connection manager. Scaling them requires shifting from request-response thinking to connection lifecycle management.

How Go handles long-lived sockets

A WebSocket connection is a single TCP socket that stays open for minutes or hours. Each connection consumes a file descriptor, a small amount of memory for read and write buffers, and a goroutine to process frames. Go makes spawning goroutines cheap, but cheap does not mean free. If you allocate one goroutine per connection and let them run forever without cleanup, your process will exhaust its file descriptor limit or run out of heap space.

Think of your server like a hotel front desk. HTTP requests are walk-in guests who check in, get a key, and leave. WebSockets are long-term residents. They occupy a room, use the utilities, and expect the staff to check on them periodically. You cannot scale a hotel by just hiring more desk clerks. You need a system to track room occupancy, handle checkouts automatically, and distribute new arrivals across multiple buildings.

Goroutines are cheap. Channels are not magic.

The baseline handler

Here is the simplest WebSocket handler. It upgrades an HTTP request, reads messages in a loop, and writes them back.

package main

import (
    "log"
    "net/http"
    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    // Allow all origins for simplicity. Production code restricts this.
    CheckOrigin: func(r *http.Request) bool { return true },
}

// handleConnection upgrades the request and manages the socket lifecycle.
func handleConnection(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("upgrade failed: %v", err)
        return
    }
    // Close the connection when this function returns to prevent leaks.
    defer conn.Close()

    for {
        // Read a message from the client. Blocks until data arrives or connection drops.
        _, msg, err := conn.ReadMessage()
        if err != nil {
            log.Printf("read error: %v", err)
            break
        }
        // Echo the message back. Demonstrates the read-write loop.
        if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
            log.Printf("write error: %v", err)
            break
        }
    }
}

func main() {
    http.HandleFunc("/ws", handleConnection)
    // ListenAndServe blocks until the process receives a termination signal.
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The code above works for a handful of clients. The for loop blocks on ReadMessage. When a client sends data, the goroutine wakes up, processes it, and blocks again. When the client disconnects, ReadMessage returns an error, the loop breaks, and defer conn.Close() cleans up the socket. This pattern is correct, but it assumes every connection lives in isolation.

Why the scheduler does the heavy lifting

Go's runtime scheduler automatically maps goroutines to operating system threads. You no longer need to call runtime.GOMAXPROCS(runtime.NumCPU()). The Go team made that the default behavior in version 1.5. Setting it manually now only restricts concurrency. The scheduler handles millions of goroutines by multiplexing them across a small pool of OS threads. Your bottleneck will be memory allocation, file descriptor limits, or network I/O, not CPU scheduling.

The GODEBUG=http2client=0 setting appears in older tutorials to work around HTTP/2 upgrade failures. WebSockets require an HTTP/1.1 101 Switching Protocols response. Modern Go servers disable HTTP/2 automatically when you use http.ListenAndServe without a TLS configuration, or you can explicitly disable it in the server struct. Forcing it via environment variables is a workaround for legacy setups, not a scaling strategy.

The community accepts verbose error handling because it makes the unhappy path visible. You will see if err != nil { return err } repeated across connection handlers. Do not hide it behind a macro or a custom wrapper. Explicit checks keep connection teardown predictable.

Trust the scheduler. Argue architecture, not thread counts.

Building a production connection hub

Scaling requires tracking connections, broadcasting messages, and handling graceful shutdowns. You need a registry that survives across requests and survives server restarts. Here is a production-ready connection manager.

package main

import (
    "sync"
    "github.com/gorilla/websocket"
)

// Hub tracks active connections and routes messages between them.
type Hub struct {
    mu       sync.RWMutex
    clients  map[*websocket.Conn]bool
    register chan *websocket.Conn
    unregister chan *websocket.Conn
    broadcast chan []byte
}

// NewHub initializes the message router and starts the background dispatcher.
func NewHub() *Hub {
    h := &Hub{
        clients:    make(map[*websocket.Conn]bool),
        register:   make(chan *websocket.Conn),
        unregister: make(chan *websocket.Conn),
        broadcast:  make(chan []byte),
    }
    go h.run()
    return h
}

The hub pattern decouples connection management from the HTTP handler. Each client goroutine sends messages to the broadcast channel. The single run goroutine reads from that channel and fans out the data. This prevents write contention. WebSockets are not thread-safe for concurrent writes. If two goroutines call WriteMessage on the same connection simultaneously, the frame headers will corrupt and the client will drop the socket. Centralizing writes solves this.

// run processes connection lifecycle events and distributes messages.
func (h *Hub) run() {
    for {
        select {
        case conn := <-h.register:
            h.mu.Lock()
            h.clients[conn] = true
            h.mu.Unlock()
        case conn := <-h.unregister:
            h.mu.RLock()
            _, ok := h.clients[conn]
            h.mu.RUnlock()
            if ok {
                h.mu.Lock()
                delete(h.clients, conn)
                h.mu.Unlock()
                conn.Close()
            }
        case msg := <-h.broadcast:
            h.mu.RLock()
            for conn := range h.clients {
                if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
                    h.mu.RUnlock()
                    h.unregister <- conn
                    break
                }
            }
            h.mu.RUnlock()
        }
    }
}

The sync.RWMutex protects the client map. Readers lock with RLock to iterate. Writers lock with Lock to add or remove entries. This allows multiple broadcasts to read the map concurrently while blocking registration and cleanup. The select statement in run handles all channels fairly. If the broadcast channel fills up, the sender blocks. You can add a buffer to broadcast if your message rate exceeds the fan-out speed, but unbounded buffers will eventually consume all available memory.

Receiver names should be short and match the type. (h *Hub) is standard. (this *Hub) or (self *Hub) breaks community convention and adds visual noise. Keep it tight.

Managing lifecycle and graceful shutdown

Long-running servers need a clean exit path. When you deploy a new version, your process receives a SIGTERM. You must stop accepting new connections, flush pending writes, and close sockets before the OS kills the process. context.Context handles this.

package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

// shutdown waits for OS signals and gracefully stops the HTTP server.
func shutdown(server *http.Server) {
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
    <-stop

    // Context cancels after 10 seconds to force cleanup.
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Shutdown stops the server and waits for active connections to finish.
    if err := server.Shutdown(ctx); err != nil {
        log.Printf("server shutdown failed: %v", err)
    }
}

context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Pass it through your connection handlers so they can abort reads when the server is winding down. The worst goroutine bug is the one that never logs. Always attach a context to long-lived operations.

Common traps and runtime failures

Connection leaks are the most common scaling failure. If a client disconnects abruptly, the TCP stack sends a FIN packet. Your ReadMessage call returns an error. If you ignore that error and keep the loop running, the goroutine stays alive, the socket stays open, and the file descriptor never returns to the OS. The compiler will not catch this. It is a runtime resource leak. Always break the read loop on error and ensure conn.Close() executes.

Another trap is unbounded connection maps. Storing pointers to *websocket.Conn in a map is fine, but if you also store client metadata, session tokens, or message history per connection, the heap grows linearly with active users. Set idle timeouts. Use conn.SetReadDeadline to force a cleanup if a client stops sending pings. The standard library does not enforce this for you.

If you attempt to pass a WebSocket connection across HTTP handlers without proper synchronization, you will hit data races. The race detector flags it with WARNING: DATA RACE. If you forget to initialize the hub channels before starting the server, the program panics with all goroutines are asleep - deadlock!. Channels must be created before any goroutine attempts to send or receive.

Load balancers complicate WebSocket scaling because they expect short-lived requests. Most default to round-robin distribution. WebSockets require sticky sessions or a shared state layer. If User A connects to Instance 1 and User B connects to Instance 2, they cannot talk to each other unless Instance 1 and Instance 2 share the connection registry. You solve this with a message broker like Redis Pub/Sub or NATS. Each instance subscribes to a channel and forwards messages to its local clients. The load balancer only needs to distribute initial HTTP upgrades.

If you forget to capture a loop variable in a closure, the compiler rejects the program with loop variable i captured by func literal. This became a hard error in Go 1.22. Always assign loop variables to a new scope or pass them as arguments to avoid stale references in connection handlers.

Goroutines are cheap. Channels are not magic.

Choosing your scaling architecture

Use a single-instance deployment when your user base stays under a few hundred concurrent connections and you want zero operational overhead. Use horizontal scaling with a shared message broker when you need to survive instance failures and distribute load across multiple machines. Use a connection proxy like Envoy or Nginx when you need TLS termination, rate limiting, or protocol translation before traffic reaches your Go application. Use a dedicated WebSocket server framework when your application logic is purely real-time messaging and you want built-in heartbeat management and automatic reconnection handling.

Where to go next