Skip to content
Blog Panji

Redis Caching Strategies: Cache-Aside, Write-Through, and Invalidation Done Right

A practical guide to Redis caching strategies for production backends: cache-aside, write-through, write-behind, TTL tuning, and safe cache invalidation — with Python code examples.

Panji Ihsanudin Fajri

14 min read0 reads

Illustration of a Redis cache layer between an application server and a database for Redis caching strategies
Illustration of a Redis cache layer between an application server and a database for Redis caching strategies
Contents
  1. What Is Caching and Why Redis Fits It
  2. How Caching Works in Redis
  3. Four Redis Caching Strategies Worth Knowing
  4. Cache-Aside
  5. Read-Through
  6. Write-Through
  7. Write-Behind
  8. Tutorial: Cache-Aside with Python
  9. Cache Invalidation Without the Drama
  10. Common Mistakes and How to Avoid Them
  11. Redis Caching Best Practices
  12. FAQ
  13. How long should I set Redis TTL for?
  14. What is the difference between cache-aside and read-through?
  15. How do I handle cache invalidation in Redis?
  16. Can Redis be used as a primary database?
  17. Conclusion

Your database is not slow. It is just tired of answering the same question forty thousand times a minute. This article covers the Redis caching strategies that actually survive production: cache-aside, read-through, write-through, and write-behind, plus the invalidation patterns that keep stale data from embarrassing you at 2 a.m.

TL;DR: Cache-aside is the strategy you should learn first — the application checks Redis before the database, and you keep full control of what gets cached. Write-through keeps cache and database in sync on every write at the cost of slower writes. Every key needs a TTL, because a cache without expiration is a slow memory leak. Invalidation is a design decision, not an afterthought — pick TTL-based expiry, event-driven deletes, or key versioning before you ship.

Flow diagram of the cache-aside pattern with Redis, one of the most common Redis caching strategies
Read flow: the application checks the cache before hitting the database

What Is Caching and Why Redis Fits It

Caching is storing the answer to an expensive question so you do not have to recompute it. Database queries with joins, aggregation endpoints, rendered pages, session lookups — anything your backend calculates more than once is a candidate. The trade is simple: you spend memory to save time.

Redis fits because it is built for exactly this trade. It keeps data in memory, so reads return in sub-millisecond time instead of the tens of milliseconds a database round trip costs. Its data model is a key-value store with rich types — strings, hashes, lists, sets — and every key can carry an expiration. That last part matters more than people expect. A cache that never expires is not a cache; it is a second database that you forgot to maintain.

There is also an operational reason. If you design REST API design principles around deterministic responses, large parts of your API become trivially cacheable. The same GET /products/4821 returns the same payload for every caller until the product changes. That is the shape of problem Redis solves best.

How Caching Works in Redis

At its core, Redis stores keys that point to values. You write with SET, read with GET, remove with DEL. For caching you will mostly reach for SETEX, which sets a key and its expiration in a single atomic step — if your process crashes between SET and EXPIRE, you never end up with a key that lives forever. The expiration is the TTL, measured in seconds, and you can inspect what is left with TTL or change it with EXPIRE.

Illustration of Redis key-value pairs with TTL timers
Every key in Redis can carry a time-to-live (TTL)

Picture every key with a small countdown attached. A product page cached with a 300-second TTL silently disappears five minutes later, and the next request rebuilds it from the source of truth. This is the heartbeat of every Redis TTL strategy: nothing in the cache is permanent, so nothing in the cache can drift forever.

What happens when Redis runs out of memory is configurable, and the choice matters. The default eviction policy in self-hosted Redis is noeviction: Redis refuses new writes and returns errors rather than dropping data you might still need. That default is safe but surprising in production — your application suddenly gets write errors when memory fills up. The alternative most caching setups want is allkeys-lru, which evicts the least recently used keys first. Old, untouched entries die to make room for hot ones, which is exactly what a cache should do. Set it with CONFIG SET maxmemory-policy allkeys-lru and set maxmemory to something your box can actually afford. The official Redis documentation on eviction covers the full list of policies if you need finer control.

One more building block worth knowing early: pipelines. The redis-py client lets you batch several commands into one round trip with pipeline(), which matters when a single request needs to read five keys or invalidate a set of related entries. Fewer round trips, less latency.

Four Redis Caching Strategies Worth Knowing

Strategy is just a fancy word for deciding who reads from where, who writes to where, and in what order. There are four patterns you will see in every serious discussion of Redis caching strategies, and each one answers those questions differently.

Cache-Aside

The application owns everything. On a read, it asks Redis first; on a miss, it queries the database, stores the result in Redis, and returns it. On a write, it updates the database and deletes (or updates) the cached entry. This is the cache-aside pattern, sometimes called lazy loading because the cache only fills up as requests arrive.

