Keyspace Notifications
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The# cli/...transcript is replayed by the course harness; the notification runs are@runioredis scripts (a subscriber and a writer on two connections). Timings are wall-clock milliseconds from this machine and vary by tens to hundreds of ms per run. Reference: Redis keyspace notifications. It builds on Pub/Sub and Pattern Subscriptions.
What are keyspace notifications?
Section titled “What are keyspace notifications?”Redis can publish a Pub/Sub message whenever something happens to a key: it is set, deleted, expires, gets evicted. An application subscribes and reacts without polling (invalidate a local cache when a key expires, clean up a session). Because it is Pub/Sub, the events are at-most-once: a subscriber that is offline misses them.
There are two channel families for each event:
- Keyspace channel, one per key; the payload is the event name:
__keyspace@<db>__:<key>carryingset. - Keyevent channel, one per event; the payload is the key name:
__keyevent@<db>__:<event>carryinguser:1.
Enabling them
Section titled “Enabling them”They are off by default (CPU cost for every event). notify-keyspace-events takes a string of flag characters. At least K or E must be present or nothing is delivered.
| Flag | Meaning |
|---|---|
K | keyspace channels (__keyspace@..) |
E | keyevent channels (__keyevent@..) |
g | generic commands: DEL, EXPIRE, RENAME, … |
$ l s h z t a | string, list, set, hash, sorted-set, stream, array commands |
x | expired events |
e | evicted events (key removed by maxmemory, see Eviction Policies) |
d | module key type events |
A | alias for g$lshztdxea (all of the above) |
n m o c | new key, key miss, overwritten, type changed: not part of A |
The server stores the string in a canonical order, so what you read back is not what you wrote:
127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) ""127.0.0.1:6379> CONFIG SET notify-keyspace-events ExOK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "xE"127.0.0.1:6379> CONFIG SET notify-keyspace-events KEAOK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "AKE"127.0.0.1:6379> SET demo:token "hello" EX 5OK127.0.0.1:6379> TTL demo:token(integer) 5127.0.0.1:6379> PTTL demo:token(integer) 4858127.0.0.1:6379> CONFIG SET notify-keyspace-events ""OK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) ""Ex came back as xE, and KEA as AKE (A replaces the individual classes). TTL printed 5 while PTTL printed 4858: TTL rounds to the nearest second.
The expired event, with timing
Section titled “The expired event, with timing”The script sets demo:token with EX 3, subscribes with PSUBSCRIBE __keyevent@0__:expired on a second connection, and measures when the event arrives. It then records which events fire with KEA and with KEAnm:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sub = new Redis(url);
// 1) the expired event, with timing. "Ex" = keyevent channel + expired eventsawait redis.config('SET', 'notify-keyspace-events', 'Ex');console.log(await redis.config('GET', 'notify-keyspace-events')); // [ 'notify-keyspace-events', 'xE' ] (canonical order)
let setAt = 0;const expired = new Promise<void>((resolve) => { sub.on('pmessage', (_pattern, channel, key) => { console.log(`${channel} -> ${key} after ${Date.now() - setAt} ms`); resolve(); });});await sub.psubscribe('__keyevent@0__:expired');setAt = Date.now();await redis.set('demo:token', 'abc', 'EX', 3); // the TTL says 3 sawait expired;
// 2) event classes. A = every generic class, but n (new key) and m (key miss) are NOT part of Aasync function record(flags: string) { await redis.config('SET', 'notify-keyspace-events', flags); const seen: string[] = []; sub.removeAllListeners('pmessage'); sub.on('pmessage', (_p, channel, payload) => seen.push(`${channel} ${payload}`)); await sub.punsubscribe(); await sub.psubscribe('__key*@0__:*'); await redis.set('user:1', 'a'); await redis.get('nope'); await redis.del('user:1'); await new Promise((r) => setTimeout(r, 100)); return seen;}console.log('KEA :', await record('KEA'));console.log('KEAnm:', await record('KEAnm'));
await redis.config('SET', 'notify-keyspace-events', '');sub.disconnect();await redis.quit();Real output (this Mac):
[ 'notify-keyspace-events', 'xE' ]__keyevent@0__:expired -> demo:token after 3056 msKEA : [ '__keyspace@0__:user:1 set', '__keyevent@0__:set user:1', '__keyspace@0__:user:1 del', '__keyevent@0__:del user:1']KEAnm: [ '__keyspace@0__:user:1 new', '__keyevent@0__:new user:1', '__keyspace@0__:user:1 set', '__keyevent@0__:set user:1', '__keyspace@0__:nope keymiss', '__keyevent@0__:keymiss nope', '__keyspace@0__:user:1 del', '__keyevent@0__:del user:1']What this shows:
- A 3-second TTL produced the event at 3,056 ms here (3,100 and 3,196 in two other runs): a bit later than the TTL, never earlier. It is a notification about the key being deleted, not a timer.
- The same operations produce a pair of events per class, one on the keyspace channel and one on the keyevent channel.
- With
KEAthere is nonewand nokeymissevent; they only appear withnandm. AGETof a missing key generatedkeymiss(a cache-miss counter without touching the client). I also checkedoandcthe same way: overwriting a key emittedoverwritten, and replacing a list with a string emittedoverwrittenthentype_changed; neither is inA.
CONFIG SET notify-keyspace-events Ex
SET demo:token "hello" EX 5
TTL demo:token
PTTL demo:tokenWhen does the expired event fire?
Section titled “When does the expired event fire?”A key with a TTL is removed in two ways (docs): lazily, when a command touches it and finds it expired, and actively, by a background cycle that samples keys with a TTL. The event fires when the key is deleted, so if nobody touches it, the delay depends on the background cycle, controlled by hz (how often the server runs periodic jobs, default 10). The next script uses a 300 ms TTL, once left alone and once read after 400 ms, at hz 10 and hz 1, and then 200,000 keys that all expire at the same millisecond:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sub = new Redis(url);await redis.config('SET', 'notify-keyspace-events', 'Ex');
const firedAt = new Map<string, number>();sub.on('message', (_channel, key) => firedAt.set(key, Date.now()));await sub.subscribe('__keyevent@0__:expired');
// A key with a 300 ms TTL: when does the event arrive if nobody looks at it (active expiry) vs if a GET touches it (lazy expiry)?async function trial(name: string, touchAfterMs?: number) { const t = Date.now(); await redis.set(name, 'v', 'PX', 300); if (touchAfterMs) { await new Promise((r) => setTimeout(r, touchAfterMs)); await redis.get(name); } await new Promise((r) => setTimeout(r, 2300)); console.log(name.padEnd(16), 'PX 300 -> event after', (firedAt.get(name) ?? 0) - t, 'ms');}for (const hz of ['10', '1']) { // hz = how often the server runs its background jobs, active expiry included await redis.config('SET', 'hz', hz); console.log(`hz ${hz}`); await trial(`untouched-hz${hz}`); await trial(`touched-hz${hz}`, 400);}await redis.config('SET', 'hz', '10');
// 200,000 keys that all expire at the very same millisecondconst N = 200000;const deadline = Date.now() + 3000;let count = 0, first = 0, last = 0;sub.removeAllListeners('message');sub.on('message', (_c, key) => { if (!key.startsWith('load:')) return; count++; last = Date.now() - deadline; first ||= last;});await redis.eval("for i=1,ARGV[1] do redis.call('SET','load:'..i,'v','PXAT',ARGV[2]) end return 1", 0, N, deadline);await new Promise((r) => setTimeout(r, 6500));console.log(`load: ${count} events, first ${first} ms and last ${last} ms after the shared deadline`);
await redis.config('SET', 'notify-keyspace-events', '');await redis.flushall();sub.disconnect();await redis.quit();Real output (this Mac):
hz 10untouched-hz10 PX 300 -> event after 474 mstouched-hz10 PX 300 -> event after 338 mshz 1untouched-hz1 PX 300 -> event after 2043 mstouched-hz1 PX 300 -> event after 407 msload: 200000 events, first 54 ms and last 920 ms after the shared deadline- Untouched vs touched. At
hz 10an untouched key’s event arrived 474 ms after the SET (174 ms after its TTL); athz 1it took 2,043 ms for a 300 ms TTL. The same key read byGETafter 400 ms produced the event at once (407 ms): lazy expiry deletes on access. - Under load. 200,000 keys with the same deadline produced all 200,000 events, but spread over about a second (first 54 ms, last 920 ms after the deadline): active expiry works in bounded slices so it does not freeze the server, at the price of lateness.
So treat expired as “this key is gone now, at some point after its TTL”, never as a scheduler with millisecond accuracy. If you need a job at a precise time, use a Sorted Set of due times or a delayed queue.
Where keyspace notifications fit, and where they do not
Section titled “Where keyspace notifications fit, and where they do not”- They are node-local in a cluster: each node publishes events for its own keys, and unlike normal Pub/Sub they are not broadcast, so subscribe to every node (docs).
- The event carries the key name only, not the value: the value is already deleted when
expiredfires, so store what the handler needs in the key name or in a shadow key. - They are at-most-once: a disconnected subscriber misses events. Anything that must happen (send the reminder, release the seat) needs a periodic reconciliation or a Stream, not just this event.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant implements “cancel unpaid orders after 15 minutes” with a key TTL and the expired event:
🐛 Spot the bug
await redis.set(`order:${id}:hold`, '1', 'EX', 900);
sub.on('message', async (_channel, key) => {const id = key.split(':')[1];await cancelIfUnpaid(id); // runs when the hold key expires});await sub.subscribe('__keyevent@0__:expired');Show the answer
Two problems, both from the runs above. First, the feature never fires on a default server: notify-keyspace-events is empty, so subscribe succeeds and stays silent forever (the transcript’s first CONFIG GET returned ""). Second, even when enabled it is at-most-once and late: if the service restarts or the subscriber reconnects, orders whose hold expired in the gap are never cancelled, and under load the events trickled in over a second. Enable the flag as part of deployment (Ex), and reconcile with a periodic query for orders past their deadline instead of trusting the event alone:
await redis.config('SET', 'notify-keyspace-events', 'Ex'); // or in redis.conf: notify-keyspace-events "Ex"setInterval(cancelOverdueOrders, 60_000); // the safety net does not depend on deliveryMental Model
Section titled “Mental Model”Keyspace notifications are a doorbell that rings when the server notices a key change, not a clock. It rings for the class of events you switched on, only for whoever is standing at the door, and for expired it rings when the server gets around to removing the key, which is usually within a fraction of a second and occasionally much later. Treat every ring as a hint.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
A narrow flag set (Ex) | Little overhead; only the events you consume | Add flags later if you need other classes |
KEA | Everything (except n, m, o, c) with no further config | Every event on every write is published: CPU and subscriber traffic |
| Event instead of polling | No repeated queries | At-most-once, and expired is late (474 ms for a 300 ms TTL; up to 920 ms extra under a 200k burst) |
| Lazy + active expiry | Cheap: no per-key timers | No precise timing; untouched keys wait for the background cycle |
| Reading a key to force expiry | Immediate event (407 ms at hz 1) | Turns notification into polling; only for keys you already read |
Common Misconceptions
Section titled “Common Misconceptions”- “The expired event fires the instant the TTL reaches zero.” It fires when the key is deleted: 474 ms for a 300 ms TTL, 2,043 ms at
hz 1. - “
KEAmeans all events.” It excludes new-key, key-miss, overwritten and type-changed events. - “
CONFIG GETreturns what I set.”Exreads back asxEandKEAasAKE. - “The event tells me the value that expired.” Only the key name; the value is gone.
- “Notifications are reliable.” They are Pub/Sub: offline subscribers miss them.
Common mistakes
Section titled “Common mistakes”- Forgetting to enable
notify-keyspace-events—subscribesucceeds and nothing arrives. - Using it as a precise scheduler — use a Sorted Set of due times.
- Enabling
KEAon a busy server — publish only the classes you use. - No fallback for missed events — reconcile periodically.
- Subscribing to one node of a cluster — events are node-local.
Real-world example
Redis’s own documentation describes the two channel families, the flags, and states that “there are no guarantees that the Redis server will be able to generate the expired event at the time the key time to live reaches the value of zero” (Redis keyspace notifications, “Timing of expired events”).