Pub/Sub vs Keyspace Notifications for Invalidation

You have several services that each cache the same entities, and a mutation in one of them must evict the matching keys everywhere else. Two Redis mechanisms can carry that signal, and they look interchangeable until you run them in production. The first is an explicit application PUBLISH: your write path deliberately emits an invalidation message on a channel you own, carrying whatever payload the subscribers need. The second is keyspace notifications, where Redis itself fires an event such as __keyevent@0__:del every time a key is deleted, expired, or overwritten, and subscribers react without the writer knowing they exist. This page compares the two, wires up a subscriber for each, and shows why both are fire-and-forget — so neither is safe without a durability backstop. It sits directly beneath pub/sub routing and shares the resilient receive loop built in implementing Redis Pub/Sub.

Prerequisites

  • Redis 7.x reachable from every service and from redis-cli.
  • redis-py 5.x on Python 3.10+ (pip install "redis>=5,<6"); the examples use the redis.asyncio client.
  • Permission to run CONFIG SET notify-keyspace-events (or the flag pre-set in redis.conf).
  • A stable channel naming convention for the explicit path, e.g. cache:invalidate:<domain>.
  • An understanding that neither mechanism persists a missed message — plan a TTL or a durable stream as the safety net.

Step-by-Step Implementation

Each step is independently runnable: point REDIS_URL at your instance and paste it into a REPL before you compose the pieces into a service.

1. Enable keyspace notifications

Keyspace notifications are off by default because they add per-command overhead, so you opt in with a flag string that selects exactly which events Redis emits.

# K = keyspace channel, E = keyevent channel, g = generic (DEL/EXPIRE/RENAME),
# x = expired, e = evicted. "KEA" would emit every event class — usually too much.
redis-cli CONFIG SET notify-keyspace-events "KEgxe"
redis-cli CONFIG GET notify-keyspace-events   # confirm the flags stuck

2. Subscribe to the keyevent channel

Subscribe to a __keyevent@0__: channel to learn which key was affected by a given event type; the event name is fixed in the channel and the key name arrives as the message payload.

import redis.asyncio as redis

client = redis.Redis.from_url("redis://localhost:6379/0", decode_responses=True)

async def watch_deletions(handler):
    pubsub = client.pubsub(ignore_subscribe_messages=True)
    # __keyevent@0__:del fires once per DEL/UNLINK on database 0.
    # The message payload is the key name that was removed.
    await pubsub.psubscribe("__keyevent@0__:del", "__keyevent@0__:expired")
    async for msg in pubsub.listen():
        if msg["type"] == "pmessage":
            await handler(affected_key=msg["data"], event=msg["channel"])

3. Publish an explicit invalidation message instead

The alternative is to make invalidation deliberate: after the source-of-truth commit, PUBLISH a structured payload on a channel you control, so subscribers get intent and context rather than reverse-engineering a raw keyspace event.

import json

async def invalidate(domain: str, entity_id: str, reason: str) -> int:
    payload = json.dumps({"entity": entity_id, "reason": reason, "ts": _now_ms()})
    # Explicit channel + rich payload: subscribers know why, not just what.
    # Returns the number of clients that received the message (0 = nobody home).
    return await client.publish(f"cache:invalidate:{domain}", payload)

4. Wire a subscriber that dispatches the bust

Whichever source you choose, the subscriber's job is the same — turn a received message into an UNLINK of the local cache key, preferring UNLINK over DEL so a large value never blocks the event loop.

async def handle_message(data: str, from_keyspace: bool) -> None:
    if from_keyspace:
        key = data                      # keyevent payload is already the key name
    else:
        key = f"cache:{json.loads(data)['entity']}"   # derive key from payload
    # UNLINK reclaims memory off-thread; safe for large cached values.
    await client.unlink(key)

5. Back the signal with a durability safety net

Because a PUBLISH and a keyspace event are both delivered only to clients connected at that instant, add a short TTL on every cached key so a missed bust self-heals, or route critical invalidations through a durable write-behind buffer with Redis Streams that survives a disconnected consumer.

async def cache_with_safety_net(key: str, value: str, ttl: int = 120) -> None:
    # The 120s TTL bounds how long a dropped invalidation can serve stale data.
    await client.set(key, value, ex=ttl)
Explicit PUBLISH versus keyspace-notification invalidation paths into shared subscribers Two source paths reach the same subscribers. Path one: the application write path explicitly publishes a JSON invalidation payload on a cache:invalidate channel. Path two: a plain UNLINK on the write path makes Redis emit a keyspace event on __keyevent@0__:del with the key name as payload. Both channels feed a subscriber fan-out, which issues UNLINK against each local cache. A dashed note marks that both paths are fire-and-forget, so an offline subscriber misses the message and only a safety-net TTL or a durable stream recovers it. TWO TRIGGER PATHS · ONE SUBSCRIBER FAN-OUT Write path source-of-truth commit, then trigger Explicit PUBLISH cache:invalidate:<domain> rich JSON payload · intent Keyspace event __keyevent@0__:del key name only · automatic app emits UNLINK → Redis emits Subscribers pubsub() receive loop dispatch local UNLINK cache A cache B cache C Both paths are fire-and-forget — no delivery guarantee A subscriber that is offline, reconnecting, or over its output buffer when the message fires never receives it. Redis keeps no backlog. Recover with a safety-net TTL on every key, or route critical busts through a durable Redis Stream.

