JusDB
Free DB Audit
NoSQL Databases

Client-Side Caching on Valkey in Production: CLIENT TRACKING and the Tracking Table

Run Valkey client-side caching in production: choose default, OPTIN or broadcasting mode, size tracking-table-max-keys, avoid invalidation storms, and pick a client that supports it.

JusDB Team
Published November 8, 2026
Updated November 10, 2026
19 min read

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.

TL;DR
  • 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.
  • OPTIN keeps only the keys you choose to cache. BCAST stores nothing per key, but it sends every change under the prefixes you subscribe to.
  • On RESP2, invalidations reach you only through a REDIRECT connection subscribed to __redis__:invalidate. Tracking on a plain RESP2 connection replies OK and 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 to BCAST with 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.

bash
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

Server-assisted client-side caching cycle Step 1: application A sends GET user:42 to Valkey, which records application A's client ID against the key in its tracking table and returns the value; application A stores it in its local cache. Step 2: application B writes the key with SET user:42. Step 3: Valkey sends an invalidation for user:42 to application A, which drops its local copy, so its next read goes back to the server. Application A local cache Valkey tracking table Application B writer 1 GET user:42 value, cached 2 SET user:42 3 invalidate user:42: drop the local copy

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.

bash
# 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

PropertyDefault mode
Enable withCLIENT TRACKING on
Server storesEvery key read on the connection, with its client ID
Invalidations go toConnections that read the key
Server cost grows withDistinct keys read, times connections reading them

OPTIN

PropertyOPTIN
Enable withCLIENT TRACKING on OPTIN
Server storesOnly keys read right after CLIENT CACHING yes
Invalidations go toConnections that opted in to the key
Server cost grows withKeys actually cached, times connections caching them

BCAST

PropertyBroadcasting
Enable withCLIENT TRACKING on BCAST PREFIX user:
Server storesNothing per key; one prefix table entry per prefix
Invalidations go toEvery subscriber of a matching prefix, on every change
Server cost grows withNumber 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.

bash
# 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
foo

Interpretation: 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'.

Tracking on RESP2 without REDIRECT does nothing useful

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 foo in database 3 invalidates a read of foo in database 2, and FLUSHDB on 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.

bash
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 caches

Interpretation: 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

ops@valkey-01:~
$ valkey-cli CONFIG GET tracking-table-max-keys1) "tracking-table-max-keys"2) "1000000"$ valkey-cli INFO clients | grep trackingtracking_clients:48$ valkey-cli INFO stats | grep trackingtracking_total_keys:998712tracking_total_items:6204390tracking_total_prefixes:0

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.

ini
# valkey.conf: size for the hot set, not the whole keyspace
tracking-table-max-keys 2000000

Interpretation: 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.

Lowering the limit on 9.1.x can stall the server

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.

bash
# 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 modeProtocolNotes
valkey-go 1.0.79OPTIN by default; BCAST through ClientTrackingOptionsRESP3DoCache() with a client TTL; cache hit and miss metrics through valkeyotel
valkey-py 6.2.0Default (CLIENT TRACKING ON)RESP3 onlycache_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.xBCAST, no prefixRESP3 onlyserver_assisted=True on ClientSideCache; tracking on every node connection; prefix support requested in #7503
Lettuce 7.8+Set by TrackingArgsRESP3 by defaultClientSideCaching exists since 6.0, deprecated in 7.8 as "legacy and incomplete"
redis-py 8.1.0DefaultRESP3Refuses 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.

go
// 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:

bash
# 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

  1. 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
  2. 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.

  3. 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
  4. 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
  5. 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
  6. 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_keys well under tracking-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.

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 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.

Redis20 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