DEV Community

Amon Ochuka
Amon Ochuka

Posted on

Scaling Real-Time Stream Chat in Go: In-Memory Hubs, WebSockets, and Redis Pub/Sub

Live stream chat is deceptively hard. What starts as "just broadcast messages to connected clients" becomes a distributed systems problem the moment you scale past a single server instance.

Wapi is a live streaming app tailored for African content, with a backend written in Go. Its chat takes a pragmatic approach to scaling: an in-memory WebSocket hub per process, with an optional Redis Pub/Sub broadcaster for cross-instance fan-out. In this post I'll break down why the design works, what it costs you, and where it starts to strain.

The Problem With Single-Instance Chat

If you only ever run one backend instance, keeping all connected clients in memory and broadcasting to them directly is the simplest and fastest option. You get low latency, no external dependencies, and a system that is easy to reason about. Go is well suited to this because goroutines and channels make managing thousands of concurrent connections cheap.

In production, though, you'll almost always want multiple instances behind a load balancer, for redundancy, for rolling deploys, and for capacity. With a pure in-memory hub, a client connected to instance A never receives messages sent by a client connected to instance B. Your "live" chat fragments based on which instance each user was routed to, and users in the same stream see different conversations.

Why Not Just Use Sticky Sessions?

The obvious shortcut is to route every viewer of a given stream to the same instance. It can work, but it has real drawbacks:

  • Hot streams break it. A popular stream can have more viewers than a single instance can hold, and you can't split it.
  • Deploys get painful. Restarting an instance drops every connection pinned to it, and all those clients reconnect at once.
  • Load balancing gets complicated. You need routing logic that understands stream IDs, not just connections.

Fan-out between instances removes all three problems. Any instance can serve any viewer, and the instances keep each other informed.

The Architecture

The design is a hybrid. Each instance runs its own hub that manages WebSocket connections and broadcasts locally. If REDIS_URL is configured, each instance also publishes its messages to Redis and subscribes to messages from other instances. A message arriving from Redis is re-broadcast to local connections, but only if it originated on a different instance, so it never echoes back.

This gives you:

  • Local speed: no network hop between clients on the same instance
  • Horizontal scalability: add instances without breaking chat
  • Graceful degradation: without Redis, it falls back to single-instance mode
  • Simplicity: no dedicated message queue, just Redis Pub/Sub

How the In-Memory Hub Works

The hub's job is to track which clients are watching which stream, and to deliver each message to everyone watching that stream. Internally it is a map from stream ID to a set of connected clients.

The important design choice is how that map is protected. Instead of guarding it with locks, the hub runs in a single goroutine and receives all changes through channels: one for clients registering, one for clients leaving, and one for messages to broadcast. Because only that goroutine ever touches the map, there are no data races and no lock contention, and the event loop is easy to follow.

Each client gets its own buffered outbound channel, drained by a dedicated write goroutine that pushes messages down the WebSocket. A matching read goroutine handles incoming messages. This read/write pump pair per connection is the standard pattern for WebSockets in Go, and it keeps slow network writes off the hub's critical path.

The Slow Client Problem

Every real-time system eventually meets the slow consumer: a client on a bad mobile connection whose outbound buffer fills up. If the hub blocks while delivering to that client, one bad connection stalls chat for everyone in the stream.

For an audience that is largely on mobile data, this isn't an edge case, it's the normal case.

The fix is to never block. If a client's buffer is full, the hub drops that client, closes its channel, and lets the client reconnect. It sounds harsh, but it protects the many from the few. Combined with history loading on reconnect, the user barely notices.

Adding Redis for Cross-Instance Fan-Out

On startup, each instance generates a unique instanceID, for example with uuid.NewString(). When it publishes a message to Redis, it attaches this ID to the payload. When it receives a message from Redis, it checks the ID first:

  • Same ID: the message came from this instance, and local clients already got it directly from the hub. Ignore it.
  • Different ID: another instance produced it. Hand it to the local hub for delivery.