Failure Modes

Notifications enabled but nothing arrives. You subscribed to __keyevent@0__:del but the handler never fires, usually because the flag string omits the class you need — g covers DEL/UNLINK, and x covers expirations, so notify-keyspace-events set to only KE emits nothing. Diagnose by reading the live flags and generating a test event:

redis-cli CONFIG GET notify-keyspace-events        # must include E plus g/x/e
redis-cli DEL __probe__ >/dev/null; redis-cli --timeout 2 PSUBSCRIBE '__keyevent@0__:del'

Fix by widening the flag string (KEgxe) and confirming your client subscribed on the same database index that the writes target.

Wrong database index. Keyspace channels are scoped per database — __keyevent@0__ only reports writes to logical DB 0 — so a subscriber on @0 sees nothing when the application writes to DB 1. Diagnose by checking where the mutation lands:

redis-cli -n 1 SET probe:1 x
redis-cli PSUBSCRIBE '__keyevent@1__:set'   # events surface only on the matching @N

Fix by subscribing on every database your services actually use, or by consolidating onto a single database so one channel covers the whole keyspace.

Missed message serves stale forever. The publisher fired while a subscriber was reconnecting, so the bust was silently dropped and that node keeps serving the pre-mutation value. Diagnose by reconciling a cached value against the source of truth for a sampled key, then check whether PUBLISH even reached a listener:

redis-cli PUBLISH cache:invalidate:user '{"entity":"1001"}'   # returns receiver count; 0 = lost

Fix with the Step 5 safety-net TTL, and for invalidations that must not be lost, move to a durable stream consumer group instead of best-effort Pub/Sub.

Verification

Confirm keyspace events fire for the exact command you rely on, using a second terminal as the subscriber while you mutate a key:

redis-cli PSUBSCRIBE '__keyevent@0__:*' &
redis-cli UNLINK product:12345      # expect a "del" event carrying "product:12345"

Confirm the explicit path reaches at least one subscriber before you trust it in the write flow — a return value of 0 means no service was listening:

redis-cli PUBLISH cache:invalidate:product '{"entity":"12345","reason":"price_update"}'
# integer reply is the number of clients that received it

Confirm the notification overhead is acceptable under load by comparing throughput with the flags on and off:

redis-cli CONFIG SET notify-keyspace-events ""     # baseline
redis-benchmark -t set -n 100000 -q
redis-cli CONFIG SET notify-keyspace-events "KEA"  # worst case, every event class
redis-benchmark -t set -n 100000 -q                # compare ops/sec delta

FAQ

Do keyspace notifications or Pub/Sub guarantee delivery?

Neither does. Both are built on the same Pub/Sub transport, which delivers a message only to clients connected at the instant it is sent and keeps no backlog. If a subscriber is disconnected, still reconnecting, or force-closed for exceeding its client-output-buffer-limit pubsub, the message is gone with no acknowledgement and no replay. Treat both as best-effort signals and pair them with a bounded TTL or a durable stream for anything where a missed bust is a correctness bug.

What is the performance cost of turning on keyspace notifications?

Every write that matches an enabled event class triggers an extra internal PUBLISH, so cost scales with write volume and with how broad your flag string is. A narrow selection such as Egx — keyevent notifications for generic and expired events only — is far cheaper than the catch-all KEA, which emits on both keyspace and keyevent channels for every class and roughly doubles the notification traffic. Measure the delta with redis-benchmark before and after enabling the flags, and never enable classes no subscriber consumes.

Why does the channel say @0 and does that matter?

The @0 is the logical database index the event occurred on: __keyevent@0__:del reports deletions on database 0, __keyevent@3__:del reports them on database 3. Keyspace notifications never cross database boundaries, so a subscriber must listen on the same index its writers use. This is the most common reason a correctly configured subscriber sees no events — the application quietly writes to a different database than the one being watched.

How do keyspace notifications behave on a Redis Cluster?

Notifications are node-local: each primary emits events only for the keys in the slots it owns, so a single subscriber connected to one node sees only that node's share of the keyspace. To observe every invalidation you must subscribe on every primary, and you should prefer sharded Pub/Sub with SSUBSCRIBE so the message stays on the slot's node instead of being broadcast cluster-wide. An explicit PUBLISH has the same locality limits, which is another reason cross-service busts often route through a durable stream.

When should I use Redis Streams instead of either notification path?

Reach for Streams the moment a lost invalidation is unacceptable. A stream persists each entry, and a consumer group tracks per-consumer acknowledgements, so a subscriber that was offline reads the backlog on reconnect and nothing is silently dropped. That durability is exactly what the write-behind buffer with Redis Streams relies on. The trade-off is more moving parts and manual trimming, so keep Pub/Sub or keyspace notifications for high-volume, loss-tolerant signals and escalate to Streams only for the invalidations that must not be missed.


Up: Pub/Sub Routing for Cross-Service Invalidation