I added a cache, and now the shop shows the old price
See the problem
Section titled “See the problem”The kettle’s product page is opened 1,000 times a second, and each view reads the price from the database. The database is busy answering the same question all day.
You add a cache, and the database relaxes. Then the shop drops the kettle from £30 to £25. Shoppers still see £30, some pay it, and support gets angry emails.
Define the task
Section titled “Define the task”- What you have: a database (the saved copy) and a cache (fast memory) beside it.
- What you want: most reads answered from the cache, and a changed price shown within moments, not hours.
- The hard part: a cache is a copy. Every copy can go out of date, and the cache does not know when the database changes.
Pause and think
Section titled “Pause and think”Hint 1 of 3
The app server is the one that saves the new price.
Hint 2 of 3
It could change or delete the cached copy at the same moment.
Hint 3 of 3
What if that delete is missed, for example after a crash?
Understand the idea
Section titled “Understand the idea”A cache is the fridge in your kitchen. The supermarket (the database) has everything, but it is far away. You keep what you use often in the fridge, and throw out what has expired (the TTL, time to live). When the fridge is full, you throw out what you used least recently (LRU eviction).
There are three common ways to use a cache:
- Cache-aside (the most common): the app looks in the cache. On a miss, it reads the database and puts the answer in the cache. On a write, it saves to the database and deletes the cached copy.
- Write-through: every write goes to the cache and the database together, so the cache always has the latest value. Writes get a little slower.
- Write-back (write-behind): writes go to the cache only, and are saved to the database later, in batches. Very fast writes, but a crash can lose what wasn’t saved yet.
Watch the price become out of date, then stay fresh. Change the TTL below the player.
No cache
Parts:
- Shoppers
- App server
- Database (the request is here)
Arrows:
- Shoppers sends requests to App server (the request went this way)
- App server sends requests to Database (the request went this way)
| Way | Database reads | Old price shown for |
|---|---|---|
| No cache | 1,000 a second | never |
Step 1 of 6. The kettle’s page is opened 1,000 times a second. Every view reads its price from the database.
The tests run this cache, including 50 requests that miss at the same moment:
"""Caching a product page: cache-aside with a TTL, an LRU size limit,deleting the cached copy on every write, and one refill per key.
`Database` counts its reads, so the tests can see what the cache saves.Time is passed in as `now` (seconds)."""
import threadingimport timefrom collections import OrderedDict
class Database: def __init__(self, delay: float = 0.0) -> None: self.rows: dict[str, float] = {} self.reads = 0 self.delay = delay # Seconds a read takes, for the stampede test.
def read(self, key: str) -> float: self.reads += 1 if self.delay: time.sleep(self.delay) return self.rows[key]
class Cache: """Keeps at most `size` entries; the least recently used goes first (LRU)."""
def __init__(self, size: int = 1000) -> None: self.size = size self.data: OrderedDict[str, tuple[float, float]] = OrderedDict() # key -> (value, expires)
def get(self, key: str, now: float) -> float | None: hit = self.data.get(key) if hit is None or now >= hit[1]: return None self.data.move_to_end(key) # Just used: keep it longest. return hit[0]
def set(self, key: str, value: float, now: float, ttl: float) -> None: self.data[key] = (value, now + ttl) self.data.move_to_end(key) while len(self.data) > self.size: self.data.popitem(last=False) # Evict the least recently used.
def delete(self, key: str) -> None: self.data.pop(key, None)
class ProductStore: """Reads go through the cache (cache-aside); writes go to the database."""
def __init__(self, db: Database, cache: Cache, ttl: float, delete_on_write: bool) -> None: self.db = db self.cache = cache self.ttl = ttl self.delete_on_write = delete_on_write self.locks: dict[str, threading.Lock] = {} self.guard = threading.Lock()
def price(self, key: str, now: float = 0.0, one_refill: bool = False) -> float: cached = self.cache.get(key, now) if cached is not None: return cached if not one_refill: value = self.db.read(key) self.cache.set(key, value, now, self.ttl) return value with self.guard: lock = self.locks.setdefault(key, threading.Lock()) with lock: # Only one request refills this key; the rest wait, then read the cache. cached = self.cache.get(key, now) if cached is not None: return cached value = self.db.read(key) self.cache.set(key, value, now, self.ttl) return value
def set_price(self, key: str, value: float) -> None: self.db.rows[key] = value if self.delete_on_write: self.cache.delete(key) # The next read fetches the new price.Climb the ladder
Section titled “Climb the ladder”Each rung serves the kettle’s page, 1,000 views a second, through one price change.
Cache-aside, kept forever
The idea: Look in the cache; on a miss, read the database and keep the answer with no expiry.
The tests show 1,000 views costing 1 database read.
- Database reads
about 1 database read, ever- Weak spot
the old price stays until a restart- Why
- After the first miss, every view is answered from memory.
Where it breaks: the price change. Nothing tells the cache, so it shows £30 for days. And with no expiry or size limit, memory fills with products nobody views any more.
Add a TTL and an LRU limit
The idea: Each entry expires after 5 minutes; when the cache is full, the least recently used entry goes.
The waste in Okay: it kept data forever, fresh or not, used or not.
- Database reads
1 read every 5 minutes- Weak spot
old prices for up to 5 minutes; stampedes- Why
- Expired entries are read again, so a change shows within one TTL.
Where it breaks: a 5-minute TTL still shows £30 for up to 5 minutes; a shorter TTL means more database reads. And when a hot key expires, every view misses at once: in the tests, more than 25 of 50 requests go to the database (a cache stampede).
Delete on write, one refill per key, TTL as a safety net
The idea: When the app saves a price, it deletes the cached copy. On a miss, one request refills the key while the others wait. Keep the TTL and LRU.
The waste in Better: it waited for a timer to notice a change the app made itself. Delete rather than update the cached copy: two writes racing to set it can leave the older value behind.
- Database reads
about 1 read per change- Weak spot
more code; a delete can still be missed- Why
- The cache is cleared exactly when the data changes, and a stampede becomes 1 read instead of many.
When Best isn’t best: if being a few minutes out of date is fine (a “most popular” list, a weather page), a plain TTL is simpler and good enough. And data that must always be exact, like a bank balance at payment, should be read from the database.
Draw it yourself
Section titled “Draw it yourself”Step 1 is cache-aside. Step 2 counts views with write-back: the cache takes the counts, and a worker saves them later.
Step 1: A busy product page
- Product pages are read 1,000 times for each price change.
- The database is busy answering the same questions.
- Answer most reads from memory, and keep the database as the saved copy.
Solid arrow: sends requests toDashed arrow: copies data to
Watch the traffic
Send 10 requests through your board and see where each one goes. Then make a part fail and send them again.
How requests move
Requests start at the Client and follow your arrows. Reads try a cache first (4 in 5 are found there), then a read replica, then the database. Writes go to the database. Background jobs go on a queue, and a worker saves them. A load balancer skips a part that is down; nothing else can.
Compare
Section titled “Compare”| Strategy | Reads | Writes | Risk | Use it for |
|---|---|---|---|---|
| Cache-aside + TTL | fast after the first miss | to the database only | stale until the TTL | most read-heavy data |
| Cache-aside + delete on write | fast after the first miss | database, then delete | a missed delete (TTL covers it) | prices, profiles, products |
| Write-through | always in the cache | cache and database together | slower writes; caches unread data | data read right after it is written |
| Write-back | always in the cache | cache now, database later | a crash loses unsaved writes | counters, view counts, likes |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Caching per user in a shared key. A page cached under
/accountshows one user’s data to the next. Put the user in the key, or don’t cache it. - Caching “not found” forever. A product not found a second ago may exist now. Cache misses briefly, if at all.
- Every key expiring at once. Entries filled at the same moment expire at the same moment. Add a little randomness to each TTL.
- The cache as the only copy. Unless it is write-back on purpose, the database is the truth. A cache can be emptied at any time.
- Huge values. One 5 MB entry evicts thousands of small ones. Cache the parts you read.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- How many reads for each write? How stale may the data be?
- Is any of it per user, or private?
- What happens if the cache is empty, for example after a restart?
A 60-second walkthrough
“Product pages are read about 1,000 times per change, so I’d use cache-aside in Redis. Read the cache first; on a miss, read the database and set the entry with a TTL. When a price changes, the app writes the database and deletes the cache key, so the next read is fresh. The TTL stays as a safety net. Memory is limited, so LRU evicts what isn’t used. For hot keys, I’d let only one request refill a missing key, so an expiry doesn’t stampede the database. For view counters, write-back: count in the cache, save every few seconds.”
Likely follow-ups
- How big should the cache be? Big enough for the hot set. If 20% of products get 80% of views, cache that 20%.
- What hit rate is good? Above 90% for read-heavy pages. Measure it; a low hit rate means the TTL or size is wrong.
- What if the cache goes down? Reads fall back to the database, which may not cope at full traffic. Run cache replicas, and warm a new cache slowly.
Red flags interviewers notice
- A cache with no plan for stale data.
- Caching data that must be exact, like stock left for a last item, without checking the database.
- No eviction and no size limit.
Practice
Section titled “Practice”1. What to cache? (easy)
Section titled “1. What to cache? (easy)”A shop has: product details (change weekly), stock left (changes every sale), the user’s cart, and a “top 10 this week” list (rebuilt nightly). What would you cache, and for how long?
Hint 1 of 3
How often is each read, and how often does it change?
Hint 2 of 3
What harm does a few minutes of staleness do in each case?
Hint 3 of 3
Some data belongs to one user only.
Show the answers
Cache everything for an hour
The idea: One TTL for all of it.
A shopper buys the last kettle shown as “5 left”.
- Gives
fast pages- Weak spot
wrong stock; carts shown to the wrong user- Why
- Stock changes every sale, and a shared cart key leaks data.
Cache the slow-changing things
The idea: Products with delete on write and a 1-hour TTL; the top 10 list for a day; stock and carts not cached.
The top 10 list is rebuilt nightly, so a day-long TTL is exact.
- Gives
most reads from memory- Weak spot
stock reads still hit the database- Why
- Each piece of data gets a TTL that fits how often it changes.
Cache by need, check where it matters
The idea: Show cached stock on product pages for a few seconds, but check the database when the order is placed; carts go in a per-user session store.
Say where being stale is harmless, and where it costs money.
- Gives
fast pages and correct orders- Weak spot
two places read stock- Why
- A rough number is fine to look at; the exact number is needed only to buy.
2. A home page for everyone (medium)
Section titled “2. A home page for everyone (medium)”The home page is the same for every visitor who isn’t signed in, and it is opened 20,000 times a second. It takes 200 ms to build.
Hint 1 of 3
Building the same page 20,000 times a second is a lot of work for one answer.
Hint 2 of 3
Where could a copy of the finished page live, closer to users?
Hint 3 of 3
Signed-in users see their own name on it. How do you keep that part out of the shared copy?
Show the answers
Cache the data, build the page each time
The idea: Cache the products the page shows, and build the HTML per request.
Helps the database, not the app servers.
- Gives
fewer database reads- Weak spot
still 20,000 builds a second- Why
- The data is cached, but the work of building the page is not.
Cache the whole page in the app
The idea: Build the page once a minute and keep the HTML in the cache.
Only for visitors who aren’t signed in.
- Gives
one build a minute- Weak spot
every request still reaches your servers- Why
- Most of the cost was building; now it is a cache read.
Cache the page at a CDN
The idea: Send Cache-Control: public, max-age=60 for visitors who aren’t signed in, so a CDN serves the page; signed-in parts load separately.
Load the user’s name with a small request after the page, so the big page stays the same for everyone.
- Gives
most requests never reach you- Weak spot
up to a minute old- Why
- CDN servers near users answer, and your servers build the page about once a minute per CDN location.
3. A likes counter (hard)
Section titled “3. A likes counter (hard)”A post can get 50,000 likes a second when it goes viral. Each like adds 1 to its count, and the count shows on the post.
Hint 1 of 3
What happens to one database row when 50,000 writes a second hit it?
Hint 2 of 3
Does the shown count need to be exact to the like?
Hint 3 of 3
Which caching strategy takes writes in memory and saves them later?
Show the answers
UPDATE posts SET likes = likes + 1
The idea: Write every like to the database.
The post’s page slows down exactly when everyone is looking at it.
- Handles
hundreds a second- Weak spot
one hot row, locked for every write- Why
- Each update locks the row, so likes queue up behind each other.
Write-back: count in the cache
The idea: INCR the count in Redis (fast and safe across servers), and a worker saves it to the database every few seconds.
Also store who liked what (one row per like, written in batches), so a user can’t like twice.
- Handles
tens of thousands a second- Weak spot
a cache crash loses a few seconds of likes- Why
- INCR in memory takes far less than a millisecond, and the database sees one write every few seconds.
Split hot counters
The idea: For viral posts, keep 10 counters per post on different cache keys, add to a random one, and add them up to read.
Show “50K likes”, not an exact number: nobody can tell, and it can be cached for a few seconds.
- Handles
far beyond one key’s limit- Weak spot
reads add 10 numbers- Why
- One key lives on one cache server; 10 keys can spread over 10.
Check yourself
Section titled “Check yourself”Cheat sheet
Section titled “Cheat sheet”- Cache-aside: read cache, on a miss read the database and fill the cache. On a write, save then delete the key.
- TTL is how stale data may get; keep one as a safety net even with deletes.
- LRU evicts the least recently used entry when memory is full.
- Write-through keeps the cache current on every write; write-back saves later and can lose a little on a crash.
- Stampede: many misses at once on a hot key. Let one request refill it; add randomness to TTLs.
Go deeper
Section titled “Go deeper”Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started