System Design

Caching Strategies: Cache-Aside, Write-Through, Write-Behind

· Pavan Y N · system-design, scalability, architecture, distributed-systems, databases

Concentric translucent rings shielding a glowing blue core while a stream of lines bounces off the outer layers
A cache is a series of layers that answer most requests before they reach the database.

A cache is a bet that the same data will be asked for again before it changes. When the bet pays off, a slow lookup becomes a fast one. When it does not, you have a second copy of the truth that can drift, expire at the wrong moment, or hide a bug for hours. EduGeek explains caching as four decisions: where copies live, who fills them, how they leave, and what you accept when they disagree with the source.

This guide covers the layers, the four read and write patterns, eviction, invalidation, stampedes and hot keys, sizing, measurement, and the cases where the right answer is no cache. It includes a tested TypeScript implementation of cache-aside with stampede protection. The same ideas drive the read path in the URL shortener walkthrough.

# Where caches sit

A single request can pass through several caches before it touches your database. Each layer trades reach for freshness control.

LayerWhat it holdsWho controls freshnessTypical failure
Browser / HTTP cacheResponses keyed by URL and headersOrigin, through Cache-ControlYou cannot purge it; you can only wait for expiry
CDN (content delivery network) / reverse proxyShared responses near the userOrigin headers plus purge APIsServing a stale or wrong variant to everyone
In-process cacheObjects in the app's own memoryThe appEvery instance holds a different copy
Distributed cache (Redis, Memcached)Objects shared by all instancesThe appBecomes a hard dependency and a hot spot
Database buffer cachePages and sometimes query resultsThe databaseInvisible until it is cold, for example after a restart

The closer a cache is to the user, the cheaper a hit is and the harder the data is to correct. A response cached in a browser for an hour is a promise you cannot retract; a value in Redis can be deleted in one command.

Four translucent gates in a row, a blue line passing through while white lines peel off and return at each gate
Each layer answers what it can; only the remainder travels on to the database.

Cache at the outermost layer that can still meet your correctness requirement. Public, identical-for-everyone content belongs at the CDN; per-user data belongs in the application tier or a distributed cache. HTTP caching (RFC 9111) is worth learning properly because it gives you two layers for free.

# The four patterns

The patterns differ in one question: who talks to the database, and when.

## Cache-aside (lazy loading)

The application owns the logic. On a read it checks the cache; on a miss it reads the database, writes the result to the cache and returns it. On a write it updates the database and then deletes the cached key (or occasionally updates it).

code
read(key):
  v = cache.get(key)
  if v is missing:
      v = db.read(key)
      cache.set(key, v, ttl)
  return v

write(key, v):
  db.write(key, v)
  cache.delete(key)

This is the default for good reason: the cache holds only data that was actually requested, and a cache outage degrades to slower reads rather than failures. The costs are a miss on the first request for each key and a read/write race, covered under invalidation. Prefer deleting on write to updating: deleting is idempotent and cannot store a value computed from stale inputs.

## Read-through

Same as cache-aside, but the cache (or a library wrapping it) loads from the database on a miss, so application code only calls cache.get. That removes duplicated loading logic and gives request coalescing a natural home, at the cost of coupling the cache layer to your data access code.

## Write-through

Every write goes to the database and the cache in one synchronous operation. Recently written keys are always warm and consistent.

code
write(key, v):
  db.write(key, v)      # source of truth first
  cache.set(key, v)     # then the cache, same request

You pay in write latency and cache pollution, since data that is written but rarely read still occupies memory. The pair is not atomic: if the cache write fails after the database write succeeds, delete the key or let a time to live (TTL) repair it. It suits read-heavy data read soon after writing, such as profiles and settings.

## Write-behind (write-back)

Writes land in the cache and are acknowledged immediately; a background process flushes them to the database later, often in batches. Repeated writes to one key coalesce into a single database write, which suits high-frequency counters and telemetry.

The price is durability. Until a flush, the cache holds the only copy, so a node failure loses those writes, and other database readers see old data. Use it only when losing a small recent window is acceptable.

Start with cache-aside. Move to another pattern only when you can name the specific problem it solves.

# Eviction: what leaves when memory runs out

A cache is smaller than the data behind it, so something has to leave. Three families matter.

  • →LRU (least recently used) evicts what has gone longest without access. It works well when recent access predicts future access, and it is the default recommendation in the Redis eviction documentation for skewed workloads. Its weakness is scans: one pass over a large cold dataset can flush the genuinely hot entries.
  • →LFU (least frequently used) evicts what is accessed least often. It protects a stable hot set from scans, but a naive implementation never lets go of keys that were popular. Redis decays its approximate frequency counter over time to address this, and W-TinyLFU, used in the Caffeine library, combines a frequency filter with a recency window.
  • →TTL is not a memory policy at all. It bounds how long a value may be wrong. Treat TTL as your correctness backstop and LRU or LFU as your memory policy, and set both.

