Module P-8·16 min read

notify-keyspace-events configuration, the event type matrix (key expiry, deletion, Set/List/Hash writes), subscribing to expiry for cache warming, and the critical limitation: keyspace notifications are at-most-once.

P-8 — Keyspace Notifications and Event-Driven Architectures

Who this module is for: You want to trigger application logic when a Redis key expires, is deleted, or is modified — without polling. Keyspace notifications let you subscribe to Redis's internal events via Pub/Sub. This module covers the configuration, the event type matrix, practical patterns, and the critical limitation that most documentation buries at the bottom.


What Keyspace Notifications Are

Keyspace notifications allow Redis to publish messages to Pub/Sub channels when specific events occur — a key expires, a key is written, a key is deleted. Your application subscribes to these channels and reacts to events.

This enables:

  • Cache warming: re-populate a key when it expires
  • Audit logging: record every mutation to sensitive keys
  • Event-driven workflows: trigger downstream logic when a key is set
  • Session expiry hooks: notify your app when a session token expires

Enabling Keyspace Notifications

By default, keyspace notifications are disabled (they add CPU overhead to every write operation).

CONFIG SET notify-keyspace-events "KEA"

Or in redis.conf:

notify-keyspace-events "KEA"

The Event Flag String

The value is a combination of flag characters:

Event type (what happened):

  • g — generic commands (DEL, EXPIRE, RENAME, COPY)
  • $ — String commands (SET, GETSET, SETRANGE, APPEND, INCR, etc.)
  • l — List commands (LPUSH, RPUSH, LPOP, RPOP, LMOVE, etc.)
  • s — Set commands (SADD, SREM, SPOP, etc.)
  • h — Hash commands (HSET, HDEL, etc.)
  • z — Sorted Set commands (ZADD, ZINCRBY, ZREM, etc.)
  • x — Expired events (key expired by TTL)
  • e — Evicted events (key evicted by maxmemory-policy)
  • t — Stream commands
  • d — Module key type events
  • m — Key miss events (when a command targets a non-existent key — generates many events)
  • A — Alias for g$lszxet (all events except key misses)

Channel type (where to publish):

  • K — Keyspace events: channel is __keyspace@{db}__:{key}; message is the event name
  • E — Keyevent events: channel is __keyevent@{db}__:{event}; message is the key name

You must specify at least one channel type (K or E) and at least one event type.

Common configurations:

"KEA"    → both channel types, all events — maximum coverage, maximum overhead
"Kx"     → keyspace events for key expiry only
"Ex"     → keyevent events for key expiry only — recommended for most use cases
"Kg$"    → keyspace events for generic + String commands
"Ex$z"   → keyevent events for expiry + String + Sorted Set commands

The Two Channel Types

Keyspace Channels (K)

Channel: __keyspace@{db}__:{key}
Message: the event name (e.g., "expired", "set", "del")

Subscribe to a specific key to receive all events affecting it:

SUBSCRIBE __keyspace@0__:session:user:1001

# Receives:
# "set"      when the key is created/updated
# "expire"   when EXPIRE is called
# "expired"  when the key's TTL causes automatic deletion
# "del"      when DEL is called

Keyevent Channels (E)

Channel: __keyevent@{db}__:{event}
Message: the key name

Subscribe to a specific event type to receive it for all keys:

SUBSCRIBE __keyevent@0__:expired

# Receives the key name whenever ANY key expires:
# "session:user:1001"
# "rate:user:2002:window"
# "cache:product:999"

For most use cases (cache expiry hooks, session expiry), __keyevent@0__:expired is the right channel.


Practical Pattern: Cache Re-Population on Expiry

When a cached value expires, automatically re-populate it:

typescript
import Redis from 'ioredis'; const subscriber = new Redis(); const publisher = new Redis(); // separate connection for other operations // Enable keyspace notifications (expired events only) await publisher.config('SET', 'notify-keyspace-events', 'Ex'); // Subscribe to all expired events await subscriber.subscribe('__keyevent@0__:expired'); subscriber.on('message', async (channel: string, expiredKey: string) => { // Only handle keys that match our cache pattern if (!expiredKey.startsWith('cache:')) return; console.log(`Key expired: ${expiredKey}`); // Re-populate the cache const value = await fetchFromDatabase(expiredKey); if (value !== null) { await publisher.set(expiredKey, JSON.stringify(value), 'EX', 300); } });

Important limitation: The notification fires after the key is deleted. There is a brief window where the key does not exist in Redis. Re-population via the notification handler closes this window, but it is not instantaneous. If a request arrives during this window, it sees a miss.


Practical Pattern: Session Expiry Hook