It is popular for a reason: the cache never contains data nobody asked for, and if Redis goes down, the application still works — it just gets slower. The cost is that the application code carries the caching logic, and the first request after a cold start always misses.

Read-Through

Same shape as cache-aside, but the cache sits between the application and the database as a proxy. The application only ever talks to the cache; on a miss, the cache itself fetches from the database, stores the result, and returns it. Your application code stays clean because it never knows the database exists on the read path.

The catch: something has to implement that proxy behavior. Redis does not do it natively, so teams usually build it with a library or a sidecar. It also means a cache outage is now a full outage, not a slowdown.

Write-Through

Writes go to the cache and the database together, in the same operation. The cache never holds stale data because it is updated at write time. Reads are fast and always consistent.

The price is write latency — every write now waits for two systems — and you are caching data that might never be read again. A write-through cache makes sense when reads vastly outnumber writes and stale reads are unacceptable, like pricing or account balances.

Write-Behind

Writes go to the cache first and return immediately; the cache flushes to the database asynchronously, seconds or minutes later. This is the fastest write path of the four and it smooths out write spikes beautifully.

The risk is obvious: if the cache node dies before the flush, you lose acknowledged writes. Use it for data you can afford to lose or reconstruct — counters, analytics events, activity feeds — not for payments.

Strategy How it works Best for Drawback
Cache-Aside App checks cache, falls back to DB, populates cache General-purpose, most web workloads App code owns caching logic; cold starts miss
Read-Through Cache proxies reads and loads from DB on miss Clean app code, read-heavy services Needs a cache-loader layer; cache outage = outage
Write-Through Writes go to cache and DB together Strong consistency, read-heavy with rare writes Slower writes; caches possibly unread data
Write-Behind Writes hit cache first, DB flush is async High write throughput, bursty traffic Risk of data loss before flush

If you are unsure where to start, start with cache-aside. It is the most forgiving, the easiest to reason about at 3 a.m., and it degrades gracefully when Redis has a bad day.

Tutorial: Cache-Aside with Python

Enough theory. Here is the cache-aside pattern implemented with redis-py, the way you would actually write it in a small service. For a quick local Redis to test against, see running Redis with Docker.

Step 1 — Set up the client and a key naming convention. Namespaced keys like user:profile:4821 keep things greppable and let you invalidate whole groups with pattern deletes later.

Step 2 — Write the read path: check the cache first. On a hit, return immediately. On a miss, query the database, store the result with SETEX so it always carries a TTL, and return it.

import json
import random

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)


def cache_key(user_id: int) -> str:
    return f"user:profile:{user_id}"


def fetch_profile_from_db(user_id: int) -> dict:
    # your actual database query goes here
    ...


def get_profile(user_id: int, ttl: int = 300) -> dict:
    """Cache-aside read: Redis first, database on miss."""
    key = cache_key(user_id)

    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    profile = fetch_profile_from_db(user_id)

    # jitter spreads expirations so a thousand keys don't die at once
    jittered_ttl = ttl + random.randint(0, 60)
    r.setex(key, jittered_ttl, json.dumps(profile))

    return profile

Step 3 — Write the write path: database first, then invalidate. Never write to the cache without writing to the database. Delete the stale key so the next read rebuilds it fresh.

Step 4 — Batch related invalidations with a pipeline. When one update touches several cached entries, send them in a single round trip instead of three.

def save_profile_to_db(user_id: int, data: dict) -> dict:
    # your actual database write goes here
    ...


def update_profile(user_id: int, data: dict) -> dict:
    """Database first, then refresh the cache in one round trip."""
    saved = save_profile_to_db(user_id, data)

    pipe = r.pipeline()
    pipe.setex(cache_key(user_id), 300, json.dumps(saved))
    pipe.delete(f"user:feed:{user_id}")  # feed listing is stale now
    pipe.execute()

    return saved


def invalidate_profile(user_id: int) -> None:
    r.delete(cache_key(user_id))

Step 5 — Think about the stampede before it happens. The jitter in get_profile already prevents mass synchronized expiry. For genuinely hot keys, add a short lock so only one request rebuilds the value while the rest wait or serve the stale copy. A cache stampede — thousands of concurrent misses hammering the database because one popular key expired — is the classic way a cache makes things worse instead of better.

Comparison diagram of write-through vs write-behind flows
Write-through writes to cache and database together; write-behind defers the database write

Cache Invalidation Without the Drama

Phil Karlton's old joke — that the two hard things in computer science are cache invalidation and naming things — survives because it is true. Here are the cache invalidation strategies that work, roughly in order of effort.

TTL-based expiry is the default and it is fine. Set a sensible TTL on everything and accept that data can be stale for at most that long. Product listings with a five-minute TTL, session data with thirty minutes, rendered pages with an hour. Most applications never need anything fancier, and the failure mode — briefly stale data that fixes itself — is the gentlest of all.

