Redis Lists vs Pub/Sub: When to Use Each

Dec 30, 2025 8 mins read


Overview

Redis offers multiple data structures for message passing between services. Two common patterns are Lists (with LPUSH/RPOP operations) and Pub/Sub channels. While both can transport messages, they serve different use cases and have distinct characteristics. This guide explores when to use each pattern, with practical Go examples.

Redis Lists: Queue-Based Messaging

Redis Lists implement a queue data structure. Messages are pushed onto one end of the list and popped from the other, providing FIFO (first-in-first-out) semantics with persistence.

Key Characteristics

  • Persistent: Messages remain in Redis until explicitly consumed
  • Single Consumer: Each message is delivered to exactly one consumer via blocking pop operations
  • Guaranteed Delivery: Messages survive consumer disconnections and Redis restarts (with persistence enabled)
  • Ordering: Messages are consumed in the order they were produced
  • Backpressure: Consumers can process at their own pace; unconsumed messages queue up

When to Use Lists

Lists are ideal when you need:

  • Work queues where each task should be processed exactly once
  • Guaranteed delivery where message loss is unacceptable
  • Single consumer per message for load distribution
  • Async job processing with variable processing times
  • Message persistence across service restarts

Examples: Background job processing, task distribution to workers, email queue, audit log ingestion.

Go Example: Publishing to Lists

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "time"

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

type Message struct {
    ID        string    `json:"id"`
    Content   string    `json:"content"`
    Timestamp time.Time `json:"timestamp"`
}

func main() {
    ctx := context.Background()

    // Connect to Redis
    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer client.Close()

    // Create a message
    msg := Message{
        ID:        "msg-001",
        Content:   "Process this task",
        Timestamp: time.Now(),
    }

    // Marshal to JSON
    jsonData, err := json.Marshal(msg)
    if err != nil {
        log.Fatal(err)
    }

    // Push to list (queue name: "work_queue")
    err = client.LPush(ctx, "work_queue", jsonData).Err()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Message pushed to queue")
}

Go Example: Consuming from Lists

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "time"

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

type Message struct {
    ID        string    `json:"id"`
    Content   string    `json:"content"`
    Timestamp time.Time `json:"timestamp"`
}

func main() {
    ctx := context.Background()

    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer client.Close()

    fmt.Println("Waiting for messages...")

    for {
        // Blocking pop with 5-second timeout
        result, err := client.BRPop(ctx, 5*time.Second, "work_queue").Result()
        if err == redis.Nil {
            // Timeout, no messages available
            continue
        }
        if err != nil {
            log.Printf("Error: %v", err)
            continue
        }

        // result[0] is the queue name, result[1] is the message
        var msg Message
        err = json.Unmarshal([]byte(result[1]), &msg)
        if err != nil {
            log.Printf("Failed to unmarshal: %v", err)
            continue
        }

        fmt.Printf("Received: %+v\n", msg)

        // Process the message here
        time.Sleep(1 * time.Second) // Simulate processing
    }
}

Redis Pub/Sub: Broadcast Messaging

Pub/Sub implements a message broker pattern where publishers send messages to channels, and all subscribed consumers receive copies of each message in real-time.

Key Characteristics

  • Ephemeral: Messages are not stored; they’re delivered to active subscribers and then discarded
  • Multiple Consumers: All subscribers receive a copy of each message
  • Fire and Forget: No delivery guarantees; disconnected subscribers miss messages
  • Real-Time: Minimal latency between publish and delivery
  • No Backpressure: Publishers don’t know or care about subscriber count or processing speed

When to Use Pub/Sub

Pub/Sub excels when you need:

  • Real-time notifications to multiple services
  • Event broadcasting where many consumers need the same data
  • Live updates for dashboards, chat systems, or monitoring
  • Cache invalidation across distributed caches
  • No persistence required for transient events

Examples: Live dashboard updates, chat applications, real-time analytics, cache invalidation, monitoring alerts.

Go Example: Publishing to Pub/Sub

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "time"

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

type Event struct {
    Type      string    `json:"type"`
    Payload   string    `json:"payload"`
    Timestamp time.Time `json:"timestamp"`
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer client.Close()

    // Create an event
    event := Event{
        Type:      "sms.incoming",
        Payload:   "New SMS received",
        Timestamp: time.Now(),
    }

    // Marshal to JSON
    jsonData, err := json.Marshal(event)
    if err != nil {
        log.Fatal(err)
    }

    // Publish to channel
    channelName := "sms:incoming"
    err = client.Publish(ctx, channelName, jsonData).Err()
    if err != nil {
        log.Printf("Failed to publish: %v", err)
        return
    }

    fmt.Printf("Event published to channel: %s\n", channelName)
}

Go Example: Subscribing to Pub/Sub

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"

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

type Event struct {
    Type      string    `json:"type"`
    Payload   string    `json:"payload"`
    Timestamp time.Time `json:"timestamp"`
}

