Skip to main content
Caching

Cache-Aside Pattern: The Most Common Caching Strategy Explained

The cache-aside pattern (also known as lazy loading or lazy population) is the most widely used caching strategy in web applications. In this pattern, the ...

📖 7 min read

Cache-Aside Pattern: The Most Common Caching Strategy Explained

The cache-aside pattern (also known as lazy loading or lazy population) is the most widely used caching strategy in web applications. In this pattern, the application code is responsible for managing the cache — reading from it, detecting misses, populating it from the database, and invalidating stale entries. Unlike read-through or write-through patterns where the cache manages itself, cache-aside puts the application in full control.

How Cache-Aside Works

Read Flow

When the application needs data, it follows a three-step process:

  1. Check the cache: Look up the data by key in the cache (e.g., Redis).
  2. On cache hit: Return the cached data immediately. Done.
  3. On cache miss: Fetch the data from the primary data store (database), store it in the cache with a TTL, then return the data.
import redis
import json

cache = redis.Redis(host='localhost', port=6379)

def get_user_profile(user_id):
    cache_key = f"user:profile:{user_id}"
    
    # Step 1: Check cache
    cached_data = cache.get(cache_key)
    
    if cached_data is not None:
        # Step 2: Cache HIT — return immediately
        return json.loads(cached_data)
    
    # Step 3: Cache MISS — fetch from database
    profile = db.query(
        "SELECT id, name, email, avatar FROM users WHERE id = %s",
        (user_id,)
    )
    
    if profile is None:
        return None
    
    # Step 4: Populate cache for future reads
    cache.setex(cache_key, 600, json.dumps(profile))  # TTL: 10 minutes
    
    return profile

Write Flow

When data is updated, the cache-aside pattern typically invalidates (deletes) the cache entry rather than updating it. The next read will naturally repopulate the cache with fresh data.

def update_user_profile(user_id, new_data):
    # Step 1: Update the database (source of truth)
    db.execute(
        "UPDATE users SET name = %s, email = %s WHERE id = %s",
        (new_data["name"], new_data["email"], user_id)
    )
    
    # Step 2: Invalidate the cache entry
    cache_key = f"user:profile:{user_id}"
    cache.delete(cache_key)
    
    # Next read will fetch fresh data from DB and repopulate cache

Why delete instead of update? Deleting is safer because it avoids race conditions where two concurrent updates could leave the cache with stale data. See the race conditions section below for details.

Pros and Cons

Advantages

  • Simplicity: Easy to understand and implement. No special cache infrastructure required — just a cache client and a few lines of code.
  • Resilience: If the cache goes down, the application still works (just slower) by falling back to the database. The cache is not in the critical path for writes.
  • No cache pollution: Only data that is actually read gets cached. Write-heavy data that is never read does not waste cache space.
  • Flexible TTL: Different data types can have different TTLs based on their freshness requirements.
  • Technology agnostic: Works with any cache (Redis, Memcached, in-memory) and any database.

Disadvantages

  • Cache miss penalty: The first request for any data always hits the database. This cold-start problem can cause latency spikes after cache flushes or deployments.
  • Stale data window: Between a database update and cache invalidation, the cache may serve stale data. With TTL-only invalidation, data can be stale for the full TTL duration.
  • Application complexity: Every data access point in the code must implement the cache-aside logic. This can lead to code duplication if not properly abstracted.
  • N+1 cache problem: Fetching a list of items may require N individual cache lookups, each potentially a cache miss.

Race Conditions in Cache-Aside

Race Condition 1: Read-Write Race

This occurs when a read and write happen concurrently:

# Timeline showing the race condition:
#
# Thread A (Read)                    Thread B (Write)
# ──────────────────                 ──────────────────
# 1. Cache miss for "user:1001"
# 2. Read from DB (gets OLD value)
#                                    3. Update DB with NEW value
#                                    4. Delete cache key "user:1001"
# 5. Write OLD value to cache
#
# Result: Cache has STALE data, DB has FRESH data
# The stale data persists until TTL expires

This race is rare in practice because step 2 (database read) is typically much faster than the time between steps 3-4 (database write + cache delete). However, under heavy load or slow database queries, it can occur.

Mitigation Strategies

  • Short TTL as safety net: Even if stale data enters the cache, it expires relatively quickly. A 5-minute TTL limits the staleness window.
  • Delayed cache delete: After updating the database, wait a brief period (e.g., 500ms) before deleting the cache, then delete again. This "double delete" pattern catches most race conditions.
  • Versioned cache keys: Include a version number in the cache key. When data changes, increment the version. Old cache entries become orphaned and are eventually evicted by the eviction policy.
