How to Use WebSockets with Redis Pub/Sub for Scaling

Web
Scale WebSockets by having servers publish client messages to a Redis channel and subscribe to it to broadcast updates across all instances.

The scaling wall

You build a real-time chat app. One server, one WebSocket endpoint, a map of connected clients. Messages flow instantly. You deploy it to production. Traffic doubles. You add a second server behind a load balancer. Suddenly, half your users stop seeing messages. The load balancer routes new connections to either server. Server A only knows about its own clients. Server B only knows about its own. When a user on Server A types a message, Server B never hears about it. The WebSocket connection is a direct pipe. It does not cross process boundaries. You need a shared broadcast layer that sits outside your application servers.

How Redis Pub/Sub bridges the gap

Redis Pub/Sub is a fire-and-forget messaging system. Publishers send a payload to a named channel. Subscribers listen to that channel. When a message arrives, Redis immediately forwards it to every active subscriber. There is no acknowledgment. There is no persistence. If a subscriber disconnects, the message is gone. That sounds like a weakness until you realize real-time UIs do not need perfect delivery. They need speed. If a message drops, the client will reconnect and fetch history from your database anyway. Pub/Sub handles the live stream. Your database handles the record.

Think of it like a walkie-talkie network. Every server runs a radio tuned to the same frequency. When a local user speaks, the server transmits to the frequency. Every other server hears it and repeats it to their local users. Redis is the radio tower. Your Go servers are the radios. The WebSocket connections are the speakers.

Redis Pub/Sub is fast because it runs in a single thread and uses a simple fan-out algorithm. It does not block the publisher. It does not wait for subscribers to acknowledge. It pushes and moves on. That design makes it perfect for scaling stateless WebSocket servers.

Goroutines are cheap. Redis Pub/Sub is the broadcast layer.

Minimal setup

Here is the simplest subscriber loop. It connects to Redis, subscribes to a channel, and prints incoming messages.

// RedisClient does X
func RedisClient() *redis.Client {
    // Connect to local Redis instance with default options
    rdb := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    return rdb
}

// SubscribeLoop listens to a channel and forwards messages
func SubscribeLoop(ctx context.Context, rdb *redis.Client, channel string) {
    // Create a subscriber attached to the specific channel
    sub := rdb.Subscribe(ctx, channel)
    defer sub.Close()

    // Receive blocks until a message arrives or context cancels
    for {
        msg, err := sub.Receive(ctx)
        if err != nil {
            // Context cancellation or network drop ends the loop
            return
        }
        // msg.Payload contains the raw bytes from the publisher
        fmt.Println(string(msg.Payload))
    }
}

Here is the publisher side. It runs inside your HTTP or WebSocket handler when a client sends data.

// PublishMessage sends data to all subscribed servers
func PublishMessage(ctx context.Context, rdb *redis.Client, channel string, data []byte) error {
    // Publish returns the number of subscribers that received the message
    res := rdb.Publish(ctx, channel, data)
    if err := res.Err(); err != nil {
        // Network error or Redis unreachable
        return err
    }
    return nil
}

The subscriber runs in a background goroutine. The publisher runs inline when a request arrives. Redis routes the bytes. Your application decides what to do with them.

Keep the subscriber loop tight. Let Redis do the routing.

Walking through the message lifecycle

A client connects to Server A via WebSocket. Server A stores the connection in a local map. Server A also starts a goroutine that calls SubscribeLoop for the chat-room channel. Server B does the exact same thing. Both servers are now listening to the same Redis channel.

The client on Server A sends a JSON payload. Server A's WebSocket handler reads the bytes. It calls PublishMessage with the same payload. Redis receives the publish command. It looks up all active subscribers for chat-room. It finds Server A and Server B. Redis pushes the payload to both.

Server A's subscriber goroutine wakes up. It receives the message. It iterates its local connection map. It writes the payload to every WebSocket. Server B's subscriber goroutine wakes up. It receives the exact same message. It iterates its local map. It writes to its WebSockets. The client on Server B sees the message. The client on Server A sees the message. The load balancer does not need to know anything about routing. Each server handles its own local connections. Redis handles the cross-server coordination.

The entire flow takes milliseconds. No database queries. No distributed locks. Just a single publish call and a fan-out.

