JusDB
Free DB Audit
NoSQL Databases

Valkey Keyspace Notifications in Production: Expired Events, hexpired and Cluster Mode

Run Valkey keyspace notifications in production: why expired and hexpired events lag the TTL, how to subscribe on every cluster node, and when a stream is safer.

JusDB Team
Published November 10, 2026
20 min read

A team enables notify-keyspace-events on Valkey so a worker can close sessions when their keys expire. On a laptop it works. In production the cleanup runs seconds or minutes after the TTL, some events never arrive, and on the cluster the worker only hears about one shard's keys. None of this is a bug. Valkey publishes an expired event when it deletes the key, not when the TTL reaches zero, and in cluster mode every node publishes only its own events. Valkey 9.0 added hexpire, hexpired, and hpersist events for hash fields, which follow the same timing. This guide covers how the events work on Valkey 9.0 and 9.1, what they cost, how to subscribe on a cluster, the October 2026 change to oversized cluster PUBLISH, and when a stream is the better source of change events.

TL;DR
  • Notifications are off by default (notify-keyspace-events ""). You need K or E plus at least one event class, for example Ex for expired-key events only.
  • expired and hexpired fire when the server deletes the key or field, through lazy access or the background expiry cycle. Expect a delay after the TTL, and no expiry events at all while writes are paused.
  • In a cluster, each node publishes events for its own keys only. Subscribe on every primary, and set the config on replicas too so a promoted replica keeps publishing.
  • Delivery is at-most-once. A disconnected or slow subscriber loses events for good, so pair notifications with a reconciliation job or write a stream entry at the source.
  • Run 9.0.7 or 9.1.3 or later on clusters: they fix GHSA-f4q9-j22m-xqq9, and cluster PUBLISH and SPUBLISH now reject payloads too large for the cluster bus.

Scope and review date: November 2026. This post covers Valkey 9.0 and 9.1 (latest patches 9.0.7 and 9.1.3), standalone and cluster mode, with 9.2 release candidate items labelled as such. Flags, defaults, event names, and error strings were checked against the Valkey documentation, the 9.1.3 source, and the release notes on 10 November 2026. The commands were not run in a lab, so rehearse them on staging.


How do Valkey keyspace notifications work?

Keyspace notifications reuse Pub/Sub. When a command changes a key, the server publishes up to two messages on special channels. The keyspace notifications page gives the example of DEL mykey in database 0, which is equivalent to these two publishes:

bash
PUBLISH __keyspace@0__:mykey del
PUBLISH __keyevent@0__:del mykey

Interpretation: the __keyspace channel is named after the key and carries the event name. The __keyevent channel is named after the event and carries the key name. Use PSUBSCRIBE __keyspace@0__:session:* to follow a family of keys, or subscribe to __keyevent@0__:expired to hear every expiry in database 0.

The notify-keyspace-events string picks which classes are published. K and E choose the channel types, and the other letters choose event classes. If neither K nor E is present, nothing is delivered.

FlagPublishes
K / EKeyspace channels / keyevent channels
gGeneric commands such as DEL, EXPIRE, RENAME
$ l s h z tString, list, set, hash, sorted set, and stream commands
xExpired events
eEvicted events (keys removed by the maxmemory policy)
m / nKey-miss events / new-key events (neither is part of A)
dModule key type events
AAlias for g$lshztxed
bash
# Expired-key events only, on keyevent channels
valkey-cli CONFIG SET notify-keyspace-events Ex
valkey-cli CONFIG GET notify-keyspace-events

Interpretation: CONFIG GET returns the flags in the server's own order, so Ex reads back as xE. That is the same setting, not a typo. The setting is per node and is lost on restart unless it is in valkey.conf or saved with CONFIG REWRITE.

What does enabling notify-keyspace-events cost?

