Skip to content
aipatterns.dev

Design patternData Patterns

Cache-Aside

Read through a cache: on a miss, load from the store and fill it; on a write, save to the store and evict the entry - so reads speed up while the store stays the only source of truth.

Complexity
Low
Seen in the wild
High
Also known as
Lazy Loading, Look-Aside Cache

Intent

Keep a cache beside the store of record, not inside it. On a read, check the cache first; on a miss, load from the store, put the value in the cache, and return it. On a write, save to the store and drop the cache entry, so the next read reloads the fresh value. The cache is an accelerator that can be emptied at any time; the store remains the only source of truth.

Forces

  • Read speed vs write cost: every cached read saves a query; every write now has a second thing to keep honest.
  • Staleness vs consistency: a cached answer can be out of date until its entry expires or is evicted.
  • Eager filling vs wasted work: pre-warming the cache with everything costs memory and effort for values that may never be read again.
  • Two copies vs one truth: once the cache holds data, someone will eventually ask which copy is right.

Therefore

On a miss, load from the store and fill the cache; on a write, update the store and evict - never update both and hope they stay in step.

The application code owns the coordination: check the cache, fall back to the store, fill on the way out. The cache is disposable by construction - empty it and the system is slower, never wrong. Writes go to the store alone and invalidate the entry, because the alternative (writing to both) turns every failure between the two into silent disagreement.

Structure

Diagram source (Mermaid)
classDiagram
  class PricingService {
    -Cache cache
    -PricingStore store
    +priceFor(sku) Price
    +reprice(sku, cents)
  }
  class Cache {
    +get(key) Price
    +put(key, price)
    +evict(key)
  }
  class PricingStore {
    +load(sku) Price
    +save(sku, cents)
  }
  PricingService --> Cache : reads, fills, evicts
  PricingService --> PricingStore : loads on miss, saves on write

Participants

  • Coordinator (PricingService): the application code the pattern lives in. Implements the read path (cache, then store, then fill) and the write path (store, then evict).
  • Cache (Cache): a keyed, expirable store - Redis, Memcached, or an in-process map. Holds entries with a TTL; losing everything is acceptable.
  • Store (PricingStore): the source of truth - the database. Every fact in the cache can be regenerated from it.
  • Clock (now): injected so TTL expiry is testable without sleeping.

Use it when

  • Reads dominate writes on data that is expensive to compute or far away: product pages, user profiles, configuration, an LLM response keyed by prompt hash.
  • Slightly stale answers are acceptable within a bounded window - a price that is at most sixty seconds old.
  • The working set is bigger than the cache, so pre-warming everything would waste memory; entries should earn their place by being read.
  • You already have a cache (Redis, a CDN, an in-process map) and a store, and need the rule that keeps them from drifting apart.

Do not use it when

  • The read must reflect a write made moments ago in the same session - showing a user their just-saved settings from a stale cache is a bug. Bypass the cache for that read, or key the cache per session.
  • The value must always be current for correctness - stock levels at checkout, an account balance before a debit. Read the store; cache only immutable or slowly changing derivatives.
  • Writes are as frequent as reads. Every write pays an eviction and the hit rate never covers it; write to the store alone.
  • The underlying query is already fast, measured. A cache adds a second source of truth and an invalidation surface to save milliseconds you have not proven you need.
  • You are in one process with the data already in memory. A map beside your map is not a cache layer, it is a bug incubator; reach for this when the store is remote or the computation is expensive.

Application grill

Inspect first: the read paths the pattern would accelerate and what they cost today (a database hit per request, N+1 loops, remote calls); whether a cache already exists - search for redis, ioredis, memcached, lru-cache, node-cache, framework caches (unstable_cache), Cache-Control headers, and in-process maps near the store; the deploy target and instance count, which decide whether one per-process cache is enough.