Context is plumbing. Run it through every long-lived call site.

Production-ready pattern

Real applications need connection management, safe concurrent writes, and graceful shutdown. WebSockets are not thread-safe for writes. If two goroutines call WriteMessage on the same connection simultaneously, the underlying TCP stream corrupts. You need a mutex or a buffered channel per connection. The channel pattern is safer because it serializes writes in a dedicated goroutine.

Here is a connection manager that handles local broadcasting and safe writes.

// Client represents a single WebSocket connection
type Client struct {
    conn *websocket.Conn
    // send buffers outgoing messages for safe concurrent writes
    send chan []byte
    // stop signals the writer goroutine to exit
    stop chan struct{}
}

// NewClient initializes a client with a buffered send channel
func NewClient(conn *websocket.Conn) *Client {
    return &Client{
        conn: conn,
        // Buffer allows the publisher to return immediately
        send: make(chan []byte, 256),
        stop: make(chan struct{}),
    }
}

// Writer runs in a goroutine and serializes WebSocket writes
func (c *Client) Writer() {
    defer c.conn.Close()
    for {
        select {
        case msg, ok := <-c.send:
            if !ok {
                return
            }
            // WriteJSON handles framing and masking automatically
            if err := c.conn.WriteJSON(msg); err != nil {
                return
            }
        case <-c.stop:
            return
        }
    }
}

Here is the broadcast function that ties Redis to your local clients.

// Broadcast forwards a Redis message to all local clients
func Broadcast(clients []*Client, payload []byte) {
    for _, c := range clients {
        select {
        case c.send <- payload:
            // Message queued for safe delivery
        default:
            // Client is slow or disconnected, skip to avoid blocking
        }
    }
}

You start the Writer goroutine when a client connects. You pass the Client slice to your Redis subscriber loop. When sub.Receive returns a message, you call Broadcast. The select with default prevents a slow client from blocking the entire broadcast. The stop channel lets you shut down cleanly when the server receives a signal.

The community accepts the if err != nil boilerplate because it makes the unhappy path visible. Check every Redis call. Check every WebSocket write. Return early. Do not swallow errors.

Don't fight the type system. Wrap the value or change the design.

Pitfalls and compiler traps

Goroutine leaks are the most common failure mode. If your subscriber goroutine blocks on sub.Receive(ctx) and you never cancel the context, the goroutine stays alive after the server shuts down. The Go runtime will wait for it. Your process will hang. Always pass a cancellable context to Subscribe and Receive. Call cancel() on shutdown. Close the subscriber. The goroutine will unblock and exit.

Redis Pub/Sub drops messages if a subscriber is disconnected. Network blips happen. Your subscriber should reconnect automatically. Wrap the SubscribeLoop in a retry loop with exponential backoff. Do not assume the connection stays open forever.

Compiler errors catch type mismatches early. If you forget to initialize the Redis client, you get undefined: redisClient. If you pass a string where bytes are expected, the compiler rejects this with cannot use x (type string) as []byte in argument. If you import github.com/redis/go-redis/v9 but never use it, you get imported and not used. Go forces you to acknowledge every return value. If you ignore the error from res.Err(), the compiler warns you. Use _ only when you deliberately discard a value and understand the tradeoff.

Runtime panics usually come from nil dereferences or concurrent map writes. Never modify a connection map while iterating it. Use a sync.RWMutex or a channel-based registry. Never call WriteMessage from multiple goroutines. Use the send channel pattern. Trust the compiler warnings. Fix the race conditions before they hit production.

The worst goroutine bug is the one that never logs.

Decision matrix

Use Redis Pub/Sub when you need low-latency cross-server broadcasting and can tolerate occasional message loss during network partitions. Use direct in-process broadcasting when all clients connect to a single server and you want zero external dependencies. Use a persistent message queue like RabbitMQ or Kafka when every message must be delivered exactly once and consumers can process at different speeds. Use database polling or long-polling when real-time updates are not required and you want to avoid managing WebSocket connections entirely. Use a dedicated real-time backend service when you want to offload connection management, scaling, and protocol handling to a third party.

Pick the tool that matches your delivery guarantees and latency requirements. Do not overengineer a single-server app with distributed messaging.

Where to go next