Avoid noeviction for a cache: a full cache then returns write errors.

# Invalidation: why it is hard

An old joke, often attributed to Phil Karlton, lists cache invalidation as one of the two hard problems in computing. It is a distributed-systems problem in disguise: two stores, no transaction across them, concurrent actors. The standard cache-aside race:

  1. •Reader A misses on key k and reads the old value from the database.
  2. •Writer B updates the database and deletes k from the cache.
  3. •Reader A, still holding the old value, writes it to the cache.

The cache now holds stale data until the TTL expires. The window is small, but at high volume small windows are hit daily. Mitigations, simplest first:

  • →Always set a TTL, so a bad entry has a bounded life.
  • →Delete after the write commits, not before. Deleting first widens the window.
  • →Version your values. Store a version or timestamp from the database row and refuse to overwrite a newer cache entry with an older one. Compare-and-set on Redis (a small Lua script or WATCH) makes this atomic.
  • →Invalidate from the change log. Publishing invalidations from the database's change stream (change data capture) removes the dependence on every writer remembering to delete.
  • →Leases. Facebook's memcache deployment, described in Scaling Memcache at Facebook, hands the missing client a token so that a set with an outdated token is rejected, which addresses both stale sets and herds.

Derived data is the other trap: if a cached page was built from five rows and one changes, the key does not say which entry to drop. Cache at the granularity of what changes, and use short TTLs for aggregates.

# Stampedes and thundering herds

When a popular key expires, every request that arrives before the first reload finishes also misses, and each one goes to the database. For example, a key read 5,000 times per second whose recomputation takes 200 ms will see about 5,000 × 0.2 = 1,000 concurrent misses for a single expiry. If the query is expensive, the database slows, reloads take longer, more requests pile up, and the system cannot recover on its own.

A crowd of thin lines narrowing through a single funnel into one blue line toward a database cube
Request coalescing collapses many concurrent misses into one database load.

Three mitigations compose well.

Request coalescing (single-flight). Within a process, keep a map from key to the in-flight load. The first miss starts the load; later misses for the same key await the same promise. Across processes, you either accept up to one load per instance per expiry or add a short distributed lock (for example SET key NX PX) so one instance refreshes while others serve stale data or wait.

Jittered TTLs. Keys written together with a 300-second TTL during a deploy or warm-up all expire together. Random spread, say plus or minus 10 percent, turns that cliff into a slope.

Stale-while-revalidate. Keep serving the expired value for a bounded extra window while one request refreshes it in the background. Users see no latency spike, and the database sees one query. The same idea exists in HTTP as the stale-while-revalidate and stale-if-error extensions in RFC 5861, supported by many CDNs.

Related: cache penetration. Requests for keys that do not exist always reach the database. Cache the "not found" answer with a short TTL.

## A working implementation

The class below implements cache-aside with all three mitigations: single-flight per process, jittered fresh windows, and a stale window served while a background refresh runs. It is written against a minimal Store interface so Redis, Memcached or an in-memory map can sit behind it.

code
export interface Store {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlMs: number): Promise<void>;
  del(key: string): Promise<void>;
}

interface Options {
  freshMs: number;  // serve straight from cache for this long
  staleMs: number;  // after that, serve stale while refreshing in the background
  jitter: number;   // 0.1 means the fresh window varies by +/-10%
}

interface Envelope<V> {
  v: V;
  freshUntil: number; // epoch ms
}

export class CachedLoader<V> {
  private inflight = new Map<string, Promise<V>>();

  constructor(
    private readonly store: Store,
    private readonly load: (key: string) => Promise<V>,
    private readonly opts: Options,
  ) {}

  async get(key: string): Promise<V> {
    const raw = await this.store.get(key);
    if (raw === null) return this.refresh(key); // miss: one caller loads, the rest wait

    const entry = JSON.parse(raw) as Envelope<V>;
    if (Date.now() >= entry.freshUntil) {
      // Stale but still usable: answer now, refresh behind the response.
      this.refresh(key).catch((err) => console.error("background refresh failed", key, err));
    }
    return entry.v;
  }

  async invalidate(key: string): Promise<void> {
    await this.store.del(key);
  }

  private refresh(key: string): Promise<V> {
    const existing = this.inflight.get(key);
    if (existing) return existing; // request coalescing

    const promise = (async () => {
      const v = await this.load(key);
      const spread = 1 + (Math.random() * 2 - 1) * this.opts.jitter;
      const freshMs = Math.round(this.opts.freshMs * spread);
      const entry: Envelope<V> = { v, freshUntil: Date.now() + freshMs };
      await this.store.set(key, JSON.stringify(entry), freshMs + this.opts.staleMs);
      return v;
    })().finally(() => this.inflight.delete(key));

    this.inflight.set(key, promise);
    return promise;
  }
}

