System Design
Intermediate 15 min read System Design

Caching Strategies

Caching stores the results of expensive work close to the consumer so repeated reads never touch the database. Cache-aside is the workhorse pattern, with write-through and write-back as alternatives, and TTLs or explicit invalidation controlling staleness. A cache only helps if the hit ratio is high and the hot keys are managed well.

cachecache-asidewrite-throughwrite-backTTLinvalidationcache stampederedishit ratio
Introduction

What is this?

A cache is a fast, usually in-memory store that holds copies of data or computed results so frequent reads avoid a slow trip to the database. Cache-aside — check the cache, on a miss load from the database and populate the cache — is the most common pattern; write-through and write-back move the cache from the read path into the write path. TTLs, versioned keys and explicit invalidation trade staleness against performance, and a well-tuned cache is what keeps a read-heavy service fast under load.

Motivation

Why do you need it?

  • Reads dominate most workloads: 90% reads / 10% writes is typical, and repeated reads are pure waste at the database.
  • An in-memory lookup is microseconds; a database round trip is tens of milliseconds — caching cuts p95 latency by an order of magnitude.
  • Caching offloads the database, so you can serve many more readers with the same number of database instances.
  • Distributed caches (Redis, Memcached) scale horizontally and survive instance restarts, unlike in-process caches.
  • A cache is the cheapest and simplest way to absorb traffic spikes before they become a scaling problem.
Think about it

Mental model

Imagine

A chef digging through the pantry for every dish

Think of

Recipe and ingredient cards kept at the work station

The chef checks the cards (cache) before walking to the pantry (database). Popular cards stay in front and are refreshed frequently; rarely used cards expire off the board. If a recipe changes, the chef tears the old card down (invalidation) so the next dish is correct.

Mechanics

How it works

  • A read request first checks the cache for a key; a hit returns the value instantly without touching the database.
  • On a miss, the application queries the database, writes the result into the cache with a TTL, and returns it.
  • A TTL (time-to-live) bounds staleness: expired keys are evicted and re-fetched, so the cache self-heals.
  • Writes either invalidate the cache (delete the key), update it synchronously (write-through) or update it async (write-back).
  • Key design and eviction policies (LRU, LFU, maxmemory) keep the working set hot and the memory bounded.

Cache-aside: fast path on a hit, slow path on a miss, then the cache is populated.

Client request
Cache lookup
Cache hit?
Cache miss
Populate cache
Return response
Reference

Key commands

docker run -d -p 6379:6379 --name redis redis:7-alpine

Start a Redis instance on the default port for local testing.

Running container redis with ID 4f3e...
redis-cli GET user:42

Read the cached value for a key.

{"id":42,"email":"ada@example.com"}
redis-cli SET user:42 '{"id":42}' EX 300

Write a value with a 300-second TTL.

OK
redis-cli TTL user:42

Show remaining seconds before the key expires.

243
redis-cli DEL user:42

Explicitly invalidate a key on write.

(integer) 1
redis-cli INFO stats

Check keyspace_hits vs keyspace_misses to compute the hit ratio.

keyspace_hits:94123\nkeyspace_misses:1840
Hands-on

Examples

Cache-aside for a user profile in Node.js with Redis

Cache-aside for a user profile in Node.js with Redis
const redis = require("redis");
const client = redis.createClient({ url: process.env.REDIS_URL });
client.connect();
async function getUser(id) {
const cached = await client.get("user:" + id);
if (cached) return JSON.parse(cached); // cache hit
const user = await db.users.findById(id); // cache miss
if (user) {
await client.setEx("user:" + id, 300, JSON.stringify(user));
}
return user;
}
async function updateUser(id, patch) {
const user = await db.users.update(id, patch); // write to source of truth
await client.del("user:" + id); // invalidate, do not write stale
return user;
}

