Redis Beyond Caching: Pub/Sub, Streams, and Time Series Data

Redis Is More Than a Cache

Most developers know Redis as a fast key-value cache. Set a key, get a key, set an expiration. And that's a great use case — Redis handles millions of operations per second with sub-millisecond latency. But treating it as just a cache means you're ignoring some genuinely useful data structures and features.

Pub/Sub for Real-Time Features

Redis Pub/Sub is the simplest way to add real-time messaging between services or to clients. It's not a message queue (messages are fire-and-forget — if nobody's listening, the message is lost), but for use cases where that's acceptable, it's hard to beat the simplicity.

# Publisher
import redis
r = redis.Redis()
r.publish('notifications', '{"user_id": 42, "type": "order_shipped"}')

# Subscriber
pubsub = r.pubsub()
pubsub.subscribe('notifications')
for message in pubsub.listen():
    if message['type'] == 'message':
        handle_notification(json.loads(message['data']))

Common uses: invalidating caches across multiple app servers, pushing real-time updates to WebSocket connections, broadcasting configuration changes.

The limitation: Pub/Sub doesn't persist messages. If your subscriber disconnects and reconnects, it misses everything published during the gap. For durable messaging, you want Redis Streams.

Redis Streams: Event Sourcing Made Simple

Streams, introduced in Redis 5.0, are append-only log data structures — think of them as a lightweight Kafka topic built into Redis.

# Add events to a stream
r.xadd('orders:events', {
    'type': 'order_created',
    'order_id': '12345',
    'amount': '9999',
    'customer': 'jane@example.com'
})

# Read events (consumer group for load balancing)
r.xgroup_create('orders:events', 'processor-group', id='0', mkstream=True)

# Each consumer in the group gets different messages
messages = r.xreadgroup(
    'processor-group', 'worker-1',
    {'orders:events': '>'},
    count=10, block=5000
)

# Acknowledge after processing
for stream, entries in messages:
    for entry_id, data in entries:
        process_order_event(data)
        r.xack('orders:events', 'processor-group', entry_id)

Streams give you:

  • Message persistence (survives restarts)
  • Consumer groups (multiple consumers sharing the workload)
  • Message acknowledgment (at-least-once delivery)
  • Historical replay (read from any point in the stream)
  • Automatic ID generation with timestamps

For small to medium scale event processing (under 100,000 events/second), Streams are a solid alternative to running a separate Kafka cluster. You get 80% of the functionality with 20% of the operational complexity.

Time Series Data

Redis has a time series module (RedisTimeSeries) that's optimized for metrics, sensor data, and monitoring. If you're already running Redis, it can save you from deploying a separate time series database for smaller workloads.

# Create a time series key with retention and labels
r.ts().create('sensor:temp:living-room',
    retention_msecs=86400000,  # Keep 24 hours
    labels={'room': 'living-room', 'type': 'temperature'})

# Add data points
r.ts().add('sensor:temp:living-room', '*', 22.5)
r.ts().add('sensor:temp:living-room', '*', 22.7)

# Aggregate queries
r.ts().range('sensor:temp:living-room',
    from_time='-',
    to_time='+',
    aggregation_type='avg',
    bucket_size_msec=3600000)  # Hourly averages

For serious time series workloads (millions of data points per second, long retention), you'll want a dedicated database like TimescaleDB or InfluxDB. But for application metrics, IoT dashboards with a few hundred sensors, or real-time monitoring counters, RedisTimeSeries keeps things simple.

Other Useful Patterns

Sorted Sets for leaderboards and ranking:

# Add scores
r.zadd('game:leaderboard', {'alice': 2500, 'bob': 1800, 'charlie': 3200})

# Top 10 players
r.zrevrange('game:leaderboard', 0, 9, withscores=True)

# Player's rank
r.zrevrank('game:leaderboard', 'bob')  # Returns 2 (0-indexed)

HyperLogLog for unique counting: Approximate unique visitor counts using 12KB of memory regardless of cardinality. Accuracy within 0.81%.

# Count unique visitors
r.pfadd('visitors:2026-09-20', user_id)
unique_count = r.pfcount('visitors:2026-09-20')

Geospatial indexes: Store locations and query by distance.

r.geoadd('restaurants', -122.4194, 37.7749, 'joes-pizza')
# Find restaurants within 2km
r.geosearch('restaurants', longitude=-122.42, latitude=37.77,
    radius=2, unit='km')

When Not to Use Redis

Redis keeps everything in memory. A dataset that's 50GB means you need 50GB+ of RAM (plus replication). For large datasets, consider whether the data genuinely needs sub-millisecond access or if a disk-based solution would work fine with a small cache layer on top.

Also, Redis persistence (RDB snapshots and AOF) is good but not as durable as a proper database. Don't use Redis as your primary data store for data you can't afford to lose. Use it alongside your database, not instead of it.