SUBID DASPORTFOLIO / 2026
000

SUBID

PORTRAIT // ORIGINAL[ 1 / 7 ]
WORK // RIDENOWW[ 2 / 7 ]
WORK // DEED POLL[ 3 / 7 ]
PORTRAIT // CYBERNETIC[ 4 / 7 ]
PORTRAIT // HOLOGRAM[ 5 / 7 ]
PORTRAIT // SKETCH[ 6 / 7 ]
PORTRAIT // GLITCH[ 7 / 7 ]
SUBID DAS
Back to Blog

Redis Caching Patterns: Cache-Aside, Write-Through & Invalidation Strategies

10 min readSubid Das
rediscachingbackendperformancedevops

Redis is fast—sub-millisecond reads. Databases are slow—10-100ms. Caching bridges the gap, reducing database load by 90%.

Why Caching Matters

Database query: 50ms Redis cache hit: 1ms Cache hit rate: 95%

Average response time = (0.95 × 1ms) + (0.05 × 50ms) = 3.5ms
Without cache: 50ms

That's 14x faster with 95% cache hit rate.

Pattern 1: Cache-Aside (Lazy Loading)

Most common. Code checks cache; if miss, query DB and populate cache.

def get_user(user_id):
    # Check cache first
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)
    
    # Cache miss: query database
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    
    # Populate cache (expires in 1 hour)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    
    return user

Advantages

  • Simple to implement
  • Flexible TTL (time-to-live)
  • Handles missing data gracefully

Disadvantages

  • Cache misses are slow (hit DB)
  • Stale data possible (if DB updates)
  • Thundering herd: Many requests on miss → many DB queries

Pattern 2: Write-Through

Cache is updated immediately on writes. Reads only hit cache (usually).

def update_user(user_id, data):
    # Update database
    db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)
    
    # Update cache immediately
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))
    
    return data

def get_user(user_id):
    # Always in cache (should be)
    cached = redis.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)
    
    # Fallback (shouldn't happen if writes are consistent)
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user

Advantages

  • Faster reads (always cache hits)
  • Data consistency (cache updated on write)

Disadvantages

  • Complex logic (must update both DB and cache)
  • Write latency (two operations)
  • Cache grows unbounded (need eviction policy)

Pattern 3: Write-Behind (Write-Back)

Asynchronously write to cache, then background job updates DB.

# Write to cache immediately (fast write)
def update_user(user_id, data):
    redis.setex(f"user:{user_id}", 3600, json.dumps(data))
    
    # Queue background job
    job_queue.add("sync_user", user_id, data)
    return data

# Background worker
def sync_user_to_db(user_id, data):
    time.sleep(5)  # Batch updates
    db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)

Advantages

  • Fastest writes (only Redis, ~1ms)
  • Reduces DB load significantly

Disadvantages

  • Risk of data loss (Redis crash before DB sync)
  • Complexity (background jobs, error handling)
  • Eventual consistency (not immediate)

Use only for non-critical data (session state, counters).

Cache Invalidation Strategies

Invalidation is hard. There are only two hard things in Computer Science: cache invalidation and naming things.

Strategy 1: TTL-Based (Expiration)

# Set expiration on cache entry
redis.setex(f"user:{user_id}", 3600, json.dumps(user))  # 1 hour TTL

Simple but can serve stale data.

Strategy 2: Event-Based (Immediate)

On writes, invalidate cache:

def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)
    
    # Invalidate cache immediately
    redis.delete(f"user:{user_id}")

Next read triggers refresh.

Strategy 3: Tag-Based (Cascade)

Invalidate related keys:

# On write, invalidate user AND user's posts
def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)
    
    redis.delete(f"user:{user_id}")
    
    # Also delete user's posts (related data)
    redis.delete(f"posts:user:{user_id}")
    
    # Or use tags (if Redis 7.0+)
    redis.delete(redis.smembers(f"user:{user_id}:tags"))

Strategy 4: Versioning

Change key on update:

