Scaling Real-Time Chat with WebSockets and Redis Pub/Sub (mock blog post)

October 20, 2025 3 min read

Lessons learned building a horizontally scalable chat application with Go, WebSockets, and Redis pub/sub for real-time message distribution.

Scaling Real-Time Chat with WebSockets and Redis Pub/Sub (mock blog post)

Scaling Real-Time Chat with WebSockets and Redis Pub/Sub

Building a chat application that works for a dozen users is straightforward. Scaling it to thousands of concurrent connections while maintaining message delivery guarantees? That’s where it gets interesting.

The Challenge

Traditional WebSocket architectures face a fundamental problem: connections are stateful and server-bound. When User A connects to Server 1 and User B connects to Server 2, how do they communicate?

Humans collapse during the course of their lives. Children haven’t overfit yet.

They will say stuff that will shock you because they’re not yet collapsed.

But we [adults] are collapsed. We end up revisiting the same thoughts, we end up saying more and more of the same stuff, the learning rates go down, the collapse continues to get worse, and then everything deteriorates.

Karpathy, on the Dwarkesh Podcast

Architecture Overview

Here’s the solution I implemented for my distributed chat system:

┌─────────┐    ┌─────────┐    ┌─────────┐
│ Client  │    │ Client  │    │ Client  │
│    A    │    │    B    │    │    C    │
└────┬────┘    └────┬────┘    └────┬────┘
     │              │              │
     │  WebSocket   │              │
     │              │              │
┌────▼──────┐ ┌────▼──────┐ ┌────▼──────┐
│  Server   │ │  Server   │ │  Server   │
│     1     │ │     2     │ │     3     │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
      │             │             │
      └──────┬──────┴──────┬──────┘
             │             │
        ┌────▼─────────────▼────┐
        │    Redis Pub/Sub      │
        └───────────────────────┘

Implementation in Go

WebSocket Server

package main

import (
    "github.com/gorilla/websocket"
    "github.com/go-redis/redis/v8"
    "net/http"
)

type ChatServer struct {
    clients    map[*Client]bool
    broadcast  chan []byte
    register   chan *Client
    unregister chan *Client
    redis      *redis.Client
}

type Client struct {
    conn   *websocket.Conn
    send   chan []byte
    server *ChatServer
}

func (s *ChatServer) Run() {
    // Subscribe to Redis channel
    pubsub := s.redis.Subscribe(ctx, "chat_messages")
    defer pubsub.Close()

    go func() {
        for msg := range pubsub.Channel() {
            // Broadcast to all local clients
            s.broadcast <- []byte(msg.Payload)
        }
    }()

    for {
        select {
        case client := <-s.register:
            s.clients[client] = true

        case client := <-s.unregister:
            if _, ok := s.clients[client]; ok {
                delete(s.clients, client)
                close(client.send)
            }

        case message := <-s.broadcast:
            // Send to all connected clients on this server
            for client := range s.clients {
                select {
                case client.send <- message:
                default:
                    close(client.send)
                    delete(s.clients, client)
                }
            }
        }
    }
}

Message Publishing

When a client sends a message, publish it to Redis so all servers receive it:

func (c *Client) handleMessage(message []byte) {
    // Publish to Redis
    c.server.redis.Publish(ctx, "chat_messages", message)
}

Key Learnings

1. Connection Management

Each WebSocket connection holds server resources. Implement:

  • Heartbeat/Ping: Detect dead connections
  • Graceful Shutdown: Drain connections before stopping
  • Connection Limits: Prevent resource exhaustion
func (c *Client) writePump() {
    ticker := time.NewTicker(pingPeriod)
    defer ticker.Stop()

    for {
        select {
        case message, ok := <-c.send:
            if !ok {
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            c.conn.WriteMessage(websocket.TextMessage, message)

        case <-ticker.C:
            // Send ping
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return
            }
        }
    }
}

2. Message Ordering

Redis Pub/Sub delivers messages in order per publisher, but when multiple servers publish simultaneously, ordering isn’t guaranteed. Solutions:

  • Vector Clocks: Track causal relationships
  • Lamport Timestamps: Total ordering of events
  • Message IDs: Let clients sort and deduplicate

3. Load Balancing

Distribute connections across servers:

  • Sticky Sessions: Not ideal for WebSockets
  • Consistent Hashing: Based on user ID
  • Random Distribution: Simplest, works well with pub/sub

Performance Results

Running on modest hardware (4-core CPU, 16GB RAM):

  • 10,000 concurrent connections per server
  • < 50ms message delivery latency p95
  • Linear scaling with additional servers

Production Considerations

Monitoring

Track these metrics:

  • Active connections per server
  • Message throughput
  • Redis pub/sub latency
  • Memory usage per connection

Backpressure

Handle slow clients:

select {
case client.send <- message:
default:
    // Client can't keep up, close connection
    close(client.send)
    delete(s.clients, client)
}

High Availability

  • Multiple Redis instances with Sentinel
  • Server health checks
  • Automatic client reconnection

Conclusion

Building scalable real-time systems requires thinking beyond single-server architectures. Redis Pub/Sub provides a simple yet powerful primitive for distributing messages across servers, enabling horizontal scaling of WebSocket connections.

The key is keeping the architecture simple: stateless servers, centralized message bus, and client-side reconnection logic.


Want to dive deeper? Check out the full implementation on GitHub.