Redis client-side caching is a powerful technique to reduce latency and database load, but its success hinges entirely on keeping that cache fresh. The default behavior for most Redis clients is to treat Redis as a simple key-value store, fetching data on every request. Client-side caching flips this: the client keeps a local copy of frequently accessed data and only queries Redis when it suspects the data might be stale. The magic that makes this work without constant manual cache-busting is the Redis client-side caching invalidation protocol, often referred to as CLIENT CACHING.

Let’s see it in action. Imagine we have a users service that frequently needs to fetch user profiles. Without client-side caching, every request for a user profile would hit Redis.

import redis

# Assume this is a simplified client setup
r = redis.Redis(host='localhost', port=6379, db=0)

def get_user_profile(user_id):
    # Without client-side caching, this always hits Redis
    profile_data = r.get(f"user:{user_id}:profile")
    if not profile_data:
        # Fetch from primary DB, then cache in Redis
        profile_data = fetch_from_primary_db(user_id)
        r.set(f"user:{user_id}:profile", profile_data, ex=3600) # Cache for 1 hour
    return profile_data

Now, with client-side caching enabled, the client can intelligently manage its local cache. The core idea is that the client tells Redis, "I’m about to read some data, and I’d like to be notified if it changes before a certain point."

To set this up, the client sends a CLIENT CACHING command before issuing the command that reads data. This command takes a single argument: a cache ID. This ID is a number that the client will use to identify the specific cached data it’s interested in.

Here’s how a client might use it:

import redis

r = redis.Redis(host='localhost', port=6379, db=0)

# Client maintains a local cache, e.g., a dictionary
local_user_cache = {}
next_cache_id = 1 # Client tracks its next available cache ID

def get_user_profile_with_csc(user_id):
    cache_key = f"user:{user_id}:profile"

    # 1. Check local cache first
    if cache_key in local_user_cache:
        # Assume local_user_cache stores (data, cache_id) tuples
        cached_data, cached_id = local_user_cache[cache_key]
        # Send the CLIENT CACHING command with the ID of the data we already have
        r.execute_command("CLIENT CACHING", cached_id)
        # If Redis returns the data, our local cache is valid.
        # If it returns nil, our local cache is stale.
        profile_data = r.get(cache_key)
        if profile_data:
            print(f"Cache hit for {cache_key} with ID {cached_id}")
            return profile_data
        else:
            print(f"Cache invalid for {cache_key} with ID {cached_id}. Fetching new data.")
            # Fall through to fetch new data
    else:
        print(f"Cache miss for {cache_key}. Fetching new data.")

    # 2. Fetch from primary DB and cache in Redis
    profile_data = fetch_from_primary_db(user_id)
    if profile_data:
        # Assign a new cache ID for this data
        current_cache_id = next_cache_id
        next_cache_id += 1
        # Store in local cache with the new ID
        local_user_cache[cache_key] = (profile_data, current_cache_id)
        # Set the data in Redis with an expiration and associate it with the cache ID
        # The NOSTORE option is crucial for some clients to avoid writing to the cache
        # if the client is not configured to store it.
        r.set(cache_key, profile_data, ex=3600)
        r.execute_command("CLIENT CACHING", current_cache_id) # Mark this data with the ID
        print(f"Cached new data for {cache_key} with ID {current_cache_id}")
    return profile_data

def update_user_profile(user_id, new_data):
    # Update primary DB
    update_primary_db(user_id, new_data)
    # Invalidate the cache in Redis. This is the critical part.
    # When a key is modified, we increment its associated cache ID.
    # This makes any previous CLIENT CACHING commands with the old ID for this key invalid.
    cache_key = f"user:{user_id}:profile"
    # Fetch the current cache ID for this key
    current_cache_id = r.get(f"csc:id:{cache_key}") # We'll store IDs separately
    if current_cache_id:
        new_cache_id = int(current_cache_id) + 1
        r.set(f"csc:id:{cache_key}", new_cache_id)
        # Also, remove the old data from Redis, or set a new expiration
        r.delete(cache_key)
    else:
        # If no ID was set, it means it was never cached with CSC.
        # We can still set an ID and potentially invalidate a client's assumption.
        new_cache_id = 1
        r.set(f"csc:id:{cache_key}", new_cache_id)
        r.delete(cache_key)

    # Clear from local cache as well
    if cache_key in local_user_cache:
        del local_user_cache[cache_key]

The CLIENT CACHING <cache_id> command tells Redis: "For any subsequent GET or MGET operations issued by this client connection, I am interested in the data associated with <cache_id>. If the data for the key I’m requesting has a different, more recent cache ID, or if it’s been invalidated, please return nil (or an empty response) instead of the actual data."

The invalidation protocol doesn’t directly tell Redis to invalidate specific keys. Instead, it relies on a clever use of monotonically increasing IDs. When data is written or updated, its associated cache ID is incremented. If a client previously issued CLIENT CACHING 5 for user:123:profile, but then the data for user:123:profile is updated and its new cache ID becomes 6, any subsequent GET user:123:profile command from that client will receive nil because the ID on the server (6) no longer matches the ID the client is expecting (5).

The client needs to manage two things: its local cache (the actual data) and the cache ID associated with that data. When a client requests data:

  1. It checks its local cache. If found, it retrieves the data and its associated cache_id.
  2. It sends CLIENT CACHING <cache_id> to Redis.
  3. It then issues the GET command.
  4. If Redis returns the data, the client’s local cache is valid.
  5. If Redis returns nil, the client’s local cache is stale. It then fetches the data from the primary source, updates its local cache with the new data, and importantly, fetches the new cache_id for that key from Redis and updates its local cache_id tracking for that key.

When data is modified:

  1. The primary data source is updated.
  2. The client (or a background process) needs to invalidate the cache. This is done by incrementing the cache ID associated with the key in Redis. A common pattern is to store the current cache ID for a key in a separate Redis key, like csc:id:<original_key>. When updating, you read this ID, increment it, and write it back. Then, you delete the old data from Redis.

The most surprising true thing about this protocol is that it’s fundamentally a versioning system, not a direct invalidation system. Redis doesn’t actively "push" invalidations to clients for specific keys. Instead, clients pull data and "opt-in" to receive notifications by associating a version (the cache ID) with their request. When data changes, its version number increments on the server, making all previous client requests with older version numbers effectively stale.

Here’s a sketch of how the server-side ID management might look:

# ... in update_user_profile function ...
cache_key = f"user:{user_id}:profile"
id_key = f"csc:id:{cache_key}"

# Atomically get the current ID, increment it, and set it back
# This ensures that concurrent updates don't lose an increment
new_cache_id = r.incr(id_key)

# Now, data read with this new_cache_id will be fresh.
# Any client still holding an older ID will get nil.
# We also need to remove the old data so the next GET doesn't return it if the client
# hasn't yet updated its ID mapping.
r.delete(cache_key)

The CLIENT CACHING command itself doesn’t store data in Redis; it merely associates a client connection with a specific cache version for subsequent reads. The actual data and its associated version (the csc:id:<key> in our example) are managed separately. The client must maintain its own mapping of keys to their current cache IDs. The NOSTORE option on SET commands is sometimes used to prevent Redis from writing data to its own persistent storage if the client isn’t configured to do so, but it doesn’t directly interact with the CLIENT CACHING protocol’s invalidation mechanism.

The next concept you’ll run into is managing the cache IDs efficiently, especially in highly concurrent write scenarios, and how to integrate this with different Redis client libraries.

Want structured learning?

Take the full Redis course →