How to Choose Between TTL and Explicit Invalidation

You are staring at a cache that occasionally serves stale data, and you have to decide whether to let keys expire on a timer or purge them the instant the source of truth changes. This page gives you a repeatable procedure for making that call per key family: measure the data's volatility and read/write ratio, quantify your tolerance for a stale read, then wire up TTL expiration, explicit invalidation, or a hybrid that uses a short TTL as a safety net behind explicit purges. Every step below is independently runnable against a live Redis 7.x instance so you can validate the decision with real numbers instead of guessing. For the underlying mechanics of how Redis stores and evicts keys, start from Redis caching architecture and invalidation fundamentals.

Prerequisites

  • Redis 6.2+ or 7.x, reachable via redis-cli and from your application host.
  • redis-py 5.x on Python 3.10+ (pip install "redis>=5,<6" tenacity). The examples use the redis.asyncio client.
  • Permission to read INFO, SLOWLOG, and CONFIG GET (no CONFIG SET required for measurement).
  • A representative key family to profile — e.g. session:*, product:*, or leaderboard:* — not the whole keyspace at once.
  • An understanding of your consistency SLA: the maximum wall-clock time a stale read is acceptable for this data.

Step-by-Step Decision Procedure

1. Classify the key family's write volatility

Sample how often the underlying record actually changes, because a key rewritten every few seconds is a different problem from one rewritten daily.

# Count writes to a key family over a 60s window using keyspace notifications.
redis-cli CONFIG SET notify-keyspace-events KEA
timeout 60 redis-cli --csv PSUBSCRIBE '__keyevent@0__:set' | grep -c 'product:'

2. Measure the read/write ratio

Pull hit/miss and command stats so you know whether the family is read-heavy (favours TTL amortization) or write-heavy (favours explicit purges).

redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|expired_keys|evicted_keys"

3. Check whether expiration is keeping up

Confirm the active-expiration cycle is not CPU-starved before you rely on TTLs; a stagnant expired_keys counter with a growing key count means expiry is lagging.

redis-cli CONFIG GET hz          # background task frequency, default 10
redis-cli SLOWLOG GET 10          # long commands can block the expire cycle

The hz directive governs how many times per second Redis runs background jobs, including active expiration. Raising it from 10 to 20 tightens the gap between a key's nominal expiry and its removal when thousands of keys expire per second, at a proportional CPU cost. If expiry lags because memory fills first, keys leave via LRU eviction under your maxmemory-policy rather than on schedule — a different failure with different symptoms.

4. Implement TTL expiration with jitter

If the family is read-heavy and tolerates a bounded staleness window, use TTLs — but jitter them so a batch written together does not expire in the same instant and stampede your database.

import random

def jittered_ttl(base_ttl: int, jitter_fraction: float = 0.1) -> int:
    """Spread expiry by ±jitter_fraction of base_ttl to avoid synchronized mass expiry."""
    jitter = int(base_ttl * jitter_fraction * (2 * random.random() - 1))
    return max(1, base_ttl + jitter)

5. Apply the jittered TTL on write

Set the value and its randomized expiry atomically so no key is ever written without a bound.

import redis.asyncio as redis

client = redis.Redis(host="localhost", port=6379, decode_responses=True)

async def cache_product(product_id: str, payload: str, base_ttl: int = 300) -> None:
    # SET with EX applies the expiry in the same round trip as the write.
    await client.set(f"product:{product_id}", payload, ex=jittered_ttl(base_ttl))

6. Implement explicit invalidation on mutation

If the family is write-heavy or the staleness SLA is near zero, purge the key the moment the source of truth changes. Prefer UNLINK over DEL so memory is reclaimed on a background thread instead of blocking the event loop.

from tenacity import (
    retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
)
import redis.exceptions

@retry(
    retry=retry_if_exception_type(
        (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError)
    ),
    wait=wait_exponential(multiplier=0.1, max=2),
    stop=stop_after_attempt(4),
    reraise=True,
)
async def invalidate_product(product_id: str) -> None:
    # UNLINK removes the key from the keyspace synchronously but frees
    # memory asynchronously — safe for large values on the hot path.
    await client.unlink(f"product:{product_id}")

7. Add a short-TTL safety net behind explicit purges

Because an explicit invalidation can be lost to a network partition or consumer lag, keep a conservative TTL on the same keys so a missed purge self-heals within a bounded window instead of serving stale data indefinitely. This hybrid is the default choice for most write-heavy families and pairs naturally with pub/sub invalidation for cross-service fan-out.

async def cache_product_hybrid(product_id: str, payload: str) -> None:
    # Explicit UNLINK is primary; the 120s TTL bounds the blast radius
    # of any invalidation message that never arrives.
    await client.set(f"product:{product_id}", payload, ex=jittered_ttl(120))

8. Gate the strategy in CI