Notify your application when a user's session token expires:

typescript
await subscriber.psubscribe('__keyevent@0__:expired'); subscriber.on('pmessage', async (pattern: string, channel: string, expiredKey: string) => { const match = expiredKey.match(/^session:user:(\d+):(\w+)$/); if (!match) return; const [, userId, sessionId] = match; console.log(`Session expired for user ${userId}, session ${sessionId}`); // Trigger cleanup: remove session from database, log out related devices await db.query('DELETE FROM sessions WHERE id = $1', [sessionId]); await notifyUserDevices(userId, 'SESSION_EXPIRED'); });

Practical Pattern: Audit Log for Sensitive Keys

Log all modifications to a sensitive key namespace:

typescript
// Subscribe to all write events on the payments namespace await subscriber.psubscribe('__keyspace@0__:payment:*'); subscriber.on('pmessage', async (pattern: string, channel: string, event: string) => { const key = channel.replace('__keyspace@0__:', ''); await db.query( 'INSERT INTO audit_log (key, event, timestamp) VALUES ($1, $2, NOW())', [key, event] ); });

The Critical Limitation: At-Most-Once Delivery

This is the most important thing to understand about keyspace notifications, and it is often buried in documentation:

Keyspace notifications use Pub/Sub internally. Pub/Sub has no delivery guarantees.

If your subscriber is disconnected when an event fires — Redis restart, network blip, subscriber process crash — the notification is permanently lost. There is no replay. The key expired, and your application never found out.

Consequences:

  • Cache re-population handler misses expiry → cache is not re-populated → next request is a miss (relatively benign)
  • Session expiry handler misses expiry → cleanup logic does not run → sessions left in database (moderate)
  • Audit logger misses events → incomplete audit trail (severe for compliance)

Keyspace notifications are not suitable for:

  • Authoritative event processing where every event must be handled
  • Audit logs that must be complete
  • Any workflow where missing an event causes permanent data inconsistency

For reliable event delivery, use Streams. Configure your application to write to a Stream on every relevant Redis write, and process the Stream with consumer groups and ACK semantics.


Performance Impact

Every Redis write command that matches the configured notification flags generates an internal event and publishes it to the relevant Pub/Sub channels. This adds CPU overhead to the write path.

Rough overhead:

  • "Ex" (expiry only): minimal — only triggered on TTL-based key deletion
  • "KEA" (all events): 5–15% additional CPU per write operation for a write-heavy instance

Do not enable "KEA" on a write-heavy production instance without benchmarking.

For most use cases:

  • "Ex" for expiry hooks (lowest overhead)
  • "K$" for String write monitoring
  • "Kxg" for expiry + generic commands (DEL, EXPIRE) monitoring

Key Miss Events (m flag)

notify-keyspace-events "KEm"

Key miss events fire whenever a command accesses a key that does not exist. This can generate enormous event volume on a cache with a non-trivial miss rate. Do not enable key miss events unless you have a specific reason and have measured the overhead.


Keyspace Notifications vs Streams for Event-Driven Design

ConcernKeyspace NotificationsRedis Streams
Delivery guaranteeAt-most-once (Pub/Sub)At-least-once (with ACK)
Replay missed eventsNoYes
Event sourceRedis internal eventsApplication-defined events
Configurationnotify-keyspace-events in redis.confApplication writes to stream
OverheadCPU on every matching writeStorage per event
Best forBest-effort hooks (cache warming)Authoritative event processing

The pattern for reliable event-driven Redis:

  1. Your application writes data to Redis
  2. Your application also writes an event to a Redis Stream
  3. Consumers process the Stream with consumer groups and ACK

Keyspace notifications are a convenience layer — they are fine for non-critical triggers where occasional missed events are acceptable.


Summary

  • Enable with CONFIG SET notify-keyspace-events "flags" — disabled by default due to CPU overhead
  • Two channel types: Keyspace (__keyspace@{db}__:{key} → event name) and Keyevent (__keyevent@{db}__:{event} → key name)
  • For expiry hooks: subscribe to __keyevent@0__:expired with flag "Ex"
  • Critical limitation: at-most-once delivery — notifications are lost if the subscriber is disconnected
  • Not suitable for authoritative event processing, audit logs, or any workflow requiring guaranteed delivery
  • Overhead scales with event flag breadth — "Ex" is minimal; "KEA" is significant on write-heavy instances
  • For reliable event-driven architectures: write to Redis Streams instead and use consumer groups

Next: P-9 — Session Management Patterns — storing sessions as Hashes vs JSON strings, sliding expiry, multi-device sessions, and the consistency trade-offs when reading from replicas.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.