WRITING / Engineering

Caching High-Traffic Endpoints with Redis

A practical caching layer for read-heavy endpoints, covering cache keys, invalidation, and stampede protection.

Some endpoints are read often and change rarely: a vendor dashboard summary, a product catalogue, a configuration blob. Recomputing those on every request is wasted work. A Redis cache in front of them is one of the cheapest performance wins available.

First principles

A cache is a second copy of a value, kept where it is faster to read. That one fact explains most of this post.

  • A copy can be wrong. Once the original changes, the copy is stale until it expires or someone deletes it. Every cache design is a decision about how long that is allowed to last.
  • A copy has to be found again. The key is the only link between a request and its copy, so keys need a scheme, not improvised strings.
  • A missing copy has to be rebuilt. When a copy expires, the next reader pays the full cost of building it. If many readers arrive at once, they all pay it together.

Decide what is worth caching

Good cache candidates share three traits: they are expensive to build, they are read far more than they are written, and a few seconds of staleness causes no harm. If a value must always be exact to the millisecond, it does not belong in a cache.

Configure Redis as the cache backend

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ["REDIS_URL"],
        "TIMEOUT": 300,
    }
}

Cache keys you can reason about

Keys are an interface. Build them deliberately, and include a version segment so you can invalidate every key at once by bumping a number.

def vendor_summary_key(vendor_id):
    return f"vendor:summary:v2:{vendor_id}"

The get-or-set pattern

Every cached read follows the same shape: look in the cache, build the value on a miss, store it, return it.

from django.core.cache import cache


def get_vendor_summary(vendor_id):
    key = vendor_summary_key(vendor_id)
    summary = cache.get(key)
    if summary is None:
        summary = build_vendor_summary(vendor_id)
        cache.set(key, summary, timeout=600)
    return summary

Invalidation is the hard part

There are two honest strategies. A short timeout accepts brief staleness and never needs explicit cleanup. Active invalidation deletes the key the moment the underlying data changes, usually from a model signal.

from django.db.models.signals import post_save
from django.dispatch import receiver


@receiver(post_save, sender=Order)
def drop_vendor_summary(sender, instance, **kwargs):
    cache.delete(vendor_summary_key(instance.vendor_id))

Defend against the stampede

When a hot key expires, every request that arrives in the next moment misses the cache and rebuilds the value at once. Keys that were set together also expire together, so the misses pile up. Adding a small random offset to the timeout spreads those expiries out so they do not all land together.

import random

cache.set(key, summary, timeout=600 + random.randint(0, 120))

The offset only helps with keys that expire together. It does not stop many requests from rebuilding one very hot key at the same moment. For that, let one request rebuild the value while the others keep serving the old copy or wait briefly.

Cache the result, not the whole response

Caching at the function level, around the value itself, beats caching a full Hypertext Transfer Protocol (HTTP) response. The same cached summary can then feed an application programming interface (API) endpoint, a server-rendered page, and a background report without being computed three times.

← All writing