Missing pieces: with no cache in the codebase, the pattern needs one. An in-process LRU (a size-capped map) is free to run but per-instance: two instances cache twice and invalidate independently. Managed Redis (Upstash, ElastiCache, Memorystore, or the platform's Redis/KV binding) is shared across instances and adds a service to pay for, secure and monitor. A CDN may already cache whole responses for anonymous traffic - check before adding an application cache.

Ask before you build:

  • Scale: how many reads per second land on the hot path, and how large is the working set that would live in the cache?
  • Performance: what latency is being bought, and how stale may an answer be (the TTL)?
  • Reliability: is "slower, never wrong" the agreed failure mode when the cache is down, and can the store survive a hot-key stampede on expiry?
  • Budget: pay for a managed cache now, or start in-process and accept per-instance scope?
  • Implementation: who owns invalidation on writes, and which single read path becomes the coordinator?

Implementation steps

  1. Name the cache key precisely: the query and its parameters (price:{sku}), not the entity (product:{id}), when different queries need different shapes.
  2. Write the read path in one place: get from cache; on miss, load from the store, put with a TTL, return. Every reader goes through it - a second read path is an uncacheable read path.
  3. Write the write path in the same place: save to the store, then evict the key. Evict, do not overwrite - the store's write is the fact; the cache entry is only a speedup.
  4. Give every entry a TTL even though writes evict, so bugs, rollbacks and manual edits age out instead of living forever.
  5. Decide the miss storm policy before you need it: when a hot key expires, many readers miss at once. Fill under a per-key lock, or accept the thundering herd because the store can take it.
  6. Measure the hit rate. A cache below roughly half hits is cost without payoff; reconsider the key or the TTL.
  7. Log evictions and misses at the key level while integrating, then drop to metrics - the first production bug in every cache is a wrong key.

Example

A storefront reads prices on every page view and reprices a few times a day. The first read misses and loads; the second is served from cache; a reprice saves to the store and evicts, so the next read reloads the fresh value; after the TTL expires, even an unrepriced entry reloads.

type Price = { cents: number };

interface PricingStore {
  load(sku: string): Price;
  save(sku: string, cents: number): void;
}

/** The store of record: counts loads so the demo can prove caching happened. */
class SlowDatabase implements PricingStore {
  loads = 0;
  private prices = new Map<string, number>([["tea-1", 450]]);

  load(sku: string): Price {
    this.loads++;
    return { cents: this.prices.get(sku) ?? 0 };
  }
  save(sku: string, cents: number): void {
    this.prices.set(sku, cents);
  }
}

class Cache {
  private entries = new Map<string, { value: Price; at: number }>();
  constructor(
    private readonly ttlMs: number,
    private readonly now: () => number,
  ) {}

  get(key: string): Price | undefined {
    const e = this.entries.get(key);
    if (!e) return undefined;
    if (this.now() - e.at >= this.ttlMs) {
      this.entries.delete(key); // expired: a miss, not a stale answer
      return undefined;
    }
    return e.value;
  }
  put(key: string, value: Price): void {
    this.entries.set(key, { value, at: this.now() });
  }
  evict(key: string): void {
    this.entries.delete(key);
  }
}

class PricingService {
  constructor(
    private readonly store: PricingStore,
    private readonly cache: Cache,
  ) {}

  priceFor(sku: string): Price {
    const key = `price:${sku}`;
    const hit = this.cache.get(key);
    if (hit) return hit; // fast path
    const price = this.store.load(sku); // miss: the store is the truth
    this.cache.put(key, price); // fill on the way out
    return price;
  }

  reprice(sku: string, cents: number): void {
    this.store.save(sku, cents); // write the fact
    this.cache.evict(`price:${sku}`); // drop the stale copy, never overwrite it
  }
}

const store = new SlowDatabase();
let clock = 0;
const service = new PricingService(store, new Cache(1_000, () => clock));

service.priceFor("tea-1"); // miss -> load 1
service.priceFor("tea-1"); // hit
console.log(`loads after two reads: ${store.loads}`); // 1

service.reprice("tea-1", 475); // save + evict
console.log(service.priceFor("tea-1").cents); // 475, load 2: fresh, not overwritten

clock += 2_000; // TTL (1 s) has passed
service.priceFor("tea-1"); // expired -> load 3
console.log(`loads after expiry: ${store.loads}`); // 3

Under concurrency the same rule holds with one addition: the write path saves and evicts as one logical step, and a read that filled the cache before the eviction landed may briefly serve the old value - that is the staleness window the TTL bounds, not a bug to chase.

Trade-offs

Gains

  • Reads stop paying the store's cost; the cache absorbs the hot path.
  • The store stays the only source of truth - empty the cache and nothing is wrong, just slow.
  • Failures degrade in the safe direction: a cache outage means slower reads, never wrong data.

Costs

  • Answers are stale by up to the TTL; read-your-writes needs a bypass or per-session keys.
  • Every write now has two steps to keep ordered, and the invalidation is the part everyone forgets.
  • Hot-key expiry can stampede the store; per-key fill locking adds real complexity.
  • A second datastore to operate, monitor and evict.

Relations

  • Proxy: the coordinator exposes the store's read interface and adds caching in front - the same shape, but the pattern's point is the invalidation rule, not the wrapping.
  • Fallback: a stale cached answer served during a store outage is the most useful fallback there is - the cache you already have doubles as the degraded mode.
  • Strategy: eviction and TTL policies vary (LRU, LFU, per-key TTL); swap them without touching the read path.
  • Flyweight: both avoid repeating expensive work by sharing, but flyweight shares objects inside one process to save memory, while cache-aside shares computed answers across processes to save loads.

Agent checklist

  • Reads and writes go through one coordinator each; no second read path skips the cache.
  • Writes save to the store and evict; nothing overwrites a cache entry directly.
  • Every entry has a TTL, and expiry is a miss rather than a stale serve.
  • The cache key covers everything the value depends on.
  • The hot-key expiry path is decided (lock or accept), and the hit rate is measured.

See also

Original writing and examples. Topic coverage follows Microsoft Azure Architecture Center, which explains this topic with its own illustrations.