Leer en español · Lire en français · Auf Deutsch lesen
Section 7.6 of 7.8 · Intermediate · 24 min read
Caching strategies and their pitfalls
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
Caches make systems fast, and they can also take systems down. In this lesson you'll learn how a cache works, how to keep its copies fresh enough, and how to stop it failing at the worst moment.
Imagine a busy café. Instead of grinding beans for every order, the barista keeps a small jar of ground coffee on the counter. Most orders are served from the jar in seconds. Only when it runs out does anyone go to the store room. The jar is a cache. It makes service fast, and it spares the store room. But it raises questions. How long can ground coffee sit before it tastes stale? And what happens when the jar empties during the morning rush? Software caches raise exactly the same questions.
A cache keeps a copy of data somewhere faster than its original home. It lowers latency, and it protects the database, because every hit is a query the database never sees. The price is paid in correctness. A cache is a second copy of the data, and two copies can disagree. So the first question isn't which cache to use. It's what may be stale, by how much, and for whom. A menu five minutes old is fine. A bank balance five minutes old may not be.
The safe default pattern is called cache-aside. Here a menu service uses it. The service first asks the cache. On a hit, the value comes straight back. On a miss, it reads the database, the system of record, and stores the result in the cache with a time to live, after which the entry expires. Writes take a different path. Update the database first, then delete the cache entry. Don't write the new value into the cache. The next read simply misses and reloads the fresh value. And if the cache disappears altogether, the service just reads the database, more slowly. That is why cache-aside fails safe.
Keeping a cache fresh is famously hard. A quip attributed to Phil Karlton says there are only two hard things in computer science: cache invalidation and naming things. Here's why. Even deleting on write leaves a narrow race. A reader misses the cache and reads a price of nine pounds from the database. Then it pauses, perhaps on a slow network. Meanwhile, a writer updates the price to eleven pounds and deletes the cache entry. So far, so good. Then the reader resumes and stores nine pounds in the cache. The stale price stays until its time to live runs out. That's why good designs combine both: invalidate on change, and keep a time to live as a safety net.
The share of lookups the cache answers is its hit ratio. Say a menu service gets twenty thousand reads a second. The cache answers in half a millisecond, the database in eight, and the database copes with about three thousand queries a second. With no cache, every read goes to the database: nearly seven times what it can sustain. At an eighty per cent hit ratio, four thousand queries a second still reach the database. That's above capacity, so queues build and latency rises. At ninety five per cent, it's a thousand queries a second. Comfortable. At ninety nine per cent, average latency barely changes, but database load falls five-fold. Each extra nine of hits divides database load by about ten. Now the cache restarts empty. All twenty thousand queries a second hit a database sized for three thousand, and it falls over. A cache you can't run without is a critical dependency.
A stampede happens when a popular entry expires, and hundreds of requests miss at the same instant and all go to the database. The defences combine well. Request coalescing lets only one request per key fetch the value, while the others wait for its result. Jittered expiry adds a random amount to each time to live, so entries written together don't expire together. Stale-while-revalidate serves the expired value to everyone while one background request fetches a fresh one. And warming a new cache from a list of hot keys, before it takes traffic, avoids the stampede after a restart.
Finally, four common mistakes. Adding a cache before measuring: often the real fix is an index or a better query. Having no time to live at all, so one forgotten code path leaves stale data forever. Sizing the database only for the cached load, so a cache restart takes the whole platform down. And caching personal or financial data in shared caches. A page with a customer's name, cached under its address alone, can be served to the next visitor.
The full section compares five caching patterns, and covers caching levels, HTTP headers and eviction. Next, in Section seven point seven, we look at schema evolution. See you there.
What you need to know
- A cache trades a second, possibly stale copy of data for lower latency, higher throughput and protection of the source; first decide what may be stale, by how much and for whom.
- Cache-aside is the safe default; read-through moves the loading into the cache layer; write-through keeps the cache current at the cost of slower writes; write-behind risks data loss and must never hold money; refresh-ahead suits a few hot keys.
- Combine invalidation on change with a TTL as a safety net; on writes, update the database and then delete the cache entry rather than overwriting it.
- Prevent stampedes with request coalescing, jittered expiry, stale-while-revalidate, probabilistic early refresh and cache warming.
- Hit ratio drives database load far more than average latency: each extra nine of hits divides database queries by about ten, and a cold cache can overwhelm a database sized for cached traffic.
- Caches exist in browsers, CDNs, reverse proxies, applications and databases; HTTP controls them with Cache-Control, ETag and conditional requests (RFC 9111, RFC 9110).
- LRU and LFU are the common eviction policies; keys must include everything that changes the answer, and personal or financial data must stay out of shared caches.
Why cache, and what it costs
A cache keeps a copy of data somewhere faster or closer than its original home, so that repeated requests do not pay the full price each time. Section 0.2 introduced the idea as a trade of space for time. At architecture scale the trade is more interesting, because a cache changes three qualities at once. It lowers latency (a Redis lookup in the same data centre typically takes well under a millisecond; a database query that joins five tables may take tens of milliseconds). It raises throughput and protects the database, because every hit is a query the database never sees. And it can improve availability, because a cache that can serve slightly old data keeps working while the source is slow or down.
The price is paid in correctness and complexity. A cache is a second copy of the data, and two copies can disagree. Every cache therefore embodies a decision about how stale an answer is allowed to be, and for whom. A restaurant menu that is five minutes old is acceptable; a bank balance that is five minutes old, shown to a customer deciding whether they can afford a payment, is not. Caches also add a component that can fail, consume memory that costs money, and create new failure modes such as stampedes and cold starts that do not exist without them. The first question is therefore not which cache?
but what may be stale, by how much, and what happens if it is?
The effectiveness of a cache is measured by its hit ratio: hits divided by all lookups. Hit ratio depends on how skewed the access pattern is. Read traffic in most consumer systems is highly skewed (a small fraction of restaurants, products or accounts receives most of the reads), so a cache holding a few per cent of the data can often answer most of the reads. Uniformly random access, by contrast, caches badly whatever you do.
The five classic patterns
Caching patterns differ in who loads the cache, when writes reach the source, and therefore how stale and how durable the data can be. The table compares them; the paragraphs after it explain where each fits.
Caching patterns compared
| Pattern | How reads work | How writes work | Strengths | Risks |
|---|---|---|---|---|
| Cache-aside (lazy loading) | Application checks the cache; on a miss it reads the database and stores the result | Application writes the database, then deletes (or updates) the cache entry | Simple, only caches what is used, cache failure degrades to slower reads | First read after expiry is slow; races can leave stale entries; stampedes |
| Read-through | Application asks the cache; the cache library itself loads from the database on a miss | Usually combined with write-through or cache-aside writes | Loading logic lives in one place | Same staleness and stampede issues; the cache layer must know the data source |
| Write-through | As read-through or cache-aside | Write goes to the cache and the database synchronously before acknowledging | Cache is never older than the database for data written through it | Every write pays both latencies; caches data that may never be read |
| Write-behind (write-back) | Served from the cache | Write is acknowledged once in the cache; flushed to the database later, often batched | Very fast writes; absorbs bursts; batches database work | Data loss if the cache fails before flushing; database lags; ordering and conflicts |
| Refresh-ahead | Served from the cache | Not about writes: hot entries are reloaded shortly before they expire | Popular items never expire in users' faces | Wasted reloads of items that would not have been read; needs access statistics |
Cache-aside is the default for good reason: it is easy to reason about and fails safe, because if the cache disappears the application simply reads the database (more slowly). Read-through moves the same logic into a cache library or product, which is tidy but no different in behaviour. Write-through suits data that is read soon after being written, such as a user's session or shopping basket. Write-behind is the risky one: it turns the cache into the temporary system of record, so it is appropriate only where losing the last few seconds of writes is acceptable (view counters, analytics events, game scores) or where the cache itself is replicated and durable. Never use write-behind for money. Refresh-ahead is an optimisation for a small set of very hot keys, such as the home-page restaurant list in a busy city.
Cache-aside in practice
import json, random
TTL = 300 # seconds
def get_menu(restaurant_id):
key = f"menu:{restaurant_id}:v3" # version the key with the data shape
cached = cache.get(key)
if cached is not None:
return json.loads(cached) # hit
with cache.lock(f"lock:{key}", timeout=5): # coalesce: one loader per key
cached = cache.get(key) # someone may have filled it meanwhile
if cached is not None:
return json.loads(cached)
menu = db.load_menu(restaurant_id) # miss: go to the system of record
jitter = random.randint(0, TTL // 10) # spread expiry times out
cache.set(key, json.dumps(menu), ex=TTL + jitter)
return menu
def update_price(restaurant_id, item_id, price):
db.update_price(item_id, price) # 1. change the system of record
cache.delete(f"menu:{restaurant_id}:v3") # 2. invalidate, do not overwriteTime to live and invalidation
There are two ways to stop a cache serving data that has changed. The first is a time to live (TTL): every entry expires after a fixed period, which bounds staleness without any coordination. A five-minute TTL on menus means a price change is visible everywhere within five minutes. The second is explicit invalidation: when the source changes, something deletes or updates the cached copy. Invalidation gives fresher data but requires every code path that changes the data to know every cache that holds it, which is exactly what goes wrong as systems grow. In distributed systems invalidation is often driven by events: the menu service publishes MenuChanged, and each service holding a copy deletes its entry. Most professional designs combine both: invalidate on change for freshness, and keep a TTL as a safety net for the invalidation that was missed.
The difficulty is captured in a quip attributed to Phil Karlton of Netscape: There are only two hard things in Computer Science: cache invalidation and naming things.
It is hard because invalidation is a distributed-systems problem in disguise. The database write and the cache delete are two operations on two systems with no shared transaction, so one can succeed while the other fails, and operations from different requests can interleave.
Two rules avoid most problems. First, on a write, update the database and then delete the cache entry; do not write the new value into the cache. Two concurrent writers that each set the cache can finish in the opposite order to their database commits and leave the older value cached indefinitely, whereas a delete can only cause an extra miss. Second, accept that even delete-on-write leaves a narrow race, shown below, and bound it with a TTL. Facebook's 2013 paper on scaling memcache describes leases for this case: the cache gives a token to the client that missed, and refuses that client's later write if the key was deleted in the meantime.
The stale-set race
Stampedes and the thundering herd
A cache stampede happens when a popular entry expires, or a cache node restarts empty, and hundreds of concurrent requests all miss at the same instant. Each one goes to the database to compute the same value, and the database, sized for the cache's normal miss rate, is overwhelmed. Slower responses mean more requests pile up, and the overload can cascade. The related thundering herd describes many waiting clients woken at once to compete for one resource. Facebook reported that, for a set of keys particularly prone to this, leases cut the peak database query rate from 17,000 to 1,300 per second.
The defences combine well. Request coalescing (also called single flight) lets only one request per key fetch from the source while the others wait for its result; within one process this is a map of in-flight futures, across processes a short lock in the cache, as in the code above. Jittered expiry adds a random amount to each TTL so that entries written together (after a deployment or a cache flush) do not expire together. Stale-while-revalidate serves the expired value to everyone while one background request fetches a fresh one, which removes the latency spike entirely when slight staleness is acceptable. Probabilistic early expiration (Vattani, Chierichetti and Lowenstein, VLDB 2015) has each reader decide at random, with a probability that rises as expiry approaches, to refresh early, so that one request usually refreshes before the entry actually expires. Finally, warming a new cache from a list of hot keys before it takes traffic avoids the cold-start stampede after a restart.
Worked example
Worked example: what hit ratio does to latency and database load
The food-delivery platform's menu service receives 20,000 menu reads per second at the evening peak. A cache lookup takes 0.5 ms; a database query takes 8 ms. On a miss the service pays both (8.5 ms) and stores the result asynchronously. The database can sustain about 3,000 such queries per second before latency climbs steeply.
| Formula | Average latency = 0.5 + (1 − h) × 8 ms. Database load = (1 − h) × 20,000 queries per second. |
|---|---|
| No cache at all | 8 ms average; 20,000 queries per second, nearly seven times what the database can sustain. |
| Hit ratio 80% | 0.5 + 0.2 × 8 = 2.1 ms; 4,000 queries per second: still above capacity, so the database queues and latency rises. |
| Hit ratio 95% | 0.5 + 0.05 × 8 = 0.9 ms; 1,000 queries per second: comfortable. |
| Hit ratio 99% | 0.5 + 0.01 × 8 = 0.58 ms; 200 queries per second. |
| From 95% to 99% | Average latency improves by only 0.32 ms, but database load falls five-fold, from 1,000 to 200 queries per second. |
| Cache cluster restarts empty | Hit ratio drops to near 0% for a while: 20,000 queries per second hit a database sized for 3,000. Without coalescing, warming or rate limiting, the database falls over. |
| Percentiles | At 95% hits, 5% of requests take 8.5 ms, so p95 sits on the edge and p99 is 8.5 ms, not 0.9 ms. |
Hit ratio matters far more for the database than for average latency: each extra nine of hits divides database load by about ten.
A cache that the system cannot run without is no longer an optimisation; it is a critical dependency, and its failure must be planned for as Section 2.5 plans for any other.
Averages flatter caches; as Section 2.4 showed, the misses define the tail.
Levels of caching
Caches exist at every level between the user and the disk, and a request is answered by the first level that has a usable copy. The browser keeps a private cache controlled by HTTP headers. A content delivery network (CDN) keeps shared copies at edge locations near users, ideal for images, scripts and public API responses such as menus. A reverse proxy at the origin (Nginx, Varnish) can cache whole responses for a few seconds, which is called microcaching and can absorb a sudden spike. The application caches computed objects, either in-process (fastest, but each instance holds its own copy and they disagree) or in a shared cache such as Redis or Memcached. The database caches pages in memory (PostgreSQL's shared buffers, the InnoDB buffer pool) and can precompute expensive results in materialised views.
The further from the data, the larger the saving per hit and the harder the invalidation. You can delete a key from Redis in a millisecond; you can purge a CDN, more slowly; you cannot reach into a customer's browser at all, so whatever max-age you send is a promise you must live with. Long browser lifetimes are therefore used for files whose URL changes when their content changes (app.3f9a1c.js), and short ones, or revalidation, for anything else.
Where caches live
HTTP caching headers
HTTP has a complete caching model, defined in RFC 9111 (June 2022), and using it well can remove load before it reaches your servers. Cache-Control: max-age=60 lets any cache reuse a response for 60 seconds; s-maxage overrides that for shared caches such as CDNs; private forbids shared caches from storing it (use it for anything personal); no-cache confusingly means store it, but check with the server before every reuse
; and no-store means never keep a copy
, the right setting for bank balances and statements. The stale-while-revalidate and stale-if-error extensions (RFC 5861, 2010) apply the stampede and availability ideas above at the HTTP level.
When a cached copy is stale, the client need not download it again. A validator lets it ask whether anything has changed: the server sends an ETag (an opaque version identifier) or a Last-Modified date with the response, and the client later sends If-None-Match or If-Modified-Since. If nothing changed, the server replies 304 Not Modified with no body, saving bandwidth and often most of the server's work. These conditional requests are defined in RFC 9110. The Vary header tells caches which request headers (such as Accept-Language) produce different responses, so that a French menu is never served to an English request.
GET /restaurants/17/menu HTTP/1.1
Host: api.example-food.test
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "menu-17-v42"
... 18 KB of menu JSON ...
# 100 seconds later the copy is past max-age plus the stale-while-revalidate
# window, so the browser must revalidate before using it:
GET /restaurants/17/menu HTTP/1.1
If-None-Match: "menu-17-v42"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "menu-17-v42"
# A bank balance, by contrast, must never sit in any cache:
Cache-Control: no-storeEviction, sizing and consistency risks
A cache is smaller than the data behind it, so when it is full it must evict something. Least recently used (LRU) evicts the entry untouched for longest and works well when recent use predicts future use. Least frequently used (LFU) evicts the entry used least often and resists one-off scans (a nightly report that touches every restaurant once would flush an LRU cache of its genuinely hot entries). Production caches approximate both: Redis, for example, samples a few keys and evicts the best candidate among them, and offers policies such as allkeys-lru, allkeys-lfu and volatile-ttl. Its default policy, noeviction, rejects writes when memory is full, which suits Redis used as a store but not as a cache, so set the policy deliberately.
Beyond staleness, caches introduce subtler consistency risks. A user who changes their delivery address and then sees the old one, because the read came from a cache another instance had not yet invalidated, experiences a broken read-your-writes guarantee (Section 7.4); a common fix is to bypass the cache for a user's own recent writes. In-process caches on many instances disagree with each other, so consecutive requests routed to different instances can show a value going backwards. Caching an error or an empty result without a short TTL turns a brief outage into a long one. Caching data that mixes users (a page with the customer's name, cached under the URL alone) leaks personal data to the next visitor, a well-known class of security incident. And cache keys must include everything that changes the answer: tenant, language, currency, permissions and the version of the data format.
Check your understanding
Why should a write delete the cache entry rather than put the new value into the cache?
Because two concurrent writers can finish their cache updates in the opposite order to their database commits, leaving the older value cached; a delete can at worst cause one extra miss that reloads the current value.
A cache lookup costs 1 ms and a database read 10 ms. Roughly what is the average latency at a 90% hit ratio with cache-aside?
1 + 0.1 × 10 = 2 ms (every request pays the lookup; one in ten also pays the database read).
What is the difference between Cache-Control: no-cache and no-store?
no-cache allows a cache to store the response but requires it to revalidate with the server before each reuse; no-store forbids keeping a copy at all.
Why can raising the hit ratio from 99% to 99.9% matter even though average latency barely changes?
Because database load is proportional to the miss rate, which falls from 1% to 0.1%, a ten-fold reduction in queries reaching the database.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “Our restaurant search API is slow at peak times. How do I decide whether to add a cache, where to put it and what TTL to use?”