Django's cache backends evict least-recently-used entries first. Popular profiles are requested often and stay warm; rarely visited ones are evicted and rebuilt on the next request. The working set here is about 40 MB in a 256 MB cache, so eviction pressure is minimal under normal traffic.
Services
Backend Engineering, Infrastructure
Industry
B2B Marketplace
Year
2021-2024
One cache for two hundred company profiles
The ticket read: 'I updated my description 10 minutes ago and it still shows the old one.' It arrived three to four times a week at a B2B marketplace that serves more than 200 company profiles from one Django instance. Every profile is public, visited constantly and edited by the company itself. Caching was not optional. Trusting it was the problem.
THE CHALLENGE
Two ways to be wrong
Uncached, every profile view cost eight to twelve database queries. The first cache used Django's per-view cache with the URL as the key, and it held until two companies edited their profiles inside the same TTL window. Company A's save invalidated the cache, but the invalidation was too broad: it flushed the pages of B, C and every other tenant, and all 200 profiles went cold at once. The workaround, longer TTLs with manual invalidation, was worse in a different way. Editors saved, refreshed and saw their old text. That is when the tickets started.
THE SOLUTION
The key carries the version
Every profile is cached under a composite key: company slug, locale and an eight-character hash of the company's last_modified timestamp. Django updates that timestamp on every save, so the next request computes a new hash and a new key. The old entry is never deleted. It becomes unreachable, because nothing asks for that key again, and the cache's TTL evicts it later. There is no invalidation code, no cache.delete() call and no race between deleting the old page and writing the new one. Tag-based invalidation was the alternative on the table: keep a secondary index of which keys belong to which tenant and delete them on save. It would have added a structure that must stay in sync with the cache, a cleanup job for stale tags and a new failure mode where index and cache disagree. The version key is stateless. The price is a few orphaned entries waiting for their TTL.
The composite key generator:
Python
import hashlib
def company_cache_key(company, locale):
"""Composite cache key with version hash.
The hash changes whenever the company saves any field,
because Django auto-updates last_modified.
No explicit invalidation needed.
"""
ts = company.last_modified.isoformat()
version = hashlib.md5(ts.encode()).hexdigest()[:8]
return f"company:{company.slug}:{locale}:{version}"Edit one tenant, watch the other 23
Each tile is a company page; traffic hits it (green) or misses (amber). Click a tile to save an edit and watch the key panel: the timestamp changes, the hash changes, and only that tile goes cold. Switch to the first attempt's URL key to see what one save did to everyone else.
Key strategy
- Hit
- Miss
- Saved, new key
Tenant pages
Selected tenantacme-gmbhv1
- last_modified
2024-03-14T09:12:05- Cache key
company:acme-gmbh:en:f14c0a46
The hash is the last_modified timestamp. A new timestamp means a new key, and nobody requests the old one again.
Ready
- Requests
- 60
- Hits
- 42
- Misses
- 18
- Hit rate
- 70 %
- Warm entries
- 18 / 24
- Orphaned keys
- 0
No edit saved yet.
Heavy sections such as galleries and map embeds are cached as fragments with their own tenant-scoped keys. A description edit leaves the gallery cached, which cut database load by 60 % compared with caching whole pages only:
HTML
{% load cache %}
{# Gallery fragment: cached independently #}
{# Uses gallery_modified, not company.last_modified #}
{% cache 3600 gallery company.slug gallery_hash %}
{% for image in company.gallery_images.all %}
<img src="{{ image.url }}" alt="{{ image.alt }}" loading="lazy">
{% endfor %}
{% endcache %}
{# Map embed: rarely changes, long TTL #}
{% cache 86400 map company.slug company.address_hash %}
{% include "company/_map_embed.html" %}
{% endcache %}THE RESULT
Editors stopped opening tickets
An editor now saves, refreshes and sees the change, because the save itself produced a new key. The stale-content tickets stopped. Visitors get a cached profile in about a thirtieth of the uncached time. In 18 months of production there has been no stale-data incident and no page served to the wrong tenant. The whole cache, around 800 entries for 200 companies, two locales and two fragment types, sits in under 40 MB.
KEY METRICS
12msAvg. cached page load
94%Cache hit rate after warm-up
1DB queries per cached page
CLIENT FEEDBACK
Editors save, refresh and see their change. The stale-content tickets that used to come in three or four times a week have stopped. Nobody on the team has touched the invalidation code since, because there is none.
Technical Lead
B2B marketplace, platform team
FOR YOUR PROJECT
- When it applies
- Many tenants share one cache and edit their own content: company profiles, vendor pages, portals. The only requirement is a last_modified column that the ORM updates on every save.
- What to check
- Every input that changes the rendered page belongs in the key. Here the locale was missed at first, and one company's German profile was served to English visitors until the locale segment was added. Fragments with their own modification dates need their own keys.
- What it needs
- The cache backend you already run, one indexed timestamp column and TTL headroom for orphaned entries. Here that is roughly 800 live entries in under 40 MB of a 256 MB cache. No secondary index, no cleanup job.
FAQ
TECHNOLOGY STACK
Django
Python
PostgreSQL
Manuel Kasbarian - CEO, SophistiXWe have enjoyed working with Daniel for 10 years now. We highly appreciate his fast response times around the clock and his all-round knowledge. Whether server configurations or programming, he always has the right solution.
Follow in the footsteps of Manuel and bring your vision to life.
Open to new ProjectsGet In Touch