Single-node · In-memory · gRPC

Heads up: I ended up switching to NATS + PostgreSQL. If you need consumer groups, bare NATS won't help — you need JetStream, which requires persistence. tinybroker fills the gap when you want in-memory consumer groups with no persistence overhead. The code works and is free to use; just know why you're reaching for it.

Intra-replica communication

When you scale a service to multiple replicas behind a load balancer, each replica is isolated. A request that arrives at Replica A cannot directly talk to Replica B. tinybroker bridges this gap without requiring shared databases, Redis, or sticky sessions.

The problem

A user connects to POST /cache/invalidate. The load balancer routes the request to Replica A. Replicas B and C still hold the stale cache entry. Without a broadcast mechanism, you need either:

  • A shared external cache (Redis, Memcached) — adds a network hop for every read
  • Sticky sessions — reduces load-balancing effectiveness and complicates failover
  • A heavy message broker — disproportionate operational cost

The solution

All replicas subscribe to cache.invalidate.* on startup. Any replica that receives an invalidation request publishes the key to that topic. All replicas receive the message and drop their local copy.

Replica A (HTTP handler)    tinybroker    Replica B    Replica C
        │                       │              │            │
 POST /cache/invalidate         │              │            │
        │                       │              │            │
        │── Publish ────────────►              │            │
        │    "cache.invalidate.user:42"        │            │
        │                       │              │            │
        │                       │─ Message ────►            │
        │                       │─ Message ─────────────────►
        │                       │                           │
     200 OK                     │          (local caches cleared)

Startup pattern

Each replica subscribes on startup and holds the connection open for the lifetime of the process:

stream, _ := client.Subscribe(ctx)
stream.Send(&pb.SubscribeCommand{
    Command: &pb.SubscribeCommand_Open{Open: &pb.OpenSubscription{
        TopicPatterns: []string{"cache.invalidate.*", "config.reload"},
        Mode:          pb.DeliveryMode_DELIVERY_MODE_MULTI,
    }},
})
go func() {
    for {
        ev, err := stream.Recv()
        if err != nil { reconnect(); return }
        handleInternalEvent(ev.GetMessage())
    }
}()

Patterns for common use cases

ScenarioTopic patternMode
Cache invalidationcache.invalidate.*MULTI (all replicas)
Config hot-reloadconfig.reloadMULTI
Distributed rate-limit syncrate.counter.*MULTI
Job dispatch to one workerjobs.*BALANCED
Scheduled task, one leaderschedule.*SINGLETON

Reconnection

Always implement reconnect logic in the subscription goroutine/task. tinybroker is in-memory — a restart clears all state. A dropped connection (pod restart, rolling update) is expected and should be handled with an exponential-backoff reconnect loop.