Django2 min read

Why I stopped putting a TTL on cached API responses

I had a Django API sitting in front of a portfolio site. The content changes maybe twice a month. Traffic is small. Caching should have been the easy part.

So I did what everyone does first. Wrapped the expensive view in a cache with a five minute timeout, watched the response time drop, moved on.

Then I edited my bio and it did not change on the site. Refreshed. Still old. Refreshed again a few minutes later and there it was.

A TTL is a guess about the future

Picking a timeout means answering a question you cannot actually answer: how long is it acceptable to serve something wrong?

Five minutes felt small when I typed it. It does not feel small when you are staring at your own stale bio wondering whether the deploy even worked.

The trade is bad in both directions, too. Content that never changes gets rebuilt every five minutes anyway. Content that just changed stays wrong for five minutes. You pay on both sides.

Versioned keys

The fix is old and boring. Put a version in the cache key, then change the version when the content changes.

def versioned_key(name):
    return f"api:{cache.get('content:version')}:{name}"

A post_save signal on the content models bumps that version. Every payload cached under the old version becomes unreachable in a single write. No scanning, no key patterns, no delete loops.

Invalidation stops being a chore you have to remember and becomes something that happens because you saved a model.

The part that bit me

My first version used an incrementing counter. cache.incr on a key.

That works right up until the key is not there. Django's database cache backend culls entries once the table passes MAX_ENTRIES, and it does not care that you set your version key to never expire. When it vanished, incr failed, my fallback reset the counter to 1, and every payload cached back when the database was still empty became reachable again.

The site served content from before it had any content. For hours. Nothing looked broken.

A counter has to be read before it can be incremented, and that read is the weak point. A timestamp does not have one:

def bump_content_version():
    cache.set("content:version", time.time_ns(), None)

Losing the key now costs one rebuild. It cannot bring anything back, because the clock does not run backwards.

What I would tell past me

Reach for a TTL when stale data is genuinely fine and you do not control the writes. Reach for versioned keys when you do control the writes, which for your own content is always.

And whatever you pick, ask what happens when the bookkeeping key disappears. Mine had an answer. It was just the wrong one.

Questions people ask

Does this work with Redis, or only the database cache?
Both. Redis will not cull a key you set to never expire the way the database backend does, but the timestamp version costs nothing and removes the question entirely.
Do I need to delete the old cache entries?
No. They become unreachable the moment the version changes, and they age out on their own timeout.

Comments

I read every comment before it goes up, so it will not appear straight away.