Enforce the decision in the pipeline so a future change cannot silently ship an unbounded key or a TTL that violates your SLA.

- name: Validate cache strategy compliance
  run: python -m pytest tests/cache/test_invalidation_patterns.py -v
Per-key-family decision: TTL-only, hybrid, or explicit invalidation A signals panel lists write volatility, read/write ratio, and staleness SLA. An arrow leads to a decision hub, which fans into three labelled branches. Branch one, "read-heavy · tolerant", reaches the TTL-only card (jittered timer expiry). Branch two, "write-heavy · self-heal", reaches the hybrid card (explicit purge with a short TTL safety net, marked default). Branch three, "write-heavy · SLA to zero", reaches the explicit-invalidation card (UNLINK on every mutation). CHOOSE PER KEY FAMILY — MEASURE, THEN BRANCH MEASURED SIGNALS Write volatility Step 1 · SET rate Read / write ratio Step 2 · hits vs writes Staleness SLA stale-read tolerance DECIDE per family read-heavy · tolerant write-heavy · self-heal write-heavy · SLA→0 TTL-only Timer expiry with jitter (Steps 4–5) amortized freshness · no purge path Hybrid: explicit + short TTL DEFAULT UNLINK on write, TTL bounds a lost purge (Step 7) self-heals within the TTL window Explicit invalidation UNLINK the moment the source changes (Step 6) tightest freshness · purge on every mutation

Failure Modes

Synchronized mass expiry (cache stampede). A batch of keys written in the same request all carry an identical TTL and expire together, dumping concurrent misses on the database. Diagnose by correlating a spike in keyspace_misses with a periodic sawtooth in key count:

redis-cli INFO stats | grep keyspace_misses

Fix by applying jittered_ttl (Step 4) so expiries are spread across a window. Read-through families that recompute on miss benefit further from probabilistic early recomputation.

Missed explicit invalidation. A purge dispatched after a database commit is lost to a broker outage or consumer lag, so the cache serves the pre-mutation value until the next write. Diagnose by comparing the last-modified timestamp in your database against the cached payload for a sampled key. Fix with the Step 7 hybrid: the short TTL caps how long any missed purge can persist, and idempotent retries (the tenacity decorator in Step 6) absorb transient broker failures.

Expiry starved by long commands. A blocking KEYS, large SORT, or slow Lua script monopolizes the single-threaded event loop, so the active-expiration cycle never runs and TTL-bearing keys pile up past their nominal expiry. Diagnose:

redis-cli SLOWLOG GET 10
redis-cli INFO stats | grep expired_keys   # stagnant while key count climbs

Fix by removing the blocking command (replace KEYS with SCAN) and, if expiry still lags under legitimate load, raise hz.

Verification

Confirm every cached key in the family carries a bound — a -1 result means an unbounded key slipped past your policy:

redis-cli TTL product:12345          # expect a positive integer, never -1

Assert that expiry is the dominant removal path over a load test, and that eviction is not silently doing the work of expiration:

redis-cli INFO stats | grep -E "expired_keys|evicted_keys"
# expired_keys should climb steadily; a large evicted_keys delta means
# maxmemory-policy is intervening — revisit sizing or eviction policy.

Verify an explicit purge actually removed the key and did not merely rewrite it:

redis-cli UNLINK product:12345
redis-cli EXISTS product:12345       # expect 0

FAQ

Can I use TTL and explicit invalidation on the same key?

Yes — that is the recommended hybrid (Step 7). Explicit UNLINK is the primary mechanism for freshness, and a short TTL is the safety net that bounds staleness if a purge is ever lost. Almost every write-heavy family should use both rather than betting on either alone.

Does a very short TTL make explicit invalidation unnecessary?

Only if your staleness SLA is larger than the TTL. A one-second TTL approximates strong consistency but multiplies database load and can expire mid-transaction, exposing partial state. If the SLA is truly near-zero, use explicit invalidation with a modest TTL behind it instead of shrinking the TTL toward zero.

DEL frees the value's memory synchronously on the main thread, so purging a large hash or set can stall every other command for milliseconds. UNLINK removes the key from the keyspace immediately but reclaims memory on a background thread, keeping the hot path responsive. Use UNLINK for anything but trivially small values.

Do not scan the whole keyspace on every write. Group related keys under a shared tag and purge the tag set, as covered in key tagging strategies. For asynchronous or high-volume fan-out, offload the purge to a queue via asynchronous invalidation queues with Celery.

My expired_keys counter is flat but keys still disappear — what is happening?

They are being evicted, not expired. When memory reaches maxmemory, Redis removes keys under its eviction policy before their TTL fires, so evicted_keys rises while expired_keys stays flat. Confirm the policy with redis-cli CONFIG GET maxmemory-policy and review LRU vs LFU eviction policies to right-size the instance.


Up: TTL vs Explicit Invalidation