Redis Caching Patterns — Cache-Aside, Write-Through, TTL Strategy

Master Redis caching patterns: Cache-Aside, Write-Through, Write-Behind, and Read-Through. Learn TTL strategies, cache stampede prevention, and eviction policies.

Why Redis for Caching?

Redis is the de facto standard for application caching. It stores data in memory, delivering sub-millisecond read latency — 100-1000x faster than a typical relational database. But raw speed isn't the only benefit: Redis supports rich data structures (strings, hashes, sorted sets, lists) that map naturally to caching problems.

The key decision isn't whether to cache, but which pattern to use. The wrong pattern leads to stale data, cache stampedes, or cache churn that negates the performance gain.

Pattern 1: Cache-Aside (Lazy Loading)

The most common pattern. The application manages the cache explicitly: check the cache, if miss fetch from DB, store in cache, return result.

import redis
import json
from typing import Optional

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

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    # 1. Check cache
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. Cache miss — fetch from database
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)

    # 3. Store in cache with TTL
    r.setex(cache_key, 3600, json.dumps(user))  # TTL: 1 hour

    return user

Pros: Only caches what is actually requested. Resilient to cache failures (app falls back to DB). Cache nodes can fail without taking down the app.

Cons: Cache miss on first request (cold start). Data can be stale for up to TTL seconds. Three round-trips on cache miss.

Pattern 2: Write-Through

Every write goes to the cache and the database simultaneously. Reads always hit the cache — no miss after first write.

def update_user(user_id: int, data: dict) -> dict:
    # 1. Write to database
    updated = db.execute(
        "UPDATE users SET name=%s, email=%s WHERE id=%s",
        data["name"], data["email"], user_id
    )

    # 2. Simultaneously update cache
    cache_key = f"user:{user_id}"
    r.setex(cache_key, 3600, json.dumps(updated))

    return updated

Pros: Cache is always fresh. No stale reads. Low read latency guaranteed.

Cons: Write latency increases (two writes per operation). Cache bloat — you cache data that may never be read. Cache nodes becoming a bottleneck on write-heavy workloads.

Pattern 3: Write-Behind (Write-Back)

Writes go to the cache immediately and are asynchronously flushed to the database. Optimizes write performance at the cost of durability risk.

import asyncio
from collections import defaultdict

write_buffer: dict[str, dict] = {}

async def update_user_fast(user_id: int, data: dict) -> dict:
    cache_key = f"user:{user_id}"

    # Write to cache only — returns immediately
    r.setex(cache_key, 3600, json.dumps(data))
    write_buffer[cache_key] = data  # Queue for async flush

    return data

async def flush_writes():
    """Run as background task every 500ms"""
    while True:
        await asyncio.sleep(0.5)
        if write_buffer:
            batch = write_buffer.copy()
            write_buffer.clear()
            # Batch write to database
            for key, data in batch.items():
                user_id = key.split(":")[1]
                await db.execute_async(
                    "UPDATE users SET name=%s WHERE id=%s",
                    data["name"], user_id
                )

Warning: If Redis crashes before flush, data is lost. Use AOF persistence and set appendfsync always if durability matters.

Pattern 4: Read-Through

Similar to Cache-Aside but the cache itself fetches from the database on a miss. The application only ever talks to the cache.

class ReadThroughCache:
    def __init__(self, redis_client, db_client):
        self.redis = redis_client
        self.db = db_client

    def get(self, key: str, loader_fn, ttl: int = 3600):
        value = self.redis.get(key)
        if value:
            return json.loads(value)

        # Cache handles the miss internally
        value = loader_fn()
        self.redis.setex(key, ttl, json.dumps(value))
        return value

# Usage — app only calls cache.get()
cache = ReadThroughCache(r, db)
user = cache.get(
    f"user:42",
    lambda: db.query("SELECT * FROM users WHERE id=42")
)

TTL Strategy

Choosing the right TTL is as important as choosing the right pattern:

  • Session data: 15-30 minutes (match session timeout)
  • User profiles: 1-24 hours (low update frequency)
  • Product catalog: 1-6 hours (changes infrequently)
  • Search results: 5-15 minutes (freshness matters)
  • Rate limit counters: Match the rate limit window exactly
  • Real-time data (prices, inventory): 30-60 seconds or event-driven invalidation

Add jitter to TTLs to prevent thundering herd when many keys expire simultaneously:

import random

BASE_TTL = 3600
jitter = random.randint(-300, 300)  # +/- 5 minutes
r.setex(key, BASE_TTL + jitter, value)

Cache Stampede Prevention

When a popular cache key expires, many requests hit the database simultaneously — the "thundering herd" or cache stampede. Use probabilistic early expiration or a mutex lock:

import time

def get_with_lock(key: str, ttl: int, loader_fn) -> dict:
    value = r.get(key)
    if value:
        return json.loads(value)

    # Acquire a lock to prevent stampede
    lock_key = f"lock:{key}"
    lock_acquired = r.set(lock_key, "1", nx=True, ex=5)  # 5s lock

    if lock_acquired:
        try:
            result = loader_fn()
            r.setex(key, ttl, json.dumps(result))
            return result
        finally:
            r.delete(lock_key)
    else:
        # Wait for lock holder to populate cache
        time.sleep(0.1)
        return get_with_lock(key, ttl, loader_fn)  # Retry

Eviction Policies

When Redis runs out of memory, it must evict keys. Choose your policy based on access patterns:

  • allkeys-lru — Evict least recently used keys (best for general caching)
  • allkeys-lfu — Evict least frequently used (best for skewed access patterns)
  • volatile-lru — Only evict keys with a TTL set
  • noeviction — Return errors when memory full (for session stores)
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

Cache Key Design

Good key design prevents collisions and simplifies debugging:

# Pattern: service:entity:id[:field]
"api:user:42"
"api:user:42:posts"
"api:search:q=python&page=1"

# Use hashes for related fields (saves memory vs multiple string keys)
r.hset("user:42", mapping={
    "name": "Alice",
    "email": "[email protected]",
    "role": "admin"
})
r.expire("user:42", 3600)

Frequently Asked Questions

Cache-Aside vs Read-Through — which should I choose?

Cache-Aside gives you more control and is more resilient to cache failures. Read-Through is cleaner code when using a caching library that supports it. For most web applications, Cache-Aside is the pragmatic choice.

How do I handle cache invalidation?

Cache invalidation is famously hard. Prefer time-based TTL expiration for most cases. For data that must be fresh immediately (prices, inventory), use event-driven invalidation: publish a message to delete the cache key when the database row changes.

Should I cache API responses?

Yes — caching API responses at the edge (CDN) or in Redis reduces both latency and upstream load significantly. Use DevKits JSON Formatter to inspect and validate the JSON structures you are caching.