Event-based invalidation is precise. When a write happens, delete the affected keys immediately, like update_profile does above. You can also publish invalidation events on Redis Pub/Sub so multiple application instances drop their local caches in sync. More moving parts, but the cache is never stale longer than the time it takes to process the event.

Key versioning sidesteps deletion entirely. Instead of deleting user:profile:4821, bump a version counter and read user:profile:4821:v7. Old versions expire naturally via TTL. This shines when invalidation needs to fan out across many keys — bump one version key instead of deleting ten thousand entries — and it avoids the thundering delete that can follow a mass invalidation.

Stampede protection deserves its own line item. Beyond TTL jitter, the standard tools are request coalescing (one rebuild serves all waiting requests) and serving stale-while-revalidate: return the expired value immediately and refresh it in the background. Users never wait for a rebuild; the database never sees the spike.

Common Mistakes and How to Avoid Them

Caching without a TTL. Every SET without an expiration is a bet that you will remember to delete the key later. You will not. Use SETEX by default and make a bare SET a conscious, reviewed decision.

Caching the wrong granularity. Caching an entire dashboard payload because one widget is slow means every tiny change invalidates the whole thing. Cache the slow widget. Smaller units invalidate more precisely and hit more often.

Treating the cache as the source of truth. If the only copy of a value lives in Redis and your eviction policy drops it, that data is gone. The database is the source of truth; the cache is a copy. Anything you cannot reconstruct does not belong in a cache.

Forgetting that invalidation has a blast radius. A KEYS user:* scan followed by deletes on a production Redis will block the server while it scans. Use SCAN incrementally, or better, design key names so related entries share a version prefix you can bump instead of deleting.

No monitoring on hit rate. If you do not track cache hits versus misses, you are guessing. A hit rate below 80% on a cache you built for reads means your TTLs are too short, your keys too granular, or your traffic pattern does not suit caching at all. Redis exposes this via INFO stats — wire it into whatever you already use for monitoring.

Redis Caching Best Practices

Keep TTLs explicit and intentional: short for volatile data, longer for reference data, never infinite by accident. Namespace your keys from day one — service:entity:id — because debugging a flat keyspace at scale is miserable. Prefer allkeys-lru over the default noeviction when Redis is purely a cache, and size maxmemory so eviction is rare rather than constant.

Separate concerns across Redis instances when it matters: sessions, rate limiting, and application cache have different eviction needs and different blast radii. One shared instance is fine until it is not.

Warm the cache on deploy. After a restart or a release that changes key formats, a cold cache means every request misses at once — the same stampede shape as an expiry storm. A warmup script that preloads the hottest keys, run as part of your CI/CD pipelines, turns deploys from latency spikes into non-events.

Finally, design for Redis being unavailable. Cache-aside degrades to "slow but working" only if your code handles connection errors and falls back to the database. Wrap Redis calls, set short timeouts, and test the fallback path. The cache is an optimization, not a dependency — your uptime should not hinge on it.

The official redis-py guide is a solid second reference for the Python integration used in this article.

FAQ

How long should I set Redis TTL for?

It depends on how stale you can tolerate, which is a product question disguised as a technical one. Start with five minutes for user-facing dynamic content and an hour for reference data, then adjust based on measured hit rates and stale-read complaints. Anything without a TTL should be a deliberate exception, not the default.

What is the difference between cache-aside and read-through?

In cache-aside, your application code checks Redis, falls back to the database, and populates the cache — the app owns the logic. In read-through, the application only talks to the cache, and a loader layer fetches from the database on a miss. Cache-aside is simpler to adopt; read-through keeps application code cleaner at the cost of building the loader.

How do I handle cache invalidation in Redis?

Three options, in order of complexity: set a TTL and let keys expire, delete keys explicitly when the underlying data changes, or version your keys so old entries die naturally. Most systems combine the first two — TTL as the safety net, explicit deletes for the hot paths. Add jitter to TTLs to avoid synchronized mass expiry.

Can Redis be used as a primary database?

Redis does offer persistence, but its eviction policies and memory-first design make it a poor primary store for data you cannot lose. Keep Redis as the cache and keep a durable database as the source of truth. If a key can vanish under memory pressure, it was never a database record.

Conclusion

The whole topic compresses to a short checklist: pick cache-aside unless you have a reason not to, give every key a TTL with jitter, invalidate on write for the data that matters, and never let the cache become load-bearing for correctness. Do that and Redis will quietly absorb the repetitive work your database was never meant to do.

Natural next steps: measure your hit rate for a week and tune TTLs from real data, then look at which endpoints miss most often — that is your roadmap for what to cache next. If you are caching embeddings or other ML artifacts, read choosing a vector database before you reach for Redis for that job; the access patterns are different enough to deserve their own design.

Keep reading

Related articles