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 commandsd— Module key type eventsm— Key miss events (when a command targets a non-existent key — generates many events)A— Alias forg$lszxet(all events except key misses)
Channel type (where to publish):
K— Keyspace events: channel is__keyspace@{db}__:{key}; message is the event nameE— 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:
typescriptimport 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:
typescriptawait 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
| 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__:expiredwith 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.