all writing
2026-03-02

Redis caching patterns that actually reduce database load

Cache-aside, sensible TTLs, and how to invalidate without serving stale data — the caching patterns I use in production.

Caching is easy to add and easy to get wrong. Add it carelessly and you trade a slow app for a fast app that shows wrong data, which is worse. Done right, Redis takes enormous load off your database and cuts response times to a fraction. Here are the patterns I actually use, and the traps I've fallen into.

Cache-aside: the default pattern

The pattern that covers most cases is cache-aside, sometimes called lazy loading. The application checks the cache first, and only queries the database on a miss, then stores the result for next time.

def get_project(slug: str) -> Project:
    cached = redis.get(f"project:{slug}")
    if cached:
        return Project.parse_raw(cached)

    project = db.query(Project).filter_by(slug=slug).one()
    redis.set(f"project:{slug}", project.json(), ex=3600)
    return project

First request for a project hits the database and warms the cache. Every request for the next hour is served from Redis in under a millisecond, never touching the database. For read-heavy data that changes rarely, this alone can remove most of your query load. It's the first thing I reach for.

TTLs are not optional

See that ex=3600? That's a one-hour expiry, and it's the safety net. Even if your invalidation logic has a bug, even if you forget to clear a key somewhere, the cache heals itself within an hour because the entry simply expires and the next read repopulates it from the database.

I never set a cache entry without a TTL. A cache without expiry is a memory leak that also serves stale data forever. The length depends on the data: an hour for things like project details, a few minutes for something more volatile, seconds for near-real-time counts. Pick based on how wrong the data is allowed to be, because that's exactly what a TTL controls.

Invalidate on write

TTLs handle eventual correctness, but for data the user just changed, an hour is too long to wait. When something updates, delete its cache key so the next read repopulates fresh.

def update_project(slug: str, changes: dict):
    db.update(Project, slug, changes)
    redis.delete(f"project:{slug}")  # next read repopulates

Delete, don't update, the cache on write. It's tempting to write the new value straight into the cache, but then you have two sources of truth to keep in sync, and they drift. Deleting the key means the database stays the single source of truth and the cache lazily rebuilds from it. Simpler, and harder to get wrong.

The stampede problem

Here's the trap that catches people at scale. A popular cache key expires, and in the moment before it's repopulated, a hundred concurrent requests all miss the cache at once and all hit the database together. The cache was protecting the database, and its expiry becomes a spike that can knock the database over. That's a cache stampede.

The fix is a short lock so only one request rebuilds the entry while the others wait briefly.

def get_with_lock(key: str, loader):
    cached = redis.get(key)
    if cached:
        return cached
    if redis.set(f"lock:{key}", "1", nx=True, ex=5):
        value = loader()             # only one request runs this
        redis.set(key, value, ex=3600)
        redis.delete(f"lock:{key}")
        return value
    time.sleep(0.05)                 # others wait, then read the fresh value
    return redis.get(key)

nx=True means "set only if it doesn't exist," so exactly one request wins the lock and rebuilds. The rest pause for a heartbeat and read the value the winner just wrote. You only need this on your hottest keys, but on those it's the difference between a smooth expiry and a thundering herd.

Don't cache everything

The instinct once caching works is to cache more, but caching has a cost: complexity and the risk of staleness. Data that changes constantly, or that must always be exact like account balances, often shouldn't be cached at all. The database with a good index is already fast, which I covered in Postgres indexing. Cache expensive-to-compute, rarely-changing, frequently-read data. That's the sweet spot. Everything else, leave it to the database.

What good caching feels like

When caching is working, your database CPU drops, your p99 latency falls, and, most importantly, the data is still correct. That last part is the whole game. A cache that's fast but wrong has just moved your problem somewhere harder to debug, because now the bug is "sometimes, for some users, the data is old," which is miserable to reproduce.

So: cache-aside as the default, a TTL on every single entry, delete keys on write, and a lock on the few keys hot enough to stampede. That covers the vast majority of what I've needed in production, and none of it is complicated. The discipline is in the TTLs and the invalidation, not the Redis commands.