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.