Without that check, every message would be delivered twice on the instance that sent it.

A design choice worth noting is how channels are laid out. Publishing to one channel per stream (something like chat:stream:<id>) and subscribing with a single pattern subscription keeps the subscriber code simple. Each instance holds one subscription rather than managing a subscribe and unsubscribe for every stream as viewers come and go.

One more detail matters for correctness: the Redis listener should not write to the hub's client map directly. It should send messages into the same broadcast channel that local messages use, so the single-goroutine guarantee holds.

Why Pub/Sub and Not Something Heavier

Redis Pub/Sub is fire-and-forget. If a subscriber is disconnected when a message is published, that message is gone for that subscriber. For payments that would be unacceptable. For live chat it is usually fine, for three reasons:

  1. Chat is ephemeral by nature, and a dropped message during a brief blip is rarely noticed.
  2. Every message is also persisted to the database, so history is complete even if live delivery isn't.
  3. The operational cost is tiny compared with running Kafka or a full message broker.

If you later need replay, consumer groups, or delivery guarantees, Redis Streams, NATS JetStream, or Kafka are the natural next steps. Start simple and upgrade when a real requirement forces you to.

Putting It Together

The full flow looks like this:

  1. Client connects via WebSocket to something like /api/v1/streams/:id/ws, with auth.
  2. Connection is upgraded and the client is registered with the local hub for that stream.
  3. Client sends a message. The service layer validates it (rate limit, ban check, permissions), persists it to the database for history, then pushes it to the local hub and publishes it to Redis with the instance ID.
  4. Other local clients receive it instantly from the hub.
  5. Other instances receive it from Redis, see a different instance ID, and broadcast it to their own local clients.

Things That Bite You in Production

Ordering. Within a single instance, messages are delivered in the order the hub processes them. Across instances there is no global ordering guarantee. Two users on different instances can send messages at nearly the same moment, and viewers may see them in different orders. For most chats this is acceptable, but if it matters, assign ordering on the server using database timestamps or sequence IDs and let clients sort on that.

Authentication and origin checks. WebSocket upgrades bypass a lot of the protections you rely on for normal HTTP requests. Validate the token during the upgrade, check the Origin header, and re-check permissions when bans or role changes happen mid-session, not just at connect time.

Reconnection storms. When an instance restarts, every client it held reconnects at once. Clients should use exponential backoff with jitter so they don't all retry in lockstep and overwhelm the remaining instances.

Heartbeats. Half-open connections are common, especially on mobile. Use WebSocket ping/pong with read deadlines so dead connections get cleaned up rather than lingering in the hub's map and leaking memory.

Message size and rate limits. Cap payload sizes and rate limit per user, for example 5 messages per second. Stream chat gets hot fast, and one spammer can saturate both the hub and Redis.

Observability. Track connected clients per instance, messages published and received per second, dropped slow clients, and Redis publish errors. When chat feels laggy, these numbers tell you whether the problem is the hub, the network, or Redis.

Lessons Learned

  • Filter your echoes. The instance ID check is non-negotiable. Without it, you get duplicate messages.
  • Never block the hub. Drop slow clients rather than letting them stall everyone else.
  • Persist for history. WebSockets are ephemeral. Write every message to your database so clients can load history on reconnect.
  • Don't let Redis block chat. If a publish fails, log it and carry on. Local delivery should never depend on Redis being up.
  • Plan for disconnects. Networks drop. Build reconnection with backoff into the client from day one.
  • Know the limits. Pub/Sub doesn't persist messages, and every instance receives every message for the streams it subscribes to. That is fine at moderate scale, but at very high volume you'll want to shard channels or move to a different transport.

Conclusion

The hybrid in-memory plus Redis approach is a sweet spot for most applications. It keeps the fast path local, scales horizontally with one small dependency, and degrades gracefully when Redis is unavailable. It doesn't overcomplicate your architecture before you need it, and the upgrade path to Streams or a full broker stays open. If you're building real-time features in Go, this pattern is worth stealing.

Top comments (0)