import time
import threading

def update_user_profile_safe(user_id, new_data):
    cache_key = f"user:profile:{user_id}"
    
    # Step 1: Delete cache (preemptive)
    cache.delete(cache_key)
    
    # Step 2: Update database
    db.execute("UPDATE users SET name=%s WHERE id=%s",
               (new_data["name"], user_id))
    
    # Step 3: Delayed second delete (catches race condition)
    def delayed_delete():
        time.sleep(0.5)  # Wait 500ms
        cache.delete(cache_key)
    
    threading.Thread(target=delayed_delete).start()

Cache-Aside vs Other Patterns

Pattern Who Manages Cache? Write Behavior Best For
Cache-Aside Application Write to DB, invalidate cache General purpose, read-heavy
Read-Through Cache library/proxy N/A (read pattern only) Simplifying application code
Write-Through Cache library/proxy Write to cache + DB synchronously Strong consistency needs
Write-Back Cache library/proxy Write to cache, async flush to DB Write-heavy, latency-sensitive

Production Implementation Tips

Abstract the Pattern

Do not duplicate cache-aside logic throughout your codebase. Create a reusable wrapper:

def cache_aside(cache_key, ttl, fetch_fn):
    """Generic cache-aside wrapper"""
    cached = cache.get(cache_key)
    if cached is not None:
        return json.loads(cached)
    
    data = fetch_fn()
    if data is not None:
        cache.setex(cache_key, ttl, json.dumps(data))
    return data

# Usage — clean and DRY
profile = cache_aside(
    f"user:profile:{user_id}",
    ttl=600,
    fetch_fn=lambda: db.get_user(user_id)
)

product = cache_aside(
    f"product:{product_id}",
    ttl=3600,
    fetch_fn=lambda: db.get_product(product_id)
)

Handle Cache Failures Gracefully

def get_user_resilient(user_id):
    cache_key = f"user:profile:{user_id}"
    
    try:
        cached = cache.get(cache_key)
        if cached is not None:
            return json.loads(cached)
    except redis.ConnectionError:
        # Cache is down — fall through to database
        pass
    
    profile = db.get_user(user_id)
    
    try:
        if profile is not None:
            cache.setex(cache_key, 600, json.dumps(profile))
    except redis.ConnectionError:
        pass  # Cache write failed — not critical
    
    return profile

Prevent Cache Stampede

When a popular cache entry expires, hundreds of requests may hit the database simultaneously. This is the cache stampede problem. Use a lock to ensure only one request fetches from the database while others wait:

def get_with_lock(cache_key, ttl, fetch_fn):
    cached = cache.get(cache_key)
    if cached is not None:
        return json.loads(cached)
    
    lock_key = f"lock:{cache_key}"
    if cache.set(lock_key, "1", nx=True, ex=5):  # Acquire lock for 5s
        try:
            data = fetch_fn()
            cache.setex(cache_key, ttl, json.dumps(data))
            return data
        finally:
            cache.delete(lock_key)
    else:
        # Another request is fetching — wait briefly and retry
        time.sleep(0.1)
        return get_with_lock(cache_key, ttl, fetch_fn)

When to Use Cache-Aside

Cache-aside is the right choice for most web applications, especially when:

  • Your workload is read-heavy (read-to-write ratio of 5:1 or higher)
  • You need resilience — the system must work even if the cache goes down
  • Different data types need different TTLs and caching strategies
  • You want to avoid caching data that is written but never read
  • You are using Redis or Memcached as a distributed cache

Frequently Asked Questions

Should I update the cache or delete it on writes?

Delete is generally preferred. Updating the cache on writes introduces race conditions when two concurrent writes occur — the slower write might overwrite the faster one's cache entry with stale data. Deleting is idempotent and safe. The exception is hot keys where the cache miss after deletion would cause a stampede — for those, consider updating with versioning or using a distributed lock.

How do I handle the cold start problem?

After a deployment or cache restart, all entries are missing. Pre-warm the cache by loading frequently accessed data before the application starts serving traffic. Query your analytics or access logs to identify the most popular keys and pre-populate them. See caching basics for cache warm-up strategies.

What if my data source is an external API, not a database?

Cache-aside works identically with external APIs. Replace the database query with an API call. This is especially valuable for third-party APIs with rate limits — the cache reduces the number of outbound API calls. Set the TTL based on how frequently the external data changes and any SLA requirements.

How does cache-aside work with microservices?

Each microservice manages its own cache-aside logic for its data. For cross-service cache invalidation, use an event-driven approach: when Service A updates data, it publishes an event to a message queue. Service B subscribes to these events and invalidates its local cache entries accordingly.

Related Articles