Skip to content
Home About Services Work Notes Contact Start a project
Performance

Cache-Aside in Django: Getting Invalidation Right

There are two hard problems in computer science, and this post is about the second one — plus off-by-one errors.

RedisDjangoCachingPerformance

Cache-aside is the pattern almost everyone ends up using, usually without naming it. The application asks the cache; on a miss it asks the database, writes the result back, and returns it.

def get_dashboard(user_id):
    key = f"dash:v3:{user_id}"
    data = cache.get(key)
    if data is None:                     # miss
        data = expensive_query(user_id)
        cache.set(key, data, timeout=300)
    return data

Four lines, and three separate ways to get it wrong.

Mistake one: falsy values are not misses

The check above is correct. This one, which is what people usually write, is not:

data = cache.get(key)
if not data:              # WRONG
    ...

A cached 0, empty list or empty string is falsy. If your dashboard legitimately returns "no pending items", every single request is a cache miss — and the cache is now pure overhead on your hottest path. Always compare against None, or use a sentinel.

Mistake two: keys without a version

The moment you change the shape of what you cache, every cached entry becomes a landmine. Old rows have the old structure; new code expects the new one; you get KeyError in production for exactly as long as the TTL.

Put a version in the key and bump it whenever the structure changes:

DASH_VERSION = 3          # bump on any shape change
key = f"dash:v{DASH_VERSION}:{user_id}"

Old entries are now unreachable and expire on their own. No flush, no downtime, no coordinating a deploy with a cache clear. This costs nothing and saves an incident.

Mistake three: the stampede

A popular key expires. Two hundred concurrent requests all miss, all run the expensive query, and all write the same value back. Your database gets two hundred copies of a query it was being protected from — precisely when traffic is highest.

The fix is a short lock so only one caller recomputes:

def get_with_lock(key, producer, timeout=300, lock_ttl=30):
    data = cache.get(key)
    if data is not None:
        return data

    # add() is SET NX: atomic, only one caller wins
    if cache.add(f"lock:{key}", "1", timeout=lock_ttl):
        try:
            data = producer()
            cache.set(key, data, timeout=timeout)
            return data
        finally:
            cache.delete(f"lock:{key}")

    # someone else is computing it. Wait briefly and re-read
    # rather than piling onto the database.
    for _ in range(10):
        time.sleep(0.1)
        data = cache.get(key)
        if data is not None:
            return data

    # the holder died or is slow: compute rather than fail
    return producer()

The final fallback matters. A lock holder that crashes must not turn a cache miss into an outage — degrading to "everyone computes" is bad, and failing the request is worse.

The best invalidation is no invalidation

Explicit invalidation — deleting keys when data changes — sounds clean and rots quickly. Every new write path is a new place someone must remember to invalidate, and the bug from forgetting is stale data that nobody notices for weeks.

Prefer keys that make themselves stale. Include the thing that changes in the key itself:

# The key changes when the row changes, so the old entry is
# simply never asked for again. Nothing to invalidate.
key = f"invoice:v2:{invoice.id}:{invoice.updated_at.timestamp()}"

For a collection, use an aggregate — a max updated_at or a count — as part of the key. You trade a cheap indexed query for never having to reason about invalidation again, which is a trade worth making almost every time.

When you genuinely must invalidate, do it in post_save, and only for keys you can name exactly. Wildcard deletion by pattern means KEYS or SCAN across the keyspace, which is slow and, in the case of KEYS, blocks the entire Redis instance.

Choose TTLs by how wrong you can afford to be

Not by how often the data changes — by how much staleness costs.

DataTTLWhy
Nav menus, site settingshoursChanges rarely, staleness harmless
Dashboard aggregates1–5 minExpensive, approximate is fine
Search result pages30–60 sAbsorbs bursts on popular terms
Stock levels, balancesdon't cacheBeing wrong here costs real money

Add jitter to TTLs on keys written together. A thousand entries created in the same request all expiring in the same second recreates the stampede on a schedule:

cache.set(key, data, timeout=300 + random.randint(0, 60))

Configure the Redis side too

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {"socket_timeout": 2, "socket_connect_timeout": 2},
    }
}

Those timeouts are not optional. Without them, a wedged Redis makes every request hang on a socket read instead of falling through to the database — the cache becomes a hard dependency of a system that was supposed to work without it.

Use a separate Redis database number (or instance) from your Celery broker, and set an eviction policy so a full cache evicts rather than erroring:

maxmemory 512mb
maxmemory-policy allkeys-lru

Never point Celery at a database with allkeys-lru. Evicting a queued task under memory pressure is exactly the silent job loss you set up a queue to avoid.

Measure the hit rate or do not bother

A cache you cannot measure is a cache you cannot reason about. redis-cli INFO stats gives you keyspace_hits and keyspace_misses; the ratio is the number that tells you whether any of this is working.

A hit rate below roughly 80% usually means the TTL is too short, the key is too specific, or — most often — you are caching something that is not actually read repeatedly. That last one is common, and the right response is to delete the caching code rather than tune it.

Next step

Need this built properly?

Tell me what you are trying to ship. You get an approach, a timeline and a realistic estimate — usually within a working day.