func main() {
    ctx := context.Background()

    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer client.Close()

    // Subscribe to channel
    pubsub := client.Subscribe(ctx, "sms:incoming")
    defer pubsub.Close()

    // Wait for subscription confirmation
    _, err := pubsub.Receive(ctx)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Subscribed to sms:incoming channel")

    // Process messages
    ch := pubsub.Channel()
    for msg := range ch {
        var event Event
        err := json.Unmarshal([]byte(msg.Payload), &event)
        if err != nil {
            log.Printf("Failed to unmarshal: %v", err)
            continue
        }

        fmt.Printf("Received event: %+v\n", event)

        // Process the event here
    }
}

Comparison Table

Feature Lists (LPUSH/RPOP) Pub/Sub
Persistence Yes, until consumed No, ephemeral
Delivery Exactly once per message Zero or more times (all subscribers)
Consumer Model Competing consumers (load balancing) Broadcasting (all subscribers receive)
Message Retention Until explicitly removed Immediate discard after delivery
Ordering FIFO guaranteed Order preserved per channel
Backpressure Yes, queues build up No, slow consumers miss messages
Disconnection Messages wait for consumer Missed messages during downtime
Use Case Task queues, job processing Event broadcasting, notifications

Real-World Example: Migrating from Lists to Pub/Sub

In the Mailer-Go project, I initially used Redis Lists to queue incoming SMS messages. The implementation looked like this:

// Original implementation with Lists
func PushToRedis(cfg config.Config, folderName string, sms SMSMessage) {
    if !cfg.RedisEnabled || redisClient == nil {
        return
    }

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    jsonData, _ := json.Marshal(sms)

    queueName := fmt.Sprintf("sms:%s", folderName)
    err := redisClient.LPush(ctx, queueName, string(jsonData)).Err()
    if err != nil {
        log.Printf("Failed to push to Redis queue: %v", err)
    }
}

This worked fine for simple cases, but I ran into a limitation when integrating with n8n (a workflow automation tool). n8n’s Redis Trigger node only supports Pub/Sub, not Lists. This forced a migration to Pub/Sub:

// Updated implementation with Pub/Sub
func PushToRedis(cfg config.Config, folderName string, sms SMSMessage) {
    if !cfg.RedisEnabled || redisClient == nil {
        return
    }

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    jsonData, err := json.Marshal(sms)
    if err != nil {
        log.Printf("Failed to marshal SMS: %v", err)
        return
    }

    channelName := fmt.Sprintf("sms:%s", folderName)
    err = redisClient.Publish(ctx, channelName, string(jsonData)).Err()
    if err != nil {
        log.Printf("Failed to publish to Redis: %v", err)
    } else {
        log.Printf("Published to Redis channel: %s", channelName)
    }
}

Why the Migration Made Sense

For this use case, Pub/Sub was actually a better fit:

  • Multiple consumers: n8n workflows, logging services, and monitoring all need the same SMS data
  • Real-time processing: SMS notifications should trigger immediately
  • No persistence needed: Email delivery already provides durable storage
  • Event broadcasting: SMS arrival is an event that multiple systems should react to

If the requirement was “ensure each SMS is processed exactly once by a worker pool,” Lists would have been the right choice. But for “notify all interested systems when an SMS arrives,” Pub/Sub is superior.

Choosing the Right Pattern

Ask yourself:

Use Lists if:

  • You need guaranteed delivery and persistence
  • Each message should be processed exactly once
  • You want load balancing across workers
  • Processing failures require retries
  • Messages are jobs/tasks that take time to complete

Use Pub/Sub if:

  • You need to broadcast events to multiple services
  • Real-time delivery is more important than guaranteed delivery
  • It’s acceptable to lose messages during subscriber downtime
  • You’re implementing notifications, chat, or live updates
  • Messages are ephemeral events rather than persistent work items

Common Pitfalls

Lists

  • Memory growth: Unconsumed messages accumulate in Redis memory
  • Consumer failure: A consumer crash can leave messages unprocessed
  • No routing: All consumers share the same queue; you can’t filter by message type easily

Pub/Sub

  • Message loss: Subscribers miss messages while disconnected
  • No history: New subscribers don’t see previous messages
  • No acknowledgment: No way to know if subscribers processed a message
  • Resource usage: Each subscriber consumes memory on the Redis server

Conclusion

Redis Lists and Pub/Sub solve different problems. Lists provide durable, queue-based messaging for work distribution, while Pub/Sub enables real-time event broadcasting. Understanding their trade-offs helps you choose the right pattern for your use case.

For task processing where you need guaranteed delivery, use Lists. For real-time notifications where you need to inform multiple services simultaneously, use Pub/Sub. In many systems, you’ll use both: Lists for reliable job queues, and Pub/Sub for live status updates.