System DesignBeginner8 min readPart 3 of 14

Caching Strategies

Caching keeps frequently used data in fast storage (memory) so you don't pay for a slow database read every time. A good cache can cut response times from 100ms to under a millisecond and take 90% or more of the load off the database.

Can you explain caching strategies out loud?

I'm Alex. Read this, then I'll interview you on a design problem and push on the parts you skip.

Why caching mattersCore idea

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
graph LR
  Client["Client"] --> App["Application"]
  App -->|"1. check"| Cache["Cache (Redis)<br/>~1 ms"]
  App -->|"2. on a miss"| DB[("Database<br/>~100 ms")]
  DB -->|"3. fill the cache"| Cache
  class Client blue
  class App orange
  class Cache green
  class DB violet
classDef orange fill:#ffd8a8,stroke:#1e1e1e,color:#1e1e1e
classDef blue fill:#a5d8ff,stroke:#1e1e1e,color:#1e1e1e
classDef green fill:#b2f2bb,stroke:#1e1e1e,color:#1e1e1e
classDef red fill:#ffc9c9,stroke:#1e1e1e,color:#1e1e1e
classDef violet fill:#d0bfff,stroke:#1e1e1e,color:#1e1e1e
classDef yellow fill:#ffec99,stroke:#1e1e1e,color:#1e1e1e
classDef grey fill:#e9ecef,stroke:#1e1e1e,color:#1e1e1e
DiagramCheck the cache first. Only on a miss do you pay for the database, and then you fill the cache for next time.

Lower latency

A Redis read takes about 1ms; a database query about 100ms.

Higher throughput

One cache node handles 100K+ reads/sec; a database handles 1K–10K.

Lower cost

Fewer database queries means smaller database machines and lower cloud bills.

Cache-aside (lazy loading)Pattern

The most common pattern. The application itself reads from and writes to the cache.

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
sequenceDiagram
  participant App as Application
  participant Cache as Redis Cache
  participant DB as Database
  App->>Cache: GET user:123
  alt Cache hit
    Cache-->>App: user data
  else Cache miss
    App->>DB: SELECT * FROM users WHERE id=123
    DB-->>App: user data
    App->>Cache: SET user:123 (TTL 1 hr)
  end
SequenceCache-aside: the app checks the cache, and fills it only after a miss.
Cache-aside readTypeScript
function getUser(id: string) {
  // 1. Check the cache
  let user = cache.get(`user:${id}`);
  if (user) return user;
  // 2. Cache miss: read from the database
  user = db.query("SELECT * FROM users WHERE id = ?", [id]);
  // 3. Fill the cache with a TTL
  cache.set(`user:${id}`, user, { ttl: 3600 });
  return user;
}

Pros

Only data someone asked for gets cached. If the cache fails, the app still works by going to the database.

Cons

A miss adds latency. After a restart the cache is empty (“cold”), so a flood of reads hits the database.

Write-throughPattern

The application writes to the cache and the database at the same time, so the cache is always up to date.

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
sequenceDiagram
  participant App as Application
  participant Cache as Redis Cache
  participant DB as Database
  App->>DB: UPDATE users SET name="John" WHERE id=123
  App->>Cache: SET user:123 {name: "John"}
  Note over Cache,DB: Both writes happen synchronously
SequenceWrite-through: both writes happen before the app answers.
Write-through updateTypeScript
function updateUser(id: string, data: UserUpdate) {
  db.update("UPDATE users SET ... WHERE id = ?", [id, data]);
  cache.set(`user:${id}`, data, { ttl: 3600 });
}

Pros

The cache is always consistent, so reads never miss.

Cons

Writes are slower (two of them). Data nobody reads still takes cache memory.

Write-behind (write-back)Pattern

The application writes only to the cache. A background process copies the changes to the database later, which makes writes very fast.

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
sequenceDiagram
  participant App as Application
  participant Cache as Redis Cache
  participant Queue as Background queue
  participant DB as Database
  App->>Cache: SET user:123 {name: "John"}
  Cache->>Queue: queue the DB write
  Queue->>DB: batch write (async)
  Note over Queue,DB: happens in the background
SequenceWrite-behind: the user gets an answer after the cache write; the database catches up in the background.
Write-behind updateTypeScript
function updateUser(id: string, data: UserUpdate) {
  cache.set(`user:${id}`, data, { ttl: 3600 });
  // A background job writes to the database
  queue.push({ type: "db-write", id, data });
}

Pros

