How to Use Redis Pub/Sub in Go

Connect to Redis in Go using the official client library to subscribe to channels and listen for real-time messages.

A message that needs to find its audience

Your payment service just confirmed a transaction. The inventory system needs to deduct stock. The analytics pipeline needs to log the event. The frontend dashboard needs to show a green checkmark. You could wire direct HTTP calls between every service, but that creates a tangled web of dependencies. One service goes down and the whole chain stalls. Instead, you broadcast the event once and let interested services listen. That is the core promise of publish-subscribe.

How pub/sub actually works

Publish-subscribe flips the traditional request-response model on its head. In a normal HTTP call, the client asks for data and waits for a reply. Pub/sub is a fire-and-forget broadcast. A publisher drops a message into a named channel. Redis takes that message and instantly forwards a copy to every active subscriber listening on that channel. The publisher never knows who received it, how many subscribers exist, or whether the message was processed successfully.

Think of it like a radio station. The station broadcasts on a specific frequency. Listeners tune their dials to that frequency. The station does not care how many radios are on, what model they are, or whether they are in the same room. It just transmits. If a listener turns off their radio, the broadcast continues. If a new listener tunes in, they only hear what comes after they connect. Nothing is stored for later. That ephemeral nature is both the strength and the limitation of pub/sub.

Goroutines are cheap. Channels are not magic.

The minimal subscriber

Here is the simplest way to listen for messages in Go. The code sets up a client, subscribes to a channel, and loops over incoming payloads.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/redis/go-redis/v9"
)

func main() {
	// v9 is the current standard; v8 is deprecated and lacks generics
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})

	ctx := context.Background()
	// Subscribe opens a dedicated connection that stays alive
	sub := rdb.Subscribe(ctx, "orders.created")
	defer sub.Close() // Free the Redis connection when main exits

	// Channel() returns a Go channel that receives Redis messages
	msgCh := sub.Channel()

	// Block until the OS sends an interrupt signal
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	<-sigCh

	for msg := range msgCh {
		fmt.Printf("Channel: %s, Payload: %s\n", msg.Channel, msg.Payload)
	}
}

The Subscribe call does not block. It returns immediately with a *PubSub object. The actual network connection opens in the background. Calling Channel() bridges that Redis connection to a native Go channel. The for msg := range msgCh loop blocks until Redis pushes data or the context cancels.

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

What happens under the hood

When you call Subscribe, the go-redis client creates a separate TCP connection to Redis. It sends a SUBSCRIBE command and then stops sending anything else. That connection is now locked to pub/sub traffic. You cannot run GET or SET commands on it. The client maintains this connection in its internal pool, but pub/sub connections are usually kept separate from your standard command pool to avoid blocking other requests.

Redis stores the subscription in memory. When a publisher sends a PUBLISH command, Redis looks up the channel name, finds the list of subscriber connections, and pushes the payload to each one. The go-redis client reads those pushes and writes them into the Go channel returned by Channel(). If your Go code is slow to read from that channel, the internal buffer fills up. Once full, the Redis client blocks on the next read, which can cause backpressure or timeout errors depending on your configuration.

Context cancellation is the standard way to tear this down. When ctx.Done() fires, the client sends an UNSUBSCRIBE command, closes the TCP socket, and closes the Go channel. The range loop exits cleanly. Always pass a context that carries a deadline or cancellation signal. Long-running listeners without cancellation paths become zombie processes.

The receiver name is usually one or two letters matching the type. You will see (c *Client) Subscribe(...) in the library source, not (this *Client). The Go community prefers short, consistent names. Trust gofmt. Argue logic, not formatting.

Publishing and handling real traffic

Real applications need to publish messages and handle subscriber errors gracefully. Here is a publisher that sends structured data and a subscriber that processes messages with proper error handling.

// PublishEvent sends a JSON payload to a Redis channel
func PublishEvent(ctx context.Context, rdb *redis.Client, channel string, payload string) error {
	// Publish returns the number of subscribers that received the message
	res := rdb.Publish(ctx, channel, payload)
	if err := res.Err(); err != nil {
		return fmt.Errorf("publish failed: %w", err)
	}
	return nil
}

The publisher is straightforward. Publish returns a *IntCmd. You must check .Err() because Redis might reject the command due to network issues or authentication failures. The return value tells you how many active subscribers got the message. Zero is normal if no one is listening yet.

Subscribers need more care. Production code rarely just prints to stdout. It usually forwards the message to a worker pool or writes to a database. Here is a robust listener pattern.

// ListenForEvents blocks and processes messages until context cancels
func ListenForEvents(ctx context.Context, rdb *redis.Client, channel string) {
	sub := rdb.Subscribe(ctx, channel)
	defer sub.Close()

	msgCh := sub.Channel()
	// Buffered channel prevents the Redis client from blocking
	// if your processing logic takes longer than the push rate
	bufferedCh := make(chan *redis.Message, 100)

	go func() {
		for msg := range msgCh {
			bufferedCh <- msg
		}
	}()

	for {
		select {
		case <-ctx.Done():
			return
		case msg, ok := <-bufferedCh:
			if !ok {
				return
			}
			// Process the message synchronously or dispatch to workers
			handleMessage(msg.Payload)
		}
	}
}

The select loop gives you control over cancellation and backpressure. The intermediate buffered channel decouples the Redis read loop from your business logic. If handleMessage takes two seconds but messages arrive every hundred milliseconds, the buffer absorbs the spike. If the buffer fills, the Redis read goroutine blocks, which naturally slows down the subscription. That is usually safer than dropping messages or panicking.

if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Write it out. Do not hide it behind a helper that swallows failures.

Where things go wrong

Pub/sub looks simple until the network hiccups or your processing logic stalls. The most common failure is the silent goroutine leak. If you spawn a goroutine to read from sub.Channel() but never close the subscription or cancel the context, that goroutine runs forever. The compiler will not catch it. You will see your process memory climb until the OOM killer steps in. Always tie the subscription lifecycle to a cancellable context.

Network drops are another headache. Redis pub/sub connections do not automatically reconnect in the same way standard client connections do. If the TCP socket breaks, go-redis will try to reconnect, but the subscription state might be lost. You will get a redis: connection lost error on the next operation. Wrap your subscriber in a retry loop that recreates the Subscribe call when the context is still valid.

The compiler will reject your code if you forget to handle the error from Publish. You will see error returned but not handled or similar type mismatches if you ignore the *IntCmd result. The Go community accepts verbose error checks because they make failure paths explicit. Write if err := res.Err(); err != nil { return err }. Do not swallow it.

Another trap is assuming pub/sub guarantees delivery. It does not. If a subscriber crashes while processing a message, that message is gone forever. Pub/sub is for real-time notifications, not reliable task queues. If you need exactly-once delivery or acknowledgment, you are looking at a different tool.

The worst goroutine bug is the one that never logs.

When to reach for pub/sub

Use Redis pub/sub when you need low-latency event broadcasting across multiple listeners. Use a direct HTTP or gRPC call when one service needs a synchronous response from another. Use a message queue like RabbitMQ or AWS SQS when you need guaranteed delivery, message persistence, and consumer acknowledgment. Use database polling or change data capture when you cannot modify the producer to emit events. Use a shared cache with TTLs when you only need eventual consistency and can tolerate stale reads.

Where to go next