Skip to content

I added a cache, and now the shop shows the old price

  • System Design · Foundations
  • 25 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: high
  • Reviewed 2026-11

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.

  • 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.
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?

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.

The kettle’s price, with and without deleting on write

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)
The kettle’s page
WayDatabase readsOld price shown for
No cache1,000 a secondnever

Step 1 of 6. The kettle’s page is opened 1,000 times a second. Every view reads its price from the database.

Try your own input

The tests run this cache, including 50 requests that miss at the same moment:

Cache-aside with a TTL, LRU, delete on write and one refill
"""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 threading
import time
from 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.

Each rung serves the kettle’s page, 1,000 views a second, through one price change.

Okay

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.

Better

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).

Best

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.

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.
Add parts from the list above. Then press the + on a part to draw an arrow.

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.

Send
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.

StrategyReadsWritesRiskUse it for
Cache-aside + TTLfast after the first missto the database onlystale until the TTLmost read-heavy data
Cache-aside + delete on writefast after the first missdatabase, then deletea missed delete (TTL covers it)prices, profiles, products
Write-throughalways in the cachecache and database togetherslower writes; caches unread datadata read right after it is written
Write-backalways in the cachecache now, database latera crash loses unsaved writescounters, view counts, likes
  • Caching per user in a shared key. A page cached under /account shows 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.

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.

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
Okay

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.
Better

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.
Best

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.

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
Okay

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.
Better

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.
Best

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.

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
Okay

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.
Better

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.
Best

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.
  1. Multiple choiceIn cache-aside, what does the app do on a cache miss?
  2. Spot the bugPrices are cached with no TTL. What goes wrong?
    def set_price(product_id, price):
        db.execute("UPDATE products SET price = %s WHERE id = %s", (price, product_id))
    
    def get_price(product_id):
        cached = cache.get(product_id)
        if cached is None:
            cached = db.price(product_id)
            cache.set(product_id, cached)
        return cached
  3. Pick the patternCount video views, about 100,000 a second, and show a rough total. Which strategy?
  4. Multiple choiceA hot key expires and 500 requests miss at once, all reading the database. What is this, and the fix?
  • 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.

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