Skip to content
SP StackPractices
beginner

Cache-Aside Pattern

Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.

Topics: design

Cache-Aside Pattern

Overview

The Cache-Aside Pattern is a caching strategy where the application is responsible for loading data into the cache from the backing store on demand. The application checks the cache first; if the data is not present (cache miss), it fetches from the database, populates the cache, and returns the result. This gives the application full control over cache logic, invalidation, and consistency.

When to Use

Use the Cache-Aside Pattern when:

  • You need read-heavy workloads where the same data is requested frequently
  • The application should control what gets cached and for how long
  • Cache invalidation can be handled explicitly by the application layer
  • You want a simple, portable caching strategy that works with any cache provider (Redis, Memcached, in-memory)
  • Examples: user profiles, product catalogs, configuration data, reference data

Solution

Python

import time
from typing import Optional, Callable

class CacheAside:
    def __init__(self, cache: dict, ttl_seconds: float = 60):
        self.cache = cache
        self.ttl = ttl_seconds
        self.timestamps = {}

    def get(self, key: str, loader: Callable[[], any]) -> any:
        now = time.time()
        if key in self.cache:
            if now - self.timestamps.get(key, 0) < self.ttl:
                print(f"Cache hit: {key}")
                return self.cache[key]
            else:
                del self.cache[key]

        print(f"Cache miss: {key}")
        value = loader()
        self.cache[key] = value
        self.timestamps[key] = now
        return value

    def invalidate(self, key: str):
        self.cache.pop(key, None)
        self.timestamps.pop(key, None)

# Usage
cache = {}
store = CacheAside(cache)

def load_user(user_id: int) -> dict:
    # Simulate DB call
    return {"id": user_id, "name": f"User {user_id}"}

user = store.get("user:1", lambda: load_user(1))
user = store.get("user:1", lambda: load_user(1))  # Cache hit
store.invalidate("user:1")

JavaScript

class CacheAside {
  constructor(cache, ttlMs = 60000) {
    this.cache = cache;
    this.ttl = ttlMs;
    this.timestamps = new Map();
  }

  get(key, loader) {
    const now = Date.now();
    if (this.cache.has(key)) {
      if (now - (this.timestamps.get(key) || 0) < this.ttl) {
        console.log(`Cache hit: ${key}`);
        return this.cache.get(key);
      }
      this.cache.delete(key);
    }

    console.log(`Cache miss: ${key}`);
    const value = loader();
    this.cache.set(key, value);
    this.timestamps.set(key, now);
    return value;
  }

  invalidate(key) {
    this.cache.delete(key);
    this.timestamps.delete(key);
  }
}

// Usage
const cache = new Map();
const store = new CacheAside(cache);

function loadUser(userId) {
  return { id: userId, name: `User ${userId}` };
}

let user = store.get("user:1", () => loadUser(1));
user = store.get("user:1", () => loadUser(1)); // Cache hit
store.invalidate("user:1");

Java

import java.util.*;
import java.util.function.Supplier;

public class CacheAside<K, V> {
    private final Map<K, V> cache;
    private final Map<K, Long> timestamps;
    private final long ttlMs;

    public CacheAside(Map<K, V> cache, long ttlMs) {
        this.cache = cache;
        this.timestamps = new HashMap<>();
        this.ttlMs = ttlMs;
    }

    public V get(K key, Supplier<V> loader) {
        long now = System.currentTimeMillis();
        if (cache.containsKey(key)) {
            if (now - timestamps.getOrDefault(key, 0L) < ttlMs) {
                System.out.println("Cache hit: " + key);
                return cache.get(key);
            }
            cache.remove(key);
        }

        System.out.println("Cache miss: " + key);
        V value = loader.get();
        cache.put(key, value);
        timestamps.put(key, now);
        return value;
    }

    public void invalidate(K key) {
        cache.remove(key);
        timestamps.remove(key);
    }
}

// Usage
CacheAside<String, Map<String, Object>> store =
    new CacheAside<>(new HashMap<>(), 60000);

Map<String, Object> user = store.get("user:1", () ->
    Map.of("id", 1, "name", "User 1")
);

Explanation

The Cache-Aside Pattern follows this flow:

  1. Read: Application checks cache → if hit, return; if miss, proceed to step 2
  2. Load: Fetch from backing store (database, API)
  3. Store: Write the result into cache with a TTL
  4. Invalidate: On writes/updates, invalidate the cache entry so the next read refreshes it

The application is the single point of control — it decides when to read from cache, when to fall back to the store, and when to invalidate.

Variants

VariantDescriptionUse Case
Lazy LoadingCache miss triggers loadMost common; prevents unnecessary cache fills
Write-ThroughWrites update cache and DB simultaneouslyStrong consistency required
Refresh-AheadProactively refresh before TTL expiresPredictable access patterns
Multi-LevelL1 (in-memory) + L2 (Redis) + L3 (DB)High-scale applications

Best Practices

  • Always set a TTL — stale data is worse than a cache miss
  • Invalidate on writes — delete the cache key after DB updates to maintain consistency
  • Use a circuit breaker around cache failures — if Redis is down, fall back directly to DB
  • Serialize complex objects before storing (JSON, protobuf)
  • Monitor cache hit ratio — aim for >90% on read-heavy workloads
  • Pre-warm cache on startup for critical reference data

Common Mistakes

  • Forgetting to invalidate cache after DB writes, causing stale data
  • Setting TTL too long, serving outdated information
  • Not handling cache provider failures gracefully (e.g., Redis connection lost)
  • Storing too much data in cache, causing memory pressure or eviction of hot keys
  • Cache stampede: many requests hit a cold cache simultaneously, overloading the DB

Frequently Asked Questions

Q: What is the difference between Cache-Aside and Read-Through? A: In Cache-Aside, the application controls cache logic. In Read-Through, the cache provider (e.g., Redis with a cache loader) fetches from the DB transparently. Cache-Aside is more explicit and portable; Read-Through delegates control to the cache layer.

Q: How do I prevent cache stampedes? A: Use a mutex or lock per key so only one request loads from DB while others wait. Alternatively, use a probabilistic early expiration (e.g., refresh the key before TTL expires with some probability).

Q: Should I cache writes (Write-Through) or invalidate (Cache-Aside)? A: Cache-Aside invalidation is simpler and safer. Write-Through adds complexity but guarantees consistency. Use Write-Through only when strong consistency is critical and worth the overhead.