getUser is cache-aside: the Redis key 'user:<id>' is read first; on a miss the row is loaded from the database and stored with a 300-second TTL. updateUser writes to the database and deletes the cache key instead of writing it, so the next read repopulates fresh data. This invalidation-on-write approach avoids the classic stale-cache bug where a cache write races with a database update.

Real world

In production

  • Session storage in Redis keeps every instance stateless, since the session lives in shared memory instead of a process.
  • Rate limiting uses Redis atomic INCR with a TTL as a cheap, fast, distributed counter at the API gateway.
  • Read models and denormalized query results (leaderboards, feed timelines) are cached to serve thousands of reads per second without touching the database.
  • Gateway and CDN caches (Varnish, nginx proxy_cache) cache full HTTP responses for anonymous traffic, cutting origin load to near zero.
Guidelines

Best practices

  • Use cache-aside with a short TTL as the default: it is simple, correct and self-healing on failures.
  • Version cache keys (user:v1:42) so schema changes invalidate old formats instead of corrupting reads.
  • Protect hot keys from stampedes with single-flight requests or a lock so only one process reloads a key.
  • Add jitter to TTLs so a mass expiry does not reload every key at the same instant.
  • Monitor the hit ratio and eviction count, and set maxmemory with an LRU policy so the cache cannot grow unbounded.
Watch out

Common mistakes

  • Caching without a TTL and never invalidating, which serves stale data and grows memory without bound.
  • Caching write-heavy data, so every write does cache work for data that is read once, if at all.
  • Serializing objects inconsistently — JSON on write, different shape on read — producing cache-corruption bugs.
  • A cache stampede: a hot key expires under load, and 1,000 concurrent requests all hit the database at once.
Root cause analysis

Common issues

Error
p95 latency explodes every few minutes, then recovers
Root cause

Many keys with identical TTLs expire at once, triggering a stampede of database reloads.

Fix

Add random jitter to TTLs and use single-flight so only one request reloads a given key.

Error
Users see stale profiles after updates
Root cause

The write path updates the database but never deletes or refreshes the cache key.

Fix

Invalidate (DEL) the key on every write, or bump a version in the key name on schema/data changes.

Error
Redis latency climbs and evictions appear in INFO stats
Root cause

The working set exceeds maxmemory, or one huge key is read on every request.

Fix

Raise maxmemory and pick an appropriate eviction policy, or split big keys with redis-cli --bigkeys.

When things break

Debugging tips

  • Compute the hit ratio from redis INFO stats: keyspace_hits / (hits + misses); a ratio under 0.9 usually means poor key design.
  • Use redis-cli --bigkeys to find oversized values that inflate memory and slow every operation.
  • Watch expired_keys and evicted_keys to see whether TTLs or memory pressure are churning the cache.
  • Reproduce a miss pattern by tracing a key prefix through the app logs and confirming which reads populate it.
Interviews

Interview checkpoint

Practice

Hands-on lab

Cache-Aside with Redis
40 min · intermediate
In progress
Objectives
  • Run Redis and a Node API that caches profile reads
  • Implement cache-aside with a TTL
  • Verify cache hits and misses through Redis stats
  • Invalidate the cache on write and prove freshness

Cache-Aside with Redis

40 min · intermediate

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

In cache-aside, what happens on a cache miss?

2

A TTL guarantees that cached data is always consistent with the database.

3

A product page is read 50x more than it is written, and you need the freshest price within seconds of an update. Which approach fits best?

Quick reference

Cheat sheet

Cache patterns
Cache-asideRead cache, miss -> load DB + populate
Write-throughUpdate cache and DB on every write
Write-backUpdate cache, flush to DB async
Write-aroundWrite to DB only; invalidate cache
Read-throughCache library loads DB transparently
redis-cli essentials
SET key val EX <sec>Write with a TTL
GET / DEL keyRead / invalidate
TTL keyRemaining lifetime
INCR keyAtomic counter (rate limits)
INFO statshit ratio and eviction stats
--bigkeysFind oversized values
Go deeper

Official documentation