A test harness confirmed that 100 concurrent cold reads cause one load, fresh reads cause none, 50 concurrent reads after expiry return the old value immediately and trigger one background reload, invalidate forces a reload, and a failed load clears the in-flight entry so the next call retries.

Its limits: the in-flight map is per process, so N instances can still produce up to N loads per expiry. invalidate can lose a race with a refresh that started before it, so keep the TTL as a backstop or add versioning. And decide deliberately how to represent "not found".

# Consistency trade-offs

Every cache read answers "how wrong am I allowed to be?" Decide per data type.

  • →Bounded staleness: a 60-second TTL means readers may see data up to 60 seconds old. Fine for listings, view counts and feeds.
  • →Read-your-writes: a user who just changed their display name must see it. Refresh or bypass the cache for that user's own key after a write, or use write-through on that path.
  • →Strong consistency: balances, checkout inventory, permission revocation. Read the source of truth.

Multiple regions compound this: invalidations reach other regions after a delay, so the same key can return different values depending on where the request lands.

# Hot keys and sizing

A distributed cache spreads keys across nodes, not traffic to one key. In Redis Cluster, a key maps to one of 16,384 hash slots, and each slot is served by one primary, so a single celebrity key can saturate one node while the rest sit idle. Typical fixes:

  • →Put a small in-process cache with a short TTL in front, so each instance asks Redis at most once per window.
  • →Replicate the hot value under several keys (k#0 to k#7) and have each reader pick one at random.
  • →Keep global, rarely changing values (flags, config) in application memory with periodic refresh.

For sizing, start from the working set, not the dataset. If 10 million objects are in active use at an average of 1 KB each, that is roughly 10 GB of payload before per-key overhead, replication and fragmentation, so budget meaningfully above it. Access is usually skewed, so a cache holding a fraction of the dataset can reach a high hit ratio, but the curve flattens: the last few points cost disproportionate memory. Find the knee by varying size while watching hit ratio and eviction rate.

# What to measure

A cache you do not measure is a cache you cannot trust. Track these:

  • →Hit ratio, per keyspace. A global number hides a cold keyspace behind a hot one. In Redis, start with keyspace_hits / (keyspace_hits + keyspace_misses).
  • →Latency of hits and misses separately, at p50 and p99. With an assumed 1 ms hit and 20 ms database read, average latency at hit ratio h is about 1 + (1 − h) × 20 ms: 3 ms at 90 percent, 1.2 ms at 99 percent. The bigger effect is on the database: at 10,000 requests per second, 90 percent sends 1,000 queries per second to the origin and 99 percent sends 100. Nine points of hit ratio cut origin load ten-fold.
  • →Eviction rate. Evictions climbing while hit ratio falls means the cache is too small or the policy is wrong.
  • →Origin load and concurrent loads per key, the stampede alarm.
  • →Entry age at read time, if you serve stale data.

These fit naturally into the metrics pipeline described in the Prometheus monitoring guide.

# When not to cache

Caching is an optimization with a maintenance bill. Skip it, or delay it, when:

  • →The data is read once or rarely. If most keys are requested a single time, the hit ratio will be low and you pay memory and complexity for nothing.
  • →The query is already cheap. A network hop to Redis can cost as much as a fast indexed lookup.
  • →The data must be exactly current. Balances, stock counts at checkout and authorization decisions are better served by a faster or better-indexed source of truth.
  • →You have not found the real bottleneck. A missing index or an N+1 query is cheaper to fix than to hide.
  • →The data is per-user and the cache is shared. A key that forgets the user ID leaks data between accounts, which is a security incident, not a performance bug.
  • →A cold cache would overload the database. The cache has then become a hard dependency, and you need warm-up or capacity for the cold case. Failover in a multi-zone design, as in the AWS multi-AZ architecture guide, often lands on a cold cache.

# Key takeaways

  • →Cache at the outermost layer that still meets your correctness requirement; copies nearest the user are the hardest to correct.
  • →Default to cache-aside with delete-on-write. Use write-through when read-after-write matters and write-behind only when losing recent writes is acceptable.
  • →Pair a memory policy (LRU or LFU) with a TTL; the TTL is the safety net for invalidation bugs.
  • →The cache-aside read/write race is real. Mitigate it with TTLs, versioning, change-log invalidation or leases.
  • →Prevent stampedes with request coalescing, jittered TTLs and stale-while-revalidate, and cache negative results. A distributed cache does not protect you from one hot key, so add a local tier or replicate the key.
  • →Measure hit ratio per keyspace, hit and miss latency, eviction rate and origin load.
  • →Sometimes the right answer is no cache: fix the query or the index first.

# Further reading

All posts