def update_user(user_id, data):
    # Get current version
    version = redis.get(f"user:{user_id}:version") or 1
    new_version = int(version) + 1
    
    # Write new version
    redis.setex(f"user:{user_id}:v{new_version}", 3600, json.dumps(data))
    
    # Update version pointer
    redis.setex(f"user:{user_id}:version", 3600, new_version)
    
    # Update DB
    db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)

def get_user(user_id):
    version = redis.get(f"user:{user_id}:version")
    if version:
        cached = redis.get(f"user:{user_id}:v{version}")
        if cached:
            return json.loads(cached)
    
    # Fallback
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user

Avoids delete operations (atomic key updates).

Thundering Herd Solution

When cache expires, many requests hit DB simultaneously:

def get_with_lock(key, db_query, ttl=3600):
    # Check cache
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    
    # Acquire lock (only one wins)
    lock_key = f"{key}:lock"
    if redis.set(lock_key, "1", nx=True, ex=10):
        # Lock acquired: fetch from DB
        try:
            data = db_query()
            redis.setex(key, ttl, json.dumps(data))
        finally:
            redis.delete(lock_key)  # Release lock
        return data
    else:
        # Lock held: wait and retry cache
        time.sleep(0.1)
        return get_with_lock(key, db_query, ttl)

Only one request hits DB; others wait.

Caching Data Structures

Different data → different strategies:

User Profile (infrequent changes)

redis.hset(f"user:{user_id}", mapping={
    "name": "John",
    "email": "john@example.com",
    "role": "admin"
})
redis.expire(f"user:{user_id}", 3600)

# Fetch specific field
name = redis.hget(f"user:{user_id}", "name")

# Increment counter
redis.hincrby(f"user:{user_id}", "visit_count", 1)

Leaderboard (frequently updated)

# Add scores (sorted set)
redis.zadd("leaderboard", {"user1": 100, "user2": 85, "user3": 90})

# Get top 10
top = redis.zrevrange("leaderboard", 0, 9, withscores=True)

# Rank of specific user
rank = redis.zrevrank("leaderboard", "user1")

Session (high-frequency, short-lived)

# Store entire session
redis.setex(f"session:{session_id}", 1800, json.dumps({
    "user_id": 42,
    "ip": "192.168.1.1",
    "created_at": time.time()
}))

# Check existence
if redis.exists(f"session:{session_id}"):
    print("Session valid")

Rate Limiting

# Allow 10 requests per minute
def rate_limit(user_id):
    key = f"rate:{user_id}"
    count = redis.incr(key)
    if count == 1:
        redis.expire(key, 60)
    return count <= 10

Monitoring Cache Health

# Cache hit ratio
def cache_stats(redis):
    info = redis.info("stats")
    hits = info["keyspace_hits"]
    misses = info["keyspace_misses"]
    total = hits + misses
    
    hit_rate = (hits / total) * 100 if total > 0 else 0
    print(f"Hit rate: {hit_rate:.1f}%")
    
    # Target: >95% for good performance
    return hit_rate

Best Practices

✅ Do❌ Don't
Cache read-heavy dataCache write-heavy data (cache thrashing)
Set appropriate TTLCache forever (stale data)
Monitor hit ratesAssume cache is working
Use versioning/tagsDelete keys without tracking
Handle cache missesFail when cache unavailable
Compress large valuesStore raw large objects
Use pipeliningOne operation per request

Checklist

  • Choose pattern (cache-aside, write-through)
  • Set TTL for all keys
  • Plan invalidation strategy
  • Handle thundering herd (locking)
  • Monitor cache hit rates (target >95%)
  • Compress large values
  • Use appropriate data structures (hash, sorted set)
  • Plan fallback for cache failures
  • Load test before production
  • Document cache keys (ttl, invalidation)

Conclusion

Redis caching is a multiplier: 14x faster responses, 90% less database load. Start with cache-aside. Graduate to write-through for consistency. Monitor hit rates religiously.

Most performance problems are solved by caching. Make it your first optimization.

About the author

Subid Das is a cloud native engineer and open source contributor. Find more articles onthe blog.

Open to freelance, full-time, and interesting problems.

LET'S CONNECT