Keyspace Notifications and Event-Driven Architectures16 min read
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).
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$lshztdxea (every event class except key-miss events)
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:
text
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:
text
Keyevent Channels (E)
Channel: __keyevent@{db}__:{event}
Message: the key name
Subscribe to a specific event type to receive it for all keys:
text
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
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
Practical Pattern: Audit Log for Sensitive Keys
Log all modifications to a sensitive key namespace:
typescript
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)
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
Concern
Keyspace Notifications
Redis Streams
Delivery guarantee
At-most-once (Pub/Sub)
At-least-once (with ACK)
Replay missed events
No
Yes
Event source
Redis internal events
Application-defined events
Configuration
notify-keyspace-events in redis.conf
Application writes to stream
Overhead
CPU on every matching write
Storage per event
Best for
Best-effort hooks (cache warming)
Authoritative event processing
The pattern for reliable event-driven Redis:
Your application writes data to Redis
Your application also writes an event to a Redis Stream
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.
Knowledge Check
A team needs to build a "Session Expiry Hook" to clean up database records whenever a Redis session key naturally expires via TTL. They want to listen for expiration events across all keys matching the session:user:* pattern. What is the most efficient configuration for notify-keyspace-events to achieve this?
An e-commerce platform implements a strict compliance requirement: every single modification (write, delete, expire) to their Redis pricing:catalog must be logged to a PostgreSQL audit table. They implement a Node.js microservice that subscribes to __keyspace@0__:pricing:catalog with notify-keyspace-events "KEA". During a massive Black Friday traffic spike, the microservice crashes due to OOM and takes 30 seconds to restart. What is the state of their audit log for those 30 seconds?
A developer wants to proactively monitor cache misses to tune their caching strategy. They enable notify-keyspace-events "KEm" in production to receive an event every time a command requests a non-existent key. What is the most likely consequence of this change on a heavily loaded web application with a 15% cache miss rate?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
"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
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
SUBSCRIBE __keyevent@0__:expired
# Receives the key name whenever ANY key expires:
# "session:user:1001"
# "rate:user:2002:window"
# "cache:product:999"
import Redis from'ioredis';const subscriber =newRedis();const publisher =newRedis();// separate connection for other operations// Enable keyspace notifications (expired events only)await publisher.config('SET','notify-keyspace-events','Ex');// Subscribe to all expired eventsawait subscriber.subscribe('__keyevent@0__:expired');subscriber.on('message',async(channel:string, expiredKey:string)=>{// Only handle keys that match our cache patternif(!expiredKey.startsWith('cache:'))return;console.log(`Key expired: ${expiredKey}`);// Re-populate the cacheconst value =awaitfetchFromDatabase(expiredKey);if(value !==null){await publisher.set(expiredKey,JSON.stringify(value),'EX',300);}});
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 devicesawait db.query('DELETE FROM sessions WHERE id = $1',[sessionId]);awaitnotifyUserDevices(userId,'SESSION_EXPIRED');});
// Subscribe to all write events on the payments namespaceawait 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]);});