Caching strategies
Thursday, 18:40: hotel pages send 3,000 reads a second to PostgreSQL and its CPU sits at 90%. Omar checks a sample: 95% of those reads return exactly what they returned a second ago.
A cache can make a page 40 times faster. It can also show guests a room that's already sold. Here's how to get the first without the second.
- Problem
The hotel page reads prices and availability from PostgreSQL on every view. At 3,000 views per second the database runs at 90% CPU, and 95% of those reads return exactly what they returned a second ago.
- Why the simple system fails
Adding app servers sends even more identical queries to the same database. Read replicas help but cost a full database each, and every read still pays a network round trip plus a query.
- Engineering concept
Keep a copy of hot, expensive-to-compute data closer to the reader, and decide explicitly how the copy is filled, how writes update it, and how stale it may get.
- Technology
Browser cache and CDN (Cache-Control headers), in-process caches, Redis or Memcached with cache-aside / read-through / write-through / write-back, TTLs, invalidation on write, request coalescing.
- Trade-offs
Every cache trades freshness and simplicity for speed. You now have two copies that can disagree, a new failure mode (cold or crashed cache), and stampedes when hot keys expire.
The problem
2 min- Omar · On-call SREPostgres CPU 90%. Hotel pages send 3,000 reads a second. In a sample, 95% return exactly what the same read returned a second ago.
- Karim · CTOA bigger database is €2,000 a month more. Anything cheaper?
- Lina · Senior engineer, your mentorYou lead this one. I'll check your answers.
The big idea
4 minA cache is a fast copy of slower data. Every copy needs a fill rule, a write rule and a freshness limit, and must never decide what only the truth can.
3,000 reads become 150 a second. Lina's one warning: a hotel's price can be a minute old; whether the last room is free cannot.
How it works
5 min- 1Check the copy
Cache-aside: the app asks Redis first for
price:42:2026-12-20. A hit returns in ~1 ms. The is the share answered there: at 95%, only 5% reach the database. - 2Miss: ask the truth
On a miss, it queries Postgres (~40 ms).
- 3Fill the copy
It stores the answer with a (time to live) of ~60 s, randomised ±20% so keys don't expire together.
- 4Price changes
Invalidation: the owner's update writes Postgres first, then deletes the key. The next read refills from the truth.
- 5Safety net
If the delete is lost, the TTL still limits staleness to about a minute.
- 6Truth at checkout
Availability is cached 30 s for browsing only. Checkout reads availability and price from Postgres, with a row lock.
Watch a decision being made
4 minYou cache hotel pages safely
Cut database load sharply without ever selling a room that's gone or showing a price that never existed. Lina checks the write path.
- 1Sort the data by staleness cost
Details (weekly): a minute stale is harmless. Prices (daily): acceptable, re-checked at checkout. Availability (constant): 30 s for browsing, never trusted at checkout.
Why: The cost of a stale read decides the TTL, and whether caching is allowed at all.
- 2Keys, TTLs, and an optional cache
hotel:{id}10 min,price:{id}:{night}60 s,avail:{id}:{night}30 s, all ±20%. If Redis is down, fall back to Postgres after a 20 ms timeout.Why: Keys match how pages read, so many guests share each copy. A Redis outage should make pages slower, not broken.
- 3Write path: truth first
Update Postgres, then after commit delete the key.
tsawait db.query(UPDATE_PRICE, [amount, hotelId, night]); await redis.del(`price:${hotelId}:${night}`); // after the commitWhy: Lina: "truth first, then remove the copy. Never the other way round."
- 4Measure
Hit ratio 95%, Postgres reads 3,000 → 150/s, CPU 90% → 25%, page p95 180 → 45 ms. Alert if the hit ratio drops below 85%.
Why: A falling hit ratio warns that keys or TTLs no longer match how guests read.
In real life
3 minWikipedia: cache whole pages
- Has publicly described serving most anonymous readers from caching proxies
- An article looks the same for every logged-out reader
- Huge savings; edits must purge the cached page
Works when the same page is shown to everyone.
Facebook: cache the pieces
- Its published memcache paper describes a huge look-aside cache of data, not pages
- Every page is personal, assembled per user from cached pieces
- Needs careful invalidation and stampede protection
Works when every page differs but is built from shared data.
What goes wrong
3 minEvery concurrent request refills the expired key at once.
Coalesce misses per key, serve stale while one request refreshes, jitter TTLs.
"2 rooms left" after the last one sold: two guests book it.
Browse from the cache; check and lock in the database at checkout.
A cold start or an outage brings the slow query back at full force.
Fix the query first; cache what's still expensive and repeated.
Trade-offs
3 min| Freshness rule | Gains | Costs |
|---|---|---|
| Short TTL only | Simple | Stale for up to the whole TTL |
| Delete on write + TTL | Usually fresh right after a change | Every write path must invalidate |
| Don't cache | Always fresh | Full database load, slower pages |
- Maya · Product managerGuests hate seeing "2 rooms left", clicking, and finding it sold. Don't cache availability at all.
- Omar · On-call SREIt's half of the reads. Uncached, Postgres goes back to 60% CPU at peak.
- Omar · On-call SREAnd checkout re-checks anyway, so nobody is double-booked.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- What may be cached and for how long: details and prices 60 s (±20%), availability 30 s for browsing only, nothing personal in a shared cache.
- What is never trusted from the cache: availability and price at checkout.
- How hot keys are protected, and what happens if Redis is empty or down: slower, never broken.
Add cache-aside caching in Redis for hotel page reads (details plus prices for the chosen nights), which today send ~3,000 reads/s to PostgreSQL at 90% CPU. TTL 60 s with ±20% jitter; delete the key after any update commits. Only one request per key may reload on a miss; others wait or get the stale value. Cache availability 30 s for browsing only; checkout keeps reading the database under its lock. Nothing guest-specific in the cache. Plan first, then show hit ratio, database reads/s and page p95 in a staging load test, including one with Redis off.
- The checkout path never reads prices or availability from the cache.
- Write order: database commit first, then
DEL. Ask Claude to point at the lines. - A test proves 800 simultaneous misses on one key run only one query; with Redis off, slower but no errors.
- It reads the cached price or availability during checkout.
- It updates the cache before the database write commits.
- It caches personalised responses in a shared cache or CDN.
Check yourself
2 minNext episode: booking confirmation takes 1.3 seconds, Maya wants 400 ms, and five teams each own a piece of it. You lead.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You lead the design. Lina only checks the write path and adds the warning about availability.
You cache hotel pages safely
Hotel pages send 3,000 reads/s to Postgres (CPU 90%), 95% of them repeats. Cut database load sharply without ever selling a room that's gone or showing a price that never existed.
- 1Sort the data by staleness cost
Hotel details (changes weekly): a minute stale is harmless. Prices (change daily): a minute stale is acceptable, re-checked at checkout. Availability (changes constantly): 30 s stale for browsing, never trusted at checkout.
Why: The cost of a stale read decides the TTL, and whether caching is allowed at all.
- 2Pick keys and TTLs
hotel:{id}(TTL 10 min),price:{id}:{night}(60 s),avail:{id}:{night}(30 s), each randomised ±20%.Why: Key per hotel and night matches how pages read the data, so many guests share each copy. Jitter stops keys written together from expiring together.
- 3Write the read path
Cache-aside: Redis first; on a miss, query Postgres, store with the TTL, return. If Redis is down, fall back to Postgres with a short timeout on Redis (20 ms).
Why: The cache must be optional: a Redis outage should make pages slower, not broken.
- 4Write the write path
Owner updates price → update Postgres → after commit,
DEL price:{id}:{night}.tsawait db.query(UPDATE_PRICE, [amount, hotelId, night]); await redis.del(`price:${hotelId}:${night}`); // after the commitWhy: Lina: "truth first, then remove the copy. Never the other way round."
- 5Protect the truth at checkout
Checkout reads availability and price from Postgres inside the booking transaction, with a row lock. The cached value is only used to show the page.
Why: A cache can say "2 rooms left" for 30 s after the last one sells. Only the database decides a booking.
- 6Measure
Hit ratio 95%, Postgres reads 3,000 → 150/s, CPU 90% → 25%, page p95 180 → 45 ms. Alert if the hit ratio drops below 85%.
Why: A falling hit ratio is the early warning that keys or TTLs no longer match how guests read.
| Pattern | Read path | Write path | Staleness | Main risk |
|---|---|---|---|---|
| Cache-aside | App: cache, then DB on miss, then fill | DB, then delete key | Up to TTL if invalidation fails or races | Stampede on hot key expiry; app owns the logic |
| Read-through | Cache library loads from DB on miss | DB, then invalidate | Same as cache-aside | Cache layer becomes a critical dependency |
| Write-through | Cache (always warm for written keys) | Cache and DB synchronously | Low for data written through it | Slower writes; caches data nobody reads |
| Write-back | Cache | Cache only; flushed to DB later | DB lags behind | Data loss if the cache dies before flushing |
# Fingerprinted asset: content never changes at this URL
Cache-Control: public, max-age=31536000, immutable
# Anonymous city landing page: CDN may cache 60 s, serve stale 5 min while refreshing
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
# Personal pages: never stored by shared caches
Cache-Control: private, no-storeInteractive simulation
Visual diagram
Cache layers in a hotel site, from the guest's browser to the database. Walk through a read, a write, and a stampede.
Analogy
- Sticky notes at a hotel deskHead office system, slow to queryCachingThe database (source of truth)
- Sticky notes at a hotel deskCheck the notes, else call and write oneCachingCache-aside
- Sticky notes at a hotel desk"Valid until 18:00" on each noteCachingA TTL (expiry time)
- Sticky notes at a hotel deskHead office: "tear up room 12's note"CachingInvalidation
- Sticky notes at a hotel deskUpdate the note, fax head office tonightCachingWrite-back: fast, can lose data
- Sticky notes at a hotel desk30 receptionists call head office at onceCachingA cache stampede
Where the analogy breaks: a desk has a handful of notes. Real caches hold millions of entries at several layers (browser, CDN, app, database), each with its own expiry.
Check your understanding
Practice ladder
Trace a price change
Scenario Warm-upCache-aside is live with TTL 60 s (±20%) and delete-on-write. At 10:00:00 the key price:42:2026-12-20 holds €120 (written at 09:59:30).
- 10:00:10: the owner changes the price to €95. The update commits; the delete succeeds.
- 10:00:11: guest A opens the page.
- 10:05:00: the owner changes it to €99. The update commits; the delete fails (network blip).
- 10:05:05: guest B opens the page. (The page is busy, so the key is refilled about once a minute; the last refill before the second update was at 10:04:40.)
Trace what each guest sees, and the latest time the wrong price can still be shown.
Finish the coalescing function
Code PracticeLina's function to stop stampedes inside one pod has two gaps. Concurrent misses for the same key should share one database call.
const inFlight = new Map<string, Promise<Price>>();
export function getPriceCoalesced(hotelId: string, night: string): Promise<Price> {
const key = `price:${hotelId}:${night}`;
const pending = inFlight.get(key);
if (pending) ___1___;
const p = getHotelPrice(hotelId, night) // cache-aside read
.finally(() => ___2___);
inFlight.set(key, p);
return p;
}Fill the gaps, then explain what this does not protect against when 20 pods run.
Cache the search results?
Design PracticeSearch queries like "Paris, 20–22 Dec, 2 guests" cost ~180 ms in the database. Omar's data for one day:
- 4 million searches; the top 2,000 distinct searches make up 60% of them; the rest are almost all unique.
- Results include prices and a "rooms left" badge.
- Prices change ~once a day; availability changes many times an hour for popular hotels.
Design a cache for search: what to cache, the key, the TTL, how to keep it safe, and the expected database savings.
Write-back for loyalty points?
Decision StretchThe loyalty team wants to make "points earned" updates faster. Each booking adds points; guests check their balance often. Proposals:
- A (write-back): update the points balance only in Redis; flush to Postgres every 5 minutes. Writes take ~1 ms instead of ~15 ms.
- B (cache-aside): write Postgres (~15 ms), delete the cached balance; reads use the cache.
Points can be spent on bookings (1 point = €0.01). About 30,000 point updates a day.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain to a teammate how you would cache hotel prices, and what can go wrong.