Skip to content

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 @run ioredis 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.

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> carrying set.
  • Keyevent channel, one per event; the payload is the key name: __keyevent@<db>__:<event> carrying user:1.

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.

FlagMeaning
Kkeyspace channels (__keyspace@..)
Ekeyevent channels (__keyevent@..)
ggeneric commands: DEL, EXPIRE, RENAME, …
$ l s h z t astring, list, set, hash, sorted-set, stream, array commands
xexpired events
eevicted events (key removed by maxmemory, see Eviction Policies)
dmodule key type events
Aalias for g$lshztdxea (all of the above)
n m o cnew 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:

cli/persistence-expiration-keyspace-notifications-1.redis
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) ""
127.0.0.1:6379> CONFIG SET notify-keyspace-events Ex
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "xE"
127.0.0.1:6379> CONFIG SET notify-keyspace-events KEA
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "AKE"
127.0.0.1:6379> SET demo:token "hello" EX 5
OK
127.0.0.1:6379> TTL demo:token
(integer) 5
127.0.0.1:6379> PTTL demo:token
(integer) 4858
127.0.0.1:6379> CONFIG SET notify-keyspace-events ""
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "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 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:

src/persistence-expiration-keyspace-notifications-expired.ts
// @run
import { 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 events
await 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 s
await expired;
// 2) event classes. A = every generic class, but n (new key) and m (key miss) are NOT part of A
async 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 ms
KEA : [
'__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 KEA there is no new and no keymiss event; they only appear with n and m. A GET of a missing key generated keymiss (a cache-miss counter without touching the client). I also checked o and c the same way: overwriting a key emitted overwritten, and replacing a list with a string emitted overwritten then type_changed; neither is in A.
CONFIG SET notify-keyspace-events Ex
SET demo:token "hello" EX 5
TTL demo:token
PTTL demo:token

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:

src/persistence-expiration-keyspace-notifications-lazy.ts
// @run
import { 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 millisecond
const 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 10
untouched-hz10 PX 300 -> event after 474 ms
touched-hz10 PX 300 -> event after 338 ms
hz 1
untouched-hz1 PX 300 -> event after 2043 ms
touched-hz1 PX 300 -> event after 407 ms
load: 200000 events, first 54 ms and last 920 ms after the shared deadline
  • Untouched vs touched. At hz 10 an untouched key’s event arrived 474 ms after the SET (174 ms after its TTL); at hz 1 it took 2,043 ms for a 300 ms TTL. The same key read by GET after 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 expired fires, 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.

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 delivery

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.

ChoiceBenefitCost
A narrow flag set (Ex)Little overhead; only the events you consumeAdd flags later if you need other classes
KEAEverything (except n, m, o, c) with no further configEvery event on every write is published: CPU and subscriber traffic
Event instead of pollingNo repeated queriesAt-most-once, and expired is late (474 ms for a 300 ms TTL; up to 920 ms extra under a 200k burst)
Lazy + active expiryCheap: no per-key timersNo precise timing; untouched keys wait for the background cycle
Reading a key to force expiryImmediate event (407 ms at hz 1)Turns notification into polling; only for keys you already read
  • “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.
  • “KEA means all events.” It excludes new-key, key-miss, overwritten and type-changed events.
  • “CONFIG GET returns what I set.” Ex reads back as xE and KEA as AKE.
  • “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.
  • Forgetting to enable notify-keyspace-events — subscribe succeeds and nothing arrives.
  • Using it as a precise scheduler — use a Sorted Set of due times.
  • Enabling KEA on 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”).

Which CONFIG flag enables keyevent channels (__keyevent@...)?
What channel delivers expiry events on database 0?
You run CONFIG SET notify-keyspace-events Ex and then CONFIG GET. What do you see?
With notify-keyspace-events set to KEA, a GET of a missing key...
A key with a 300 ms TTL is never read. With hz 1 the expired event arrived after 2,043 ms. Why so late?
A service uses the expired event to cancel unpaid orders and is restarted for a minute. What happens to orders that expired meanwhile?