Back to Homepage
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

We 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.

Manuel Kasbarian - CEO, SophistiX

Follow in the footsteps of Manuel and bring your vision to life.

Get In TouchGet In Touch