Extremely fast writes, and many updates can be batched into one.

Cons

If the cache crashes before flushing, data is lost. The background processing adds complexity.

Cache invalidationHow it works

Often called the hardest problem in computer science. When data changes, you must remove (invalidate) the cached copy, or users will see stale data.

Time-based (TTL)simple

Each entry expires after a set time. Easy, but data can be stale until it expires.

Event-basedimmediate

Listen to database change events (change data capture, CDC) and delete the cache key right away.

Version-basedno deletes

Put a version in the key, like user:123:v3. When data changes, bump the version; old keys are simply never read again.

The thundering herd problemScale

When a popular key expires, thousands of requests miss at the same moment and all hit the database. The cache was absorbing that traffic; now the database gets all of it at once.

%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","actorBkg":"#e7f5ff","actorBorder":"#1e1e1e","noteBkgColor":"#fff9db","noteBorderColor":"#1e1e1e"}}}%%
graph LR
  E["popular key expires"] --> H["1000s of requests<br/>miss together"]
  H --> O["database overloaded"]
  O --> F{"fix"}
  F --> M["mutex: one request<br/>refills the cache"]
  F --> S["stale-while-revalidate:<br/>serve old, refresh async"]
  F --> P["pre-warm before<br/>it expires"]
  class E yellow
  class H,O red
  class M,S,P green
classDef orange fill:#ffd8a8,stroke:#1e1e1e,color:#1e1e1e
classDef blue fill:#a5d8ff,stroke:#1e1e1e,color:#1e1e1e
classDef green fill:#b2f2bb,stroke:#1e1e1e,color:#1e1e1e
classDef red fill:#ffc9c9,stroke:#1e1e1e,color:#1e1e1e
classDef violet fill:#d0bfff,stroke:#1e1e1e,color:#1e1e1e
classDef yellow fill:#ffec99,stroke:#1e1e1e,color:#1e1e1e
classDef grey fill:#e9ecef,stroke:#1e1e1e,color:#1e1e1e
DiagramOne expiry can overload the database. The fixes all make sure only one request reloads the key.
Mutex lockOnly one request refills the cache; the others wait or get the old value.
Stale-while-revalidateKeep serving the old value while refreshing it in the background.
Pre-warmingRefresh the key before it expires, for example at 80% of its TTL.

Redis vs MemcachedCompare

FeatureRedisMemcached
Data structuresLists, sets, sorted sets, hashesSimple key-value only
PersistenceYes (RDB + AOF)No
ThreadingSingle-threaded (event loop)Multi-threaded
Use casesLeaderboards, sessions, rate limiting, pub/subSimple object caching, HTML fragments

Common interview mistakesPitfalls

No invalidation planStale data can be worse than no cache. Always say how entries get updated or removed.
Ignoring the thundering herdUse a mutex or stale-while-revalidate for hot keys.
Caching everythingCache hot data only. Cold data wastes memory and adds complexity.
No size limitSet a max memory and an eviction policy (LRU: least recently used, LFU: least frequently used).
Not watching the hit rateBelow about 80%, the caching strategy needs work.

SummarySummary

StrategyWrite pathRead pathBest for
Cache-asideWrite to DB, invalidate cacheCheck cache → DB on missMost workloads
Write-throughWrite to both cache and DBRead from cacheConsistent reads
Write-behindWrite to cache, DB laterRead from cacheWrite-heavy workloads

Frequently asked questionsFAQ

What is the difference between cache-aside and write-through?

Cache-aside loads data into the cache only on a read miss and writes to the database, so the cache can go stale and needs invalidation. Write-through updates the cache and database together on every write, keeping them consistent but adding write latency and storing possibly-unread data.

What happens to a cache when the database is updated?

The system must invalidate or refresh the cached copy. Common options are deleting the key on write so the next read repopulates it, setting a short TTL to bound staleness, or versioning keys. The dangerous pattern is updating the cache directly on writes, which drifts from the database.

When does caching hurt rather than help?

Caching helps reads of hot, relatively static data and hurts when data churns constantly, when every read repopulates a cold cache, or when a thundering herd collapses on a missing key. The overhead usually outpaces the win when cache hit rates stay below roughly 80%.

Now explain caching strategies to me out loud

I'll give you a design problem and push on the trade-offs, like a real interviewer.

Practice with Alex

Keep readingKeep reading

Ready to practice?

Reading is step one. Saying it out loud is step two.

Start a mock interview with Alex, get pushed on the parts you skip, and see your hiring signal right after.