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.
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.
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.
Mental model
A chef digging through the pantry for every dish
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.
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.
Key commands
docker run -d -p 6379:6379 --name redis redis:7-alpineStart a Redis instance on the default port for local testing.
Running container redis with ID 4f3e...
redis-cli GET user:42Read the cached value for a key.
{"id":42,"email":"ada@example.com"}redis-cli SET user:42 '{"id":42}' EX 300Write a value with a 300-second TTL.
OK
redis-cli TTL user:42Show remaining seconds before the key expires.
243
redis-cli DEL user:42Explicitly invalidate a key on write.
(integer) 1
redis-cli INFO statsCheck keyspace_hits vs keyspace_misses to compute the hit ratio.
keyspace_hits:94123\nkeyspace_misses:1840
Examples
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.
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.
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.
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.
Common issues
p95 latency explodes every few minutes, then recoversMany keys with identical TTLs expire at once, triggering a stampede of database reloads.
Add random jitter to TTLs and use single-flight so only one request reloads a given key.
Users see stale profiles after updatesThe write path updates the database but never deletes or refreshes the cache key.
Invalidate (DEL) the key on every write, or bump a version in the key name on schema/data changes.
Redis latency climbs and evictions appear in INFO statsThe working set exceeds maxmemory, or one huge key is read on every request.
Raise maxmemory and pick an appropriate eviction policy, or split big keys with redis-cli --bigkeys.
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.
Interview checkpoint
Hands-on lab
- 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
Quick quiz
In cache-aside, what happens on a cache miss?
A TTL guarantees that cached data is always consistent with the database.
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?
Cheat sheet
Cache-asideRead cache, miss -> load DB + populateWrite-throughUpdate cache and DB on every writeWrite-backUpdate cache, flush to DB asyncWrite-aroundWrite to DB only; invalidate cacheRead-throughCache library loads DB transparentlySET key val EX <sec>Write with a TTLGET / DEL keyRead / invalidateTTL keyRemaining lifetimeINCR keyAtomic counter (rate limits)INFO statshit ratio and eviction stats--bigkeysFind oversized values