Your cache is not the problem — your invalidation is
Most teams tune TTLs when what they actually need is to know which keys a write touches. Here is the registry pattern we use on every Django project.
Hnin Ei Phyu
Senior Backend Engineer

Every team that has been burned by a stale page reaches for the same lever: lower the TTL. Five minutes becomes one minute becomes thirty seconds, and the cache hit rate quietly collapses until the cache is doing nothing but adding a Redis round-trip to a database query you were going to make anyway.
The TTL was never the problem. The problem is that nobody can answer, for a given write, which cache keys just became wrong.
The shape of the bug
Here is the version of this bug we have now seen in a dozen codebases:
def list(self, request):
data = cache.get('services_list')
if data is None:
data = ServiceSerializer(Service.objects.all(), many=True).data
cache.set('services_list', data, timeout=60 * 60)
return Response(data)Nothing is wrong with that view. What is wrong is what happens three hundred lines away, in a model's save(), where someone remembered to call cache.delete('services_list') — and in the four other places that also write a Service and did not.
The knowledge of "this write invalidates these keys" lives nowhere. It is smeared across every call site that happens to remember.
Declare it once
The fix is boring and it works: one module that knows every cache key the application writes, grouped by the resource that owns it.
RESOURCES = {
'services': {
'patterns': ['services_list', 'service_detail_*'],
'label': 'Services',
},
'portfolio': {
'patterns': ['portfolio_list', 'portfolio_detail_*', 'portfolio_categories'],
'label': 'Portfolio & case studies',
},
}Then a single invalidate(resource) that expands the patterns and deletes them — remembering that keys on the wire carry Django's KEY_PREFIX:VERSION: stamp, so the pattern has to go through cache.make_key() before it reaches Redis rather than being matched raw. That one detail has cost more debugging hours across our projects than the rest of the caching layer combined.
Wire it to signals, not to call sites
With the map in one place, the trigger can be generic:
MODEL_RESOURCES = {
'services.Service': 'services',
'services.ServiceFeature': 'services',
'portfolio.Project': 'portfolio',
}
@receiver(post_save)
def content_saved(sender, instance, created, **kwargs):
resource = MODEL_RESOURCES.get(label_for(instance))
if resource:
invalidate(resource)Now a new model is one line in a dict, and it is structurally impossible for a write path to forget. post_delete gets the same treatment, and so does m2m_changed — which is the one everybody misses, because attaching a technology to a project fires no post_save on either side.
The part that changes the argument
Once invalidation is a single function, you can make it do a second thing: tell connected browsers.
def invalidate(resource, *, broadcast=True):
deleted = sum(delete_pattern(p) for p in patterns_for(resource))
if broadcast:
broadcast_content_updated(resource)
return deletedCache first, broadcast second — always in that order. A browser that reacts to the broadcast must never be able to refetch the entry you are in the middle of deleting.
That ordering is the whole trick. With it, a seven-day TTL is not a freshness risk, because nothing ever lives to see it: content is dropped and re-pushed the instant an editor saves. Without it, you are back to guessing, and the guess is always thirty seconds.
What we gave up
Pattern deletion means SCAN against Redis, which is O(keys) rather than O(1). At our key counts — thousands, not millions — that is single-digit milliseconds on a write path that happens when a human clicks Save. If your write rate is high enough for that to matter, keep a per-resource set of live keys and delete the set members directly. We have not needed to yet, and we would rather add that when the numbers say so than carry the complexity on a guess.