The 9.1.3 valkey.conf ships with notify-keyspace-events "" and says notifications are disabled because most users do not need them and "the feature has some overhead". The overhead has two parts, so choose the smallest flag set that answers your question, such as Ex.

  • Publish work on every matching write. With KEA, each modifying command can publish two messages. The PUBLISH command page gives its complexity as O(N+M), where N is the number of subscribers to the channel and M is the total number of pattern subscriptions on the node. Every PSUBSCRIBE pattern is checked against every event.
  • Output buffers for subscribers. The default is client-output-buffer-limit pubsub 32mb 8mb 60. A subscriber that falls 32 MB behind, or stays above 8 MB for 60 seconds, is disconnected, and its unread events are gone.
bash
valkey-cli INFO stats | grep -E \
  'pubsub_patterns|client_output_buffer_limit_disconnections|expire_cycle_cpu'
valkey-cli INFO clients | grep pubsub_clients

Interpretation: pubsub_patterns counts the distinct patterns behind the M in the complexity above. A rising client_output_buffer_limit_disconnections means some push client, often an event subscriber, is being cut off and losing messages. expire_cycle_cpu_milliseconds shows the CPU the background expiry cycle uses.

Valkey 9.2 release candidate 1 lists "speed up keyspace event notifications by skipping work when there are no subscribers" (#4285). That is release candidate behaviour, not 9.1 GA.

The n flag does not survive CONFIG REWRITE on 9.1.3

In 9.1.3, CONFIG SET notify-keyspace-events KEAn reads back as AKE, so CONFIG REWRITE saves AKE and new-key events stop after a restart. The fix (PR #5199) was merged to the unstable branch on 5 November 2026 and is not in a release yet. Put the flags in valkey.conf by hand if you depend on n.

Why do expired events arrive after the TTL?

Valkey removes expired keys in two ways: when a command touches a key and finds it expired (lazy expiry), and in a background cycle that samples keys with a TTL (active expiry). The notifications page says the expired event is generated when one of those deletes the key, so the server cannot publish it at the moment the TTL reaches zero. A key nobody reads waits for the background cycle.

The cycle is sized to be cheap, not prompt. In the 9.1.3 source, the slow cycle runs on the server's hz timer (default 10) and may use up to 25% of CPU time, and valkey.conf says the default active-expire-effort 1 tries to avoid having more than ten percent of expired keys still in memory. Raising the effort (maximum 10) shortens the delay at the cost of CPU and latency.

bash
valkey-cli INFO stats | grep -E \
  '^expired_(keys|fields|stale_perc|time_cap_reached_count)'
valkey-cli CONFIG GET active-expire-effort

Interpretation: expired_stale_perc estimates the share of keys with a TTL that have expired but not yet been deleted, which is roughly the backlog of expired events not yet published. A growing expired_time_cap_reached_count means the cycle keeps hitting its time limit before it finishes.

Three more cases change what a subscriber sees:

  • Paused writes. The CLIENT PAUSE page says keys are not evicted or expired while clients are paused. A failover or switchover that pauses writes holds back expired events until it ends.
  • Replicas. Replicas do not delete expired keys on their own; they wait for the primary's DEL or UNLINK in the replication stream. A subscriber on a replica sees del, not expired, and only once the primary has deleted the key.
  • Eviction. A key removed by the maxmemory policy before its TTL produces evicted (flag e), not expired. A cleanup job that listens only to x never hears about it.

Enabling expiry and hash events, then watching keyevent channels

Illustrative output: the command shapes follow the Valkey notifications page; key names are examples

ops@bastion:~
$ valkey-cli -h 10.0.1.12 CONFIG SET notify-keyspace-events ExhOK$ valkey-cli -h 10.0.1.12 CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "hxE"$ valkey-cli -h 10.0.1.12 --csv PSUBSCRIBE '__keyevent@*__:*'"psubscribe","__keyevent@*__:*",1"pmessage","__keyevent@*__:*","__keyevent@0__:hset","user:42""pmessage","__keyevent@*__:*","__keyevent@0__:hexpire","user:42""pmessage","__keyevent@*__:*","__keyevent@0__:expired","sess:42""pmessage","__keyevent@*__:*","__keyevent@0__:hexpired","user:42"

Read the payload: keyevent messages carry the key name only. The hexpired line says some fields of user:42 were reaped, not which ones. The pattern and output format come from the notifications page.

How do hexpired events work for hash fields on Valkey 9.0 and later?

Valkey 9.0 added per-field TTLs, and the notifications page lists three hash-field events. HEXPIRE and its variants, HSETEX, and HGETEX publish hexpire when they set a future expiry. HPERSIST and HGETEX publish hpersist when they remove at least one field TTL. hexpired fires when fields are deleted because they expired. If the hash ends up empty, a del follows.

The flag that carries hexpired depends on how the field was deleted. The page does not say; the 9.1.3 source does:

How the field wentEventFlag class
Background expiry cycle reaps fields whose TTL passedhexpiredx (expired)
A command sets an expiry in the past, for example HEXPIRE ... 0hexpiredh (hash)
A command sets a future expiryhexpireh
A command removes a field TTLhpersisth
bash
# Hear field expiry from the background cycle without every HSET
valkey-cli CONFIG SET notify-keyspace-events Ex
valkey-cli PSUBSCRIBE '__keyevent@*__:hexpired'

Interpretation: Ex delivers both expired and background hexpired events. Add h only if you also need the immediate case, because h also publishes every hset, hdel, and hincrby.

Two details shape consumers. First, the message is the key name, never the field list, so a consumer that needs the fields must keep its own record of them. Second, the background cycle reaps fields in batches of up to 1,024 per key and publishes one hexpired per batch, so a single event can stand for many fields. Field TTLs have no lazy deletion on read: a read hides an expired field, but only the background cycle or a command with a past expiry removes it. hexpired therefore lags the TTL in the same way expired does.

Our hash field expiration guide covers the commands, the memory cost, and why 9.0.7 or 9.1.3 is the patch floor for field TTLs.

How do you receive keyspace events from every node in a Valkey cluster?

Ordinary Pub/Sub messages in a cluster reach every node. Keyspace events do not. The notifications page says each node generates events for its own subset of the keyspace and that they "are not broadcasted to all nodes". In the 9.1.3 source, the server publishes them only to local, non-sharded channels. To hear every expiry, hold one subscription on every primary.

Keyspace events in a three-primary cluster

Each primary publishes events for its own slots only, so the consumer needs a subscription on all three

Per-node keyspace event subscriptions Three primaries, A, B, and C, each own a third of the hash slots. Step 1: a key on primary B expires and the expiry cycle deletes it, so B publishes an expired event on its own keyevent channel. Step 2: primary A publishes events only for its own keys. Step 3: primary C does the same. The event consumer at the bottom holds a separate subscription on each primary. Events never cross the cluster bus, so a consumer connected to one node hears only that node's keys. Primary A slots 0-5460 Primary B slots 5461-10922 Primary C slots 10923-16383 Event consumer one link per primary 1 expired, from B only 2 A's keys only 3 C's keys only Keyspace events never cross the cluster bus

Missed shard: a consumer with two of three subscriptions silently drops a third of all expiries. Alert on the number of live subscriptions, not only on errors.

Build the node list from CLUSTER SHARDS, which returns each shard's slots and nodes with their role and health:

bash
valkey-cli -h 10.0.1.11 CLUSTER SHARDS
# For each node with role "master" and health "online":
valkey-cli -h <primary-endpoint> -p <port> PSUBSCRIBE '__keyevent@*__:expired'

Interpretation: the CLUSTER SHARDS page describes the endpoint, role, and health fields. Rebuild the list after every failover and every resharding, because the node that owns a key can change.

Set the config on replicas too. A replica publishes no expired events, but once a failover promotes it, it starts deleting expired keys and needs notify-keyspace-events already set.

bash
valkey-cli --cluster call 10.0.1.11:6379 CONFIG SET notify-keyspace-events Ex
valkey-cli --cluster call 10.0.1.11:6379 CONFIG REWRITE

Interpretation: --cluster call runs the command on every node, primaries and replicas, and prints one line per node, such as 10.0.1.12:6379: OK. Any Failed! line is a node that will publish nothing.

Use @* in the pattern. Valkey 9.0 added numbered databases in cluster mode (cluster-databases, default 1; see the numbered databases post), and the database number is part of the channel name. A subscriber on __keyevent@0__:expired misses expiries in database 3.

Check your client library: since regular channels are forwarded across the cluster, a cluster client may send PSUBSCRIBE to just one node.

Does sharded Pub/Sub (SSUBSCRIBE) help with keyspace events?

No. Sharded Pub/Sub, available since Redis OSS 7.0 and present in every Valkey release, maps a shard channel to a hash slot. SPUBLISH sends the message only to the nodes of the shard that owns that slot, and subscribers use SSUBSCRIBE on the primary or a replica of that shard (Pub/Sub page). Keyspace events are not published on shard channels, so an SSUBSCRIBE __keyspace@0__:mykey receives nothing.

The two share a scope: keyspace events already stay on the node that produced them, but on regular channels. Sharded Pub/Sub is the right tool for your own application events, because it keeps them off the cluster bus:

bash
# Application events, kept inside one shard
SSUBSCRIBE orders:{eu}:events
SPUBLISH orders:{eu}:events "order 1001 paid"

Interpretation: the {eu} hash tag pins the channel to one slot. SSUBSCRIBE can return a -MOVED redirect if you connect to the wrong shard, and all channels in one SSUBSCRIBE call must hash to the same slot. Regular PUBLISH, by contrast, is forwarded to every node in the cluster.

What changed for cluster PUBLISH in Valkey 9.0.7 and 9.1.3?

The October 2026 patches fix GHSA-f4q9-j22m-xqq9, rated high. When a node forwarded a PUBLISH or SPUBLISH to the cluster, it computed the message length as a 64-bit size and passed it through a 32-bit field. A channel plus message larger than UINT32_MAX (about 4 GiB) caused a heap buffer overflow. The advisory says an authenticated user who can publish could crash the server and possibly execute code. Only cluster mode is affected.

The fix (PR #5030) checks the full serialized length first. An oversized publish now returns an error and is neither published locally nor forwarded:

bash
(error) ERR Publish message is too large for the cluster bus

Interpretation: the source passes the text Publish message is too large for the cluster bus to the standard error reply, which normally adds the ERR prefix shown here. The threshold is the combined size of channel and message, just under 4 GiB. With the default proto-max-bulk-len of 512 MB, a client cannot send arguments that large directly, but the advisory notes that a script can build them on the server.

Patched versions are 9.1.3, 9.0.7, 8.1.11, 8.0.12, 7.2.15, and 9.2.0-rc2. Keyspace notifications themselves are not affected: their channels and payloads are key names, and they are never forwarded over the cluster bus. If you cannot patch yet, the advisory suggests restricting scripting commands with ACLs where the application does not use them, and leaving proto-max-bulk-len at its default.

What delivery behaviour should a consumer expect?

The Pub/Sub page describes at-most-once delivery: a message is sent once if at all, and if the subscriber cannot handle it, it is lost. The notifications page calls this fire and forget: events published while a client is disconnected are not delivered when it reconnects. A consumer loses events when it restarts, when the network drops, when a failover or resharding moves keys before it resubscribes, and when it hits the pubsub output buffer limit.

A client holding a channel and a matching pattern, or two overlapping patterns, receives a message twice. Make handlers idempotent.

The design that survives this is notifications as a hint plus a periodic reconciliation. For expiry-driven work, keep a deadline index next to the TTL, and let the worker sweep it on a timer. Events only make the sweep run sooner.

bash
# When you set the TTL, record the deadline (epoch seconds)
SET sess:{s7}:42 "..." EX 1800
ZADD deadlines:{s7} 1794301200 sess:{s7}:42

# Worker: everything due by now, oldest first
ZRANGE deadlines:{s7} -inf 1794301200 BYSCORE LIMIT 0 100

Interpretation: the sorted set is the record of work to do, so a missed expired event delays the work until the next sweep instead of losing it. Remove each member with ZREM after its work succeeds. The {s7} hash tag keeps the key and its index in one slot; spread the load across several tags on a cluster.

Setting up keyspace event consumers you can trust

Seven steps for a cluster; steps 6 and 7 are what make missed events recoverable

  1. Enable the smallest flag set on every node

    Include replicas, so a promoted replica keeps publishing. Ex covers expired keys and background field expiry.

    valkey-cli --cluster call 10.0.1.11:6379 \ CONFIG SET notify-keyspace-events Ex
  2. Persist the setting

    Without this, a restarted node publishes nothing. Check the file afterwards if you use the n flag.

    valkey-cli --cluster call 10.0.1.11:6379 \ CONFIG REWRITE
  3. Discover the primaries

    Read the nodes with role master and health online from each shard.

    valkey-cli -h 10.0.1.11 CLUSTER SHARDS
  4. Subscribe on each primary

    Use one connection per primary and @* so every numbered database is covered.

    valkey-cli -h 10.0.1.12 \ PSUBSCRIBE '__keyevent@*__:expired'
  5. Watch for buffer disconnects

    A slow consumer is cut off at the pubsub output buffer limit. Alert when this counter rises.

    valkey-cli INFO stats | grep \ client_output_buffer_limit_disconnections
  6. Resubscribe on topology change

    After a failover, a resharding, or a dropped connection, rebuild the node list and subscribe again.

    valkey-cli -h 10.0.1.11 CLUSTER SHARDS
  7. Reconcile every gap

    Sweep the deadline index on a timer and after every resubscribe. Events only shorten the wait.

    ZRANGE deadlines:{s7} -inf 1794301200 \ BYSCORE LIMIT 0 100

Main risk: every event published between a disconnect and the next subscribe is lost. Step 7 is what turns that loss into a delay.

When should you use streams instead of keyspace notifications?

If the event must not be lost, write it to a stream when the change happens instead of inferring it later from a notification. The streams introduction covers consumer groups, the pending entries list, and XAUTOCLAIM for taking over idle messages, which together give at-least-once processing. Stream entries are stored in the keyspace, so they are replicated and persisted like other data.

bash
# Producer: change and event in one transaction, same hash tag
MULTI
HSET {orders:7}:order:1001 status paid
XADD {orders:7}:changes MAXLEN ~ 1000000 * id 1001 op paid
EXEC

# Consumer group: read, process, acknowledge
XGROUP CREATE {orders:7}:changes billing $ MKSTREAM
XREADGROUP GROUP billing worker-1 COUNT 100 BLOCK 5000 STREAMS {orders:7}:changes >
XACK {orders:7}:changes billing 1794301200000-0

# Take over messages another worker left pending for 60 s
XAUTOCLAIM {orders:7}:changes billing worker-2 60000 0-0 COUNT 100

Interpretation: in cluster mode every key in a MULTI block must hash to the same slot, hence the shared {orders:7} tag; spread load across several tags and streams. MAXLEN ~ trims old entries, and an entry trimmed before a consumer reads it is gone, so size the cap above your worst consumer lag.

Change capture: keyspace notifications or a stream

Switch panels to compare what each gives a consumer on Valkey 9.1

Keyspace notifications

PropertyKeyspace notifications
DeliveryAt-most-once Pub/Sub
Consumer offlineEvents published meanwhile are lost
Expiry timingWhen the server deletes the key, after the TTL
Cluster modeOne subscription per primary
PayloadKey name or event name only
ReplayNone
Application changesNone: set a config flag

Stream written at the source

PropertyStream written at the source
DeliveryAt-least-once with consumer groups and XACK
Consumer offlineEntries wait until read or trimmed
Expiry timingNot covered: pair with a deadline index
Cluster modeOne stream per hash tag, read from its shard
PayloadAny fields you write
ReplayFrom any ID still in the stream
Application changesEvery writer must add the XADD

Rule of thumb: notifications suit hints, debugging, and best-effort cache work. Anything that bills, audits, or must run exactly when a TTL passes needs a stream or a deadline index.

Two other options fit specific jobs. To drop entries from an in-process cache, client-side caching with CLIENT TRACKING sends invalidations only for keys the client has read. If the source of truth is another database, capture changes there, as in our post on cache invalidation with Debezium CDC.

Frequently asked questions

Why is my Valkey expired event delayed?

Valkey publishes the expired event when it deletes the key, not when the TTL reaches zero. Deletion happens when a command touches the key or when the background expiry cycle samples it. Keys nobody reads wait for that cycle, which is tuned to use little CPU. Paused writes, for example during a failover, also hold expiry back.

Which notify-keyspace-events flag do I need for hexpired?

For fields reaped by the background expiry cycle, the hexpired event belongs to the x class, so Ex is enough. When a command sets an expiry in the past, for example HEXPIRE with 0, hexpired is published in the h class. Add h only if you need that case, because it also publishes every hash write.

Do keyspace notifications work in Valkey cluster mode?

Yes, but each node publishes events only for the keys it holds, and the events are not forwarded across the cluster. A consumer must subscribe on every primary, rebuild that list after failovers and resharding, and use a pattern with @* if the cluster uses numbered databases, which Valkey 9.0 added.

Can I receive keyspace events with SSUBSCRIBE?

No. Valkey publishes keyspace and keyevent messages on regular local channels, not on shard channels, so SSUBSCRIBE receives none of them. Use PSUBSCRIBE or SUBSCRIBE on each primary. Sharded Pub/Sub with SPUBLISH is still the better choice for your own application events in a cluster.

Are Valkey keyspace notifications reliable enough for billing or cleanup?

Not on their own. Delivery is at-most-once, so events published while a consumer is disconnected, slow, or resubscribing are lost. Use them as a hint that triggers work sooner, and keep the record of work in a stream with consumer groups or in a sorted set of deadlines that a worker sweeps on a timer.

Key takeaways

  • Enable the smallest flag set, such as Ex, on every node including replicas, and persist it with CONFIG REWRITE or valkey.conf.
  • expired and background hexpired events fire on deletion, after the TTL, and carry only the key name.
  • In a cluster, subscribe on every primary with an @* pattern and rebuild the subscriptions after each topology change; SSUBSCRIBE does not receive keyspace events.
  • Treat notifications as at-most-once hints. Back them with a stream or a deadline index that a worker sweeps.
  • Patch clusters to 9.0.7 or 9.1.3 or later for GHSA-f4q9-j22m-xqq9; oversized cluster publishes now return an error.

Review your Valkey event design with JusDB

JusDB's Valkey consulting team can review how your services consume keyspace notifications, check the flag set and subscriber coverage on your cluster, and help plan a move to streams or a deadline index where an event must not be lost.

Bring your Valkey versions, topology, current notify-keyspace-events setting, and a list of the jobs that depend on events. Your team approves every production change.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Moving a Multi-Database Valkey Instance into a 9.x Cluster

Move a Valkey instance that uses SELECT 1 to 15 into a 9.x cluster: keep or remap databases, copy each one, verify counts per database, and keep a way back.

Redis18 minNov 10, 2026
Read

Valkey GLIDE in Production: AZ-Affinity Reads and Migrating from Legacy Clients

Configure Valkey GLIDE AZ-affinity reads on servers and clients, learn what GLIDE 2.5.3 does not support, and migrate from redis-py, Jedis, ioredis, or go-redis one service at a time.

Redis20 minNov 10, 2026
Read

What the Valkey Docs Get Wrong: 12 Gaps Operators Should Know in Valkey 9.1

Twelve places where valkey.io disagrees with Valkey 9.1.3, from failover timing and TLS expiry to default bind, MONITOR and CLIENT LIST, with how to check each one and report it.

Redis20 minNov 9, 2026
Read