You turned on CLIENT TRACKING so your application could serve hot Valkey keys from its own memory. Now a few things don't add up. The hit rate drops whenever tracking_total_keys reaches 1,000,000, a service reads stale values after a reconnect, and on a RESP2 connection invalidations never arrive at all. Before Valkey 9.1.3, a cached XPENDING result could also outlive the XACK that changed it. The server keeps state for every cache, and the client library decides most of what happens. This guide covers the tracking modes, the tracking table and its limit, the October 2026 fixes, client support, measurement, and when to leave it off.
- Default mode records every key a tracking connection reads in one table, capped by
tracking-table-max-keys(default 1,000,000). When it is full, the server sends invalidations for random keys that never changed. OPTINkeeps only the keys you choose to cache.BCASTstores nothing per key, but it sends every change under the prefixes you subscribe to.- On RESP2, invalidations reach you only through a
REDIRECTconnection subscribed to__redis__:invalidate. Tracking on a plain RESP2 connection repliesOKand then delivers nothing. - Run 9.1.3, 9.0.7, 8.1.11 or later: before those patches, stream consumer-group commands did not send invalidations. The fixes for the dead-client leak and for the stall after you lower the limit exist only in the 9.2 release candidates.
- Check your client: valkey-go defaults to
OPTIN, valkey-py to default mode, GLIDE 2.5 toBCASTwith no prefix. redis-py 8.1 refuses to cache against Valkey.
Scope and review date: November 2026. This post covers self-managed Valkey 9.1.3 (the current GA patch), with notes on 9.0.x, 8.1.x and the 9.2.0-rc2 release candidate. Commands, defaults and behaviour were checked against the Valkey documentation, release notes and the 9.1.3 source. Client behaviour was checked against each library's source or documentation at the version named. None of it was run in a lab, and every terminal transcript is illustrative, so rehearse on staging.
How does server-assisted client-side caching work in Valkey?
The Valkey client-side caching guide calls the feature tracking. A connection turns it on with CLIENT TRACKING on. From then on, the server notes which keys that connection reads with read-only commands. When one of them is modified, expires or is evicted, the server sends an invalidation, and the client must drop its copy.
The server only sends invalidations. The client library keeps the cache and handles reconnects, so the server can tell you a key changed but not whether the client acted on it.
CLIENT TRACKING <ON | OFF> [REDIRECT client-id] [PREFIX prefix [PREFIX prefix ...]]
[BCAST] [OPTIN] [OPTOUT] [NOLOOP]Interpretation: this is the syntax from the CLIENT TRACKING reference, available since 6.0.0. It lasts until the connection closes or CLIENT TRACKING off, so every pooled connection, and every reconnect, must turn it on again.
The read, cache and invalidate cycle
Default tracking mode: the server remembers who read user:42 and tells them when it changes
Where state lives: the value sits in application A, and the record of who read it sits in Valkey. If application A misses step 3, for example during a reconnect, nothing else corrects its copy except a local TTL.
Default mode, OPTIN or broadcasting: which should you use?
Valkey has two tracking modes. Default mode records, per key, which connections read it. Broadcasting (BCAST) keeps only a prefix table and notifies every subscriber when a matching key changes. The guide puts the trade-off plainly: broadcasting "does not consume any memory on the server side, but instead sends more invalidation messages to clients."
With OPTIN, nothing is tracked unless CLIENT CACHING yes immediately precedes the read; OPTOUT is the reverse. NOLOOP, valid in both modes, skips invalidations for keys the same connection wrote, which matters for clients that cache their own writes.
# OPTIN: track only what you intend to cache
CLIENT TRACKING on OPTIN NOLOOP
CLIENT CACHING yes
GET user:42
# BCAST: no per-key state; one subscription per namespace
CLIENT TRACKING on BCAST PREFIX user: PREFIX product:
# Overlapping prefixes are rejected
CLIENT TRACKING on BCAST PREFIX user: PREFIX user:4
(error) ERR Prefix 'user:' overlaps with another provided prefix 'user:4'. Prefixes for a single client must not overlap.Interpretation: CLIENT CACHING yes applies only to the next command. If that command is MULTI, it covers the whole transaction, and in a script it covers every command the script runs. The error text is from checkPrefixCollisionsOrReply() in the 9.1.3 tracking.c. The same source rejects OPTIN or OPTOUT with BCAST, and switching modes without turning tracking off first.
Three ways to run CLIENT TRACKING
Switch modes to see what the server stores and who receives invalidations
Default
| Property | Default mode |
|---|---|
| Enable with | CLIENT TRACKING on |
| Server stores | Every key read on the connection, with its client ID |
| Invalidations go to | Connections that read the key |
| Server cost grows with | Distinct keys read, times connections reading them |
OPTIN
| Property | OPTIN |
|---|---|
| Enable with | CLIENT TRACKING on OPTIN |
| Server stores | Only keys read right after CLIENT CACHING yes |
| Invalidations go to | Connections that opted in to the key |
| Server cost grows with | Keys actually cached, times connections caching them |
BCAST
| Property | Broadcasting |
|---|---|
| Enable with | CLIENT TRACKING on BCAST PREFIX user: |
| Server stores | Nothing per key; one prefix table entry per prefix |
| Invalidations go to | Every subscriber of a matching prefix, on every change |
| Server cost grows with | Number of prefixes (CPU) and the write rate under them |
Rule of thumb: default mode costs server memory, and BCAST costs invalidation traffic. BCAST with no prefix subscribes the connection to every write on the server.
RESP3 push messages or a RESP2 redirect connection?
With RESP3, invalidations arrive as push messages on the same connection that reads the data. RESP2 cannot mix push data with replies, so the only RESP2 option is the two-connection model. One connection subscribes to __redis__:invalidate, and the data connections send their invalidations to it with REDIRECT <id>. Many data connections can share one invalidation connection, which suits connection pools.
# Connection 1 (invalidations), RESP2
CLIENT ID
:4
SUBSCRIBE __redis__:invalidate
# Connection 2 (data)
CLIENT TRACKING on REDIRECT 4
+OK
GET foo
# Connection 1, after another client runs SET foo bar
*3
$7
message
$20
__redis__:invalidate
*1
$3
fooInterpretation: adapted from the Valkey guide. The third element is an array, so one message can carry several keys; a FLUSHALL or FLUSHDB sends a null, meaning "drop everything". Only the connection named in REDIRECT receives it, and that ID must exist on the same server, so a cluster needs one invalidation connection per node. In RESP3, valkey-cli -3 prints the same event as -> invalidate: 'foo'.
On a RESP2 connection with no redirect, CLIENT TRACKING on still replies OK. The server records the keys you read and counts them in the table, but sendTrackingMessage() in tracking.c returns without sending anything. Your cache never hears about a change. Use RESP3 (HELLO 3) or a redirect connection, and check CLIENT TRACKINGINFO afterwards: a redirect of 0 on a RESP2 connection is this case.
The two-connection model has two more costs. A GET reply can arrive after the invalidation for that key, so the guide suggests a "caching-in-progress" placeholder: if an invalidation deletes it before the reply lands, the client discards the reply. And the invalidation connection is a Pub/Sub client, so the default client-output-buffer-limit pubsub 32mb 8mb 60 applies. If it reads slowly during an invalidation burst, the server can disconnect it, and only RESP3 data connections get the tracking-redir-broken push that reports the broken redirect.
How much memory does the tracking table use?
The 9.1.3 valkey.conf describes the tracking table as "a radix key indexed by key name" that records which clients have which keys. In the source, each key holds an inner radix tree of 8-byte client IDs. Two details affect sizing:
- There is one table per server, not per database: a write to
fooin database 3 invalidates a read offooin database 2, andFLUSHDBon any database flushes every tracking client. - A disconnected client's IDs stay until the key is next modified, deleted, evicted or flushed.
INFO has no field for the table's memory use. You size it from three counters, described in the INFO reference: tracking_clients in the clients section, and tracking_total_keys and tracking_total_items in stats. Items are the sum, over all tracked keys, of the clients tracking each one. So the table grows with distinct keys read times connections reading them, which is why connection pools are expensive in default mode.
valkey-cli INFO clients | grep '^tracking_clients'
valkey-cli INFO stats | grep -E '^tracking_total_(keys|items|prefixes)'
valkey-cli CONFIG GET tracking-table-max-keys
valkey-cli CLIENT LIST FLAGS R # 9.0+: broken redirects, blind cachesInterpretation: divide tracking_total_items by tracking_total_keys to get the average number of connections per tracked key. If that ratio is far above the number of live tracking connections, or items stay high while tracking_clients is near zero, the table is holding the IDs of disconnected clients. Any CLIENT LIST FLAGS R line is a connection whose redirect target is gone (CLIENT LIST reference); its application should flush and re-enable tracking.
Reading tracking-table pressure
Illustrative output: a default-mode table sitting just under its cap
What this shape means: the table is at 99.9% of its cap, and each key is tracked by about six connections on average. The next new reads will force random invalidations of keys that have not changed. The field names come from the INFO reference; the numbers are invented.
What happens when tracking-table-max-keys fills up?
The 9.1.3 valkey.conf is direct: the default is "1M of keys", and once it is reached "the server will start to evict keys in the invalidation table even if they were not modified", which forces clients to drop those values. 0 removes the limit, BCAST ignores it, and CONFIG SET changes it at runtime.
trackingLimitUsedSlots() in tracking.c runs before every command and from serverCron, but only while at least one client has tracking on. Each call over the limit invalidates up to 100 random keys, and the budget grows by 100 on each consecutive call still over it. Each random eviction becomes an invalidation to every live client tracking that key, so a table at its cap keeps emptying the caches it serves. That is the invalidation storm: hit rate falls while invalidation traffic and server CPU rise.
# valkey.conf: size for the hot set, not the whole keyspace
tracking-table-max-keys 2000000Interpretation: a higher limit trades server memory for fewer random invalidations; watch used_memory as the table grows. AWS documents the same parameter for ElastiCache: modifiable, default 1,000,000, applied immediately, and "after this parameter value is exceeded, clients are sent invalidation randomly" (ElastiCache engine parameters). Memorystore for Valkey supports CLIENT TRACKING but blocks CONFIG SET (supported and blocked commands), so change the limit through the provider's settings, if it offers one.
In 9.1.3, each eviction pass is bounded by a key count, not by time, and each key costs one step per client ID. PR #4775, released in 9.2.0-rc2, adds a 500 µs limit per pass. In the PR author's local test, with 500k keys tracked by 50 disconnected clients and the limit lowered to 50k, the worst PING took 96.2 ms before the change and 4.4 ms after it. On 9.1.x, lower the limit in small steps during a quiet period and watch latency between steps.
The disconnected-client problem has its own fix in the same release candidate. PR #4190 (issue #4143) describes the leak: clients track keys that are rarely written, then disconnect, and their IDs stay in the table indefinitely. The PR adds a time-limited sweeper for dead IDs and includes the table in active defrag. As of November 2026, neither fix is in 9.1.3; both are 9.2 release-candidate behaviour, not GA.
Which writes trigger invalidation, and what did 9.1.3 fix for streams?
Tracking works on key names, so it covers every data type. In default mode, the keys of any read-only command go into the table: GET, HGETALL, LRANGE, ZRANGE, XRANGE, XINFO, XPENDING and the rest. For EVAL_RO and FCALL_RO, the keys come from the commands the script runs. A key is invalidated when any write calls signalModifiedKey(), when it expires, or when maxmemory evicts it. A flush invalidates everything.
The weak spot was commands that change stream metadata rather than stream entries. PR #3459 found that XGROUP, XSETID, XREADGROUP, XACK, XCLAIM and XAUTOCLAIM changed consumer-group or pending-entries state, and replicated it, without marking the key modified. Cached XPENDING or XINFO GROUPS replies outlived the state they described, and WATCH missed the same writes.
# Reader A (tracking on, RESP3) caches the pending summary
XPENDING orders billing
1) (integer) 3
...
# Worker B acknowledges an entry
XACK orders billing 1731234567890-0
(integer) 1
# Before 9.1.3: no invalidation for "orders"; A keeps reporting 3 pending
# 9.1.3 and later: A receives an invalidation for "orders"Interpretation: this is an illustrative sequence. The fix shipped on every maintained branch in the October 2026 patches: 9.1.3 (26 October), 9.0.7, 8.1.11, 8.0.12 and 7.2.15. If you cache stream metadata or use WATCH around consumer groups, that patch is the minimum version.
Which Valkey clients implement client-side caching?
Support differs more between clients than between server versions. The Valkey clients page lists client-side caching for valkey-py, valkey-go and Redisson. GLIDE added it in 2.4 and 2.5, though the page did not show it when checked. The table comes from each library's source at the version named.
| Client (version checked) | Tracking mode | Protocol | Notes |
|---|---|---|---|
| valkey-go 1.0.79 | OPTIN by default; BCAST through ClientTrackingOptions | RESP3 | DoCache() with a client TTL; cache hit and miss metrics through valkeyotel |
| valkey-py 6.2.0 | Default (CLIENT TRACKING ON) | RESP3 only | cache_enabled=True; an allow list of read commands; flushes the cache on disconnect; one cache per pooled connection unless you pass a shared client_cache; the server tracks every key read, cached or not |
| Valkey GLIDE 2.5.x | BCAST, no prefix | RESP3 only | server_assisted=True on ClientSideCache; tracking on every node connection; prefix support requested in #7503 |
| Lettuce 7.8+ | Set by TrackingArgs | RESP3 by default | ClientSideCaching exists since 6.0, deprecated in 7.8 as "legacy and incomplete" |
| redis-py 8.1.0 | Default | RESP3 | Refuses Valkey: the cache requires the HELLO server name redis and version 7.4 or later |
Two rows need action. GLIDE's server-assisted mode subscribes every connection to every write on the server, and it has an open bug, #7446: after a reconnect, the Java client serves values from before the drop until entryTtlMs expires. Fix PR #7493 was still open in November 2026, so keep that TTL short. In redis-py, CacheProxyConnection raises ConnectionError: To maximize compatibility with all Redis products, client-side caching is supported by Redis 7.4 or later against Valkey. Valkey reports server: valkey, and even extended-redis-compatibility yes reports version 7.2.4. Use valkey-py instead.
// valkey-go: OPTIN tracking is on by default; DoCache opts each read in
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{"valkey.internal:6379"},
})
if err != nil {
panic(err)
}
v, err := client.DoCache(ctx,
client.B().Get().Key("user:42").Cache(), time.Minute).ToString()Interpretation: the minute is a client-side TTL that bounds staleness if an invalidation is lost. Only DoCache() reads enter the server's table, so tracking_total_keys stays close to what you actually cache.
How do you measure hit rate and invalidation volume?
The server never sees a cache hit, so measure the hit rate in the client. valkey-go exports valkey_do_cache_hits and valkey_do_cache_miss when you build the client with valkeyotel.NewClient. GLIDE collects hit and miss metrics when you create the cache with enable_metrics=True. Hit rate is hits divided by hits plus misses, per service.
The server side shows the effects. As of 9.1.3, INFO has no counter for invalidation messages sent, so use these signals instead:
# Reads should fall on the server after rollout
valkey-cli INFO commandstats | grep -E '^cmdstat_(get|hget|hgetall|mget):'
# RESP2 redirect connection: invalidation bytes and buffered output
valkey-cli CLIENT LIST ID 4
# Table fill as a percentage of the cap (alert well before 100)
valkey-cli INFO stats | grep '^tracking_total_keys'Interpretation: compare calls for your hot read commands before and after the change. That is the load the cache removed. On the redirect connection, tot-net-out (added in 8.0) grows with invalidation traffic, and a rising omem means the application is reading invalidations too slowly. On RESP3, count invalidation pushes in the client. Also sample a few cached keys on a schedule and compare them with a direct read: a high hit rate can hide a cache that stopped receiving invalidations.
How do you roll out client-side caching safely?
Treat this as a change to both the server and the application, with a check at each step.
Client-side caching rollout on Valkey 9.1
Six steps; turning tracking off in the client is the rollback at any point
-
Patch the server
Run 9.1.3, 9.0.7 or 8.1.11 or later so stream metadata changes invalidate. Note that the leak and stall fixes are 9.2 RC only.
valkey-cli INFO server | grep valkey_version -
Choose the mode per workload
Use OPTIN for a selective cache over a large keyspace, BCAST with prefixes for small read-mostly namespaces. Avoid BCAST with no prefix.
-
Size the tracking table
Set the limit above the number of distinct keys you expect to cache (keys, not items). Alert at about 80% of the cap.
valkey-cli CONFIG SET \ tracking-table-max-keys 2000000 -
Enable on one service over RESP3
Start with one service and a short client TTL, such as 60 seconds. Confirm the flags on its connections.
valkey-cli CLIENT LIST FLAGS t -
Test reconnect and failover
Kill the service's connections and fail over the primary. The cache must flush and tracking must come back on.
valkey-cli CLIENT KILL USER app-cache -
Measure, then widen
After a full business cycle of clean hit rate, table fill and staleness samples, add services one at a time and lengthen TTLs.
Main risk: step 5. A client that keeps its cache across a reconnect serves stale data until its TTL expires, and no server metric shows it.
In cluster mode, each node keeps its own tracking table, so a client turns tracking on for every node connection, and RESP2 needs a redirect connection per node. The guide does not cover topology changes, so flush a node's entries whenever its connection drops or its slots move.
When should you not use client-side caching?
The guide advises against caching "many keys that change continuously" or keys "requested very rarely", such as a constantly incremented counter. Leave it off when:
- Writes outnumber repeated reads. Each write to a key that N application instances hold sends N invalidations, and the next read misses anyway.
- The read set is huge and flat. Default mode fills the table, and OPTIN over a set this wide gains little.
- You need read-your-writes across services. Invalidations are asynchronous. Another service can read its local copy before its invalidation arrives.
- Your client lacks reconnect handling. Without a flush on disconnect, a network drop becomes stale data for a full TTL.
- A shared cluster serves many teams. One team's BCAST without a prefix, or its share of the table cap, becomes everyone's cost.
If hot keys are the real problem, compare this with read replicas and key splitting; our Valkey performance tuning guide covers the server side.
Frequently asked questions
What is tracking-table-max-keys in Valkey?
It caps the server-side table that records which connections read which keys in default tracking mode. The default is 1,000,000, and 0 removes the limit. When the table is full, Valkey removes random entries and sends invalidations for keys that did not change. Broadcasting mode does not use the table.
Does Valkey client-side caching work with RESP2?
Only with redirection. A RESP2 client opens a second connection, subscribes it to __redis__:invalidate, and enables tracking on data connections with REDIRECT and that connection's ID. Enabling tracking on a RESP2 connection without REDIRECT replies OK, but the server sends no invalidations to it. RESP3 delivers invalidations as push messages on the same connection.
What is the difference between default tracking and BCAST mode?
Default mode remembers each key a connection reads and invalidates only those keys, which costs server memory. BCAST remembers only prefixes and invalidates every modified key under them, which costs network traffic and client CPU instead. Use OPTIN for selective caching over large keyspaces and BCAST with prefixes for small, read-mostly namespaces.
Can I use redis-py client-side caching with Valkey?
Not as of redis-py 8.1.0. Its cache checks the HELLO reply and requires the server name "redis" at version 7.4 or later. Valkey reports "valkey", and its Redis compatibility mode reports 7.2.4, so the connection raises ConnectionError. valkey-py 6.2.0 supports client-side caching with protocol=3 and cache_enabled=True.
Which Valkey version fixed stream invalidation for client-side caching?
Valkey 9.1.3, 9.0.7, 8.1.11, 8.0.12 and 7.2.15, released on 26 and 27 October 2026. Before them, XGROUP, XSETID, XREADGROUP, XACK, XCLAIM and XAUTOCLAIM changed consumer-group state without triggering tracking invalidation or WATCH, so cached XPENDING or XINFO results could go stale.
Key takeaways
- Default mode costs server memory for every key read times every connection reading it; OPTIN limits that to keys you cache, and BCAST trades it for invalidation traffic.
- Keep
tracking_total_keyswell undertracking-table-max-keys; a full table sends random invalidations and lowers the hit rate it exists to raise. - Use RESP3, or a RESP2 redirect connection with the placeholder pattern; tracking on plain RESP2 replies OK and delivers nothing.
- Run 9.1.3, 9.0.7 or 8.1.11 or later for stream invalidation; on 9.1.x, lower the table limit gradually and expect dead-client IDs to linger until 9.2.
- Pick the client deliberately, flush on every disconnect, keep a client TTL as a staleness bound, and measure staleness as well as hit rate.
Review your Valkey client-side caching with JusDB
JusDB's Valkey performance tuning team can review your hot keys and read patterns, help choose between client-side caching, replicas and key splitting, and plan the tracking mode, table size and staged rollout for your clients and topology.
Bring your Valkey version and topology, your client libraries and versions, and INFO and CLIENT LIST samples from a busy period. Your team approves every production change.