Caching, from first idea to production
How caching works, from TTLs and cache-aside to eviction, HTTP headers, stampedes and LLM caches, with two live simulators and real production code.
Key takeaways
- A cache keeps a copy of an expensive answer close to where it's needed; it pays off when reads repeat, a little staleness is acceptable, and a miss is still correct.
- Cache-aside with a TTL is the default: read the cache, fill it from the database on a miss, and after every write update the database first, then delete the key.
- Hit ratio sets the bill: the database sees reads × (1 − hit ratio), so going from 99% to 99.9% hits cuts its load tenfold.
- When a hot key expires, every concurrent reader can hit the database at once; a single-flight lock or early probabilistic refresh turns that stampede into one query.
- Put everything that changes the answer into the key (tenant, permissions, version, schema), or the cache will serve one caller's answer to another.
When you opened this page, it most likely came from a CloudFront edge near you, not from the storage bucket it lives in. The edge already had a copy, checked it was still allowed to use it, and handed it over. That’s caching: keeping a copy of an answer close to where it’s needed, so you don’t have to fetch or compute it again.
It’s the cheapest performance work in backend engineering, and the source of some of its strangest bugs: a price that won’t update, a permission change that takes a day to apply, a database that falls over the moment one popular key expires. All of them come from a few decisions you can learn to make on purpose.
By the end of this article you’ll be able to:
- say when a cache will help and when it won’t, with numbers;
- pick a caching pattern, an invalidation method and an eviction policy, and defend the choice;
- write HTTP
Cache-Controlheaders that are fast and still correct after a deploy; - prevent stampedes, stale writes and cross-tenant leaks, the three bugs that hurt most in production;
- size a cache from its hit ratio and walk through a caching design in an interview.
Start at Beginner if “cache hit” is a new phrase. If you already put Redis in front of things, jump to Advanced: that’s where the production bugs live. The last section walks through the real caches in ezychat.io, a multi-tenant customer-messaging product I’m the primary engineer of, and the CDN setup of this site, with the code comments that explain each choice.
Beginner: what a cache is and when it pays
Why cache at all?
Because the same expensive answer gets asked for again and again, and keeping a copy nearby costs far less than producing it each time. Here are real numbers. The ones marked “laptop” I measured for this article on an M1 Pro; the others are the widely quoted orders of magnitude from Jeff Dean’s “latency numbers every programmer should know”.
| What | Time | Where the number comes from |
|---|---|---|
Look up a key in a Python dict |
18 ns | laptop, 100,000 keys |
| Read from main memory | ~100 ns | widely quoted |
| Postgres read by primary key, over localhost | 37 µs | laptop, Postgres 14 |
Redis GET over localhost, with redis-py |
96 µs | laptop, Redis 7.2 |
| Network round trip inside one data center | ~500 µs | widely quoted |
| Round trip between continents | ~150 ms | widely quoted |
| Postgres “top 10 customers by spend” over 1M rows | 339 ms | laptop, Postgres 14 |
The middle of that table surprised me. On my laptop, Postgres answered a primary-key read faster than Redis answered a GET. Both costs are mostly the network stack and the client library; the database’s own work for a primary-key read is tiny. So Redis in front of a cheap indexed read doesn’t make it faster. It takes load off the database, which matters when thousands of requests ask at once.
The bottom row is where caching shines: a 339 ms aggregate that becomes a 0.1 ms GET is a 3,000-fold saving. The rule of thumb: a cache saves whatever the miss costs. Cache the expensive things (aggregates, big joins, calls to other services, rendered pages, model calls). Cheap things you cache only for load.
Hits, misses and TTL
A cache is a store of answers you’ve already worked out, looked up by a key that describes the question. When the answer is there, that’s a hit. When it isn’t, that’s a miss: you do the slow work, then usually store the result for next time. The share of lookups that are hits is the hit ratio.
A cached answer can go out of date: the exchange rate moves, the product gets a new price. An answer that no longer matches the source of truth is stale. The simplest defense is a time to live (TTL): every entry carries an expiry time, and after it you treat the entry as missing.
Here’s a complete cache with a TTL. The clock is passed in so we can move time forward in the example:
import time
class TTLCache:
"""Remember answers for `ttl` seconds."""
def __init__(self, ttl, clock=time.monotonic):
self.ttl = ttl
self.clock = clock
self.entries = {} # key -> (value, time it expires)
self.hits = self.misses = 0
def get(self, key):
entry = self.entries.get(key)
if entry is not None and self.clock() < entry[1]:
self.hits += 1
return entry[0]
self.misses += 1
self.entries.pop(key, None) # expired or never there
return None
def set(self, key, value):
self.entries[key] = (value, self.clock() + self.ttl)
now = 0.0
cache = TTLCache(ttl=60, clock=lambda: now)
def exchange_rate(currency):
rate = cache.get(currency)
if rate is None: # a miss: do the slow thing, then remember it
print(" asking the bank for", currency)
rate = {"USD": 4.21, "SGD": 3.27}[currency] # made-up rates
cache.set(currency, rate)
return rate
print(exchange_rate("USD"))
print(exchange_rate("USD"))
print(exchange_rate("SGD"))
now = 61.0 # a minute later, the USD answer has expired
print(exchange_rate("USD"))
print("hits:", cache.hits, "misses:", cache.misses)
# asking the bank for USD
# 4.21
# 4.21
# asking the bank for SGD
# 3.27
# asking the bank for USD
# 4.21
# hits: 1 misses: 3
The second USD lookup never asked the bank. SGD had its own entry, because it’s a different key. After 61 seconds the USD entry was gone, so the bank was asked again. A TTL is a promise about staleness: “this answer is at most 60 seconds old.” Picking the number is a product decision as much as a technical one.
Your language already has one
Python ships a cache in the standard library. functools.lru_cache remembers a function’s results by its arguments, up to a size limit:
from functools import lru_cache
@lru_cache(maxsize=2)
def shipping_zone(postcode):
print(" looking up", postcode)
return "east" if postcode.startswith("8") else "west"
for code in ["50450", "50450", "88000", "10200", "50450"]:
shipping_zone(code)
print(shipping_zone.cache_info())
# looking up 50450
# looking up 88000
# looking up 10200
# looking up 50450
# CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
The last lookup of 50450 missed even though we’d seen it. The cache had room for two entries, so when 10200 arrived, something had to go. Choosing what goes is eviction, and lru_cache throws out whatever was used least recently (LRU). More on that in Intermediate.
Where caches live
A request for a web page passes several places that can keep a copy, and a hit at any of them skips everything further down:
- The browser keeps files it was told it may keep. A hit sends no request at all.
- A CDN (content delivery network, such as CloudFront or Cloudflare) keeps copies on servers in many cities, close to users.
- App memory is a dictionary inside your server process, like
lru_cache. Fastest, but each process has its own copy and they don’t agree. - A shared cache such as Redis or Memcached is one store every server can reach, for the price of a network round trip.
- The database’s buffer pool keeps hot pages of tables and indexes in RAM (Postgres calls it
shared_buffers). It’s free, and saves the disk read but not the query.
What makes data worth caching
Before you cache anything, ask: how wrong can this be, and for how long? “Never, not even for a second” means don’t cache it, or only with invalidation you can prove. “A minute is fine” means you have a TTL. Good candidates are:
- Read far more often than written. A product page read a thousand times between price changes.
- The same answer for many requests, or at least for many requests from the same user.
- Expensive to produce: an aggregate, a call to another service, a rendered page, a model call.
- Small enough to store many of.
Poor candidates: a balance at the moment of payment, one-off reports, anything unique to one request, and anything where an old answer is a security problem, unless your invalidation can’t lag (ezychat.io caches permissions this way; see the last section).
Exercise · BeginnerCache it or not?
For each item, say whether you’d cache it, and if so with roughly what TTL:
- The list of countries in a signup form.
- A user’s account balance shown on the checkout button.
- The rendered HTML of a blog post.
- The result of “is this user an admin?”
- A weather forecast fetched from a paid API.
- A one-time report a single analyst runs once a quarter.
Show solution
- Yes, for a long time (a day or more). It almost never changes and every visitor needs it.
- No, or only for display with a re-check at payment time. Paying with a stale balance is the kind of wrong you can’t take back.
- Yes. It changes only when you publish, so a long TTL plus invalidation on publish works. This site does exactly that; see In production.
- Only with explicit invalidation when roles change, plus a short TTL as a backstop. A demoted admin who keeps admin rights for an hour is a security bug.
- Yes, for as long as the forecast is useful (say 10–30 minutes). Every miss costs money, so the hit ratio is a line on your bill.
- No. Nobody will ask again, so a cached copy would only take up memory.
Exercise · BeginnerA memoizer with a TTL
functools.cache remembers results forever. Write a decorator ttl_cache(seconds) that does the same, except each result expires after seconds. Take a clock argument so it can be tested without sleeping.
Show solution
Store each result with its expiry time, keyed by the arguments (a tuple, so it can be a dictionary key):
import functools
import time
def ttl_cache(seconds, clock=time.monotonic):
"""Like functools.cache, but every answer expires after `seconds`."""
def decorate(fn):
entries = {} # args -> (value, expires_at)
@functools.wraps(fn)
def wrapper(*args):
now = clock()
hit = entries.get(args)
if hit is not None and now < hit[1]:
return hit[0]
value = fn(*args)
entries[args] = (value, now + seconds)
return value
wrapper.entries = entries
return wrapper
return decorate
now = 0.0
calls = 0
@ttl_cache(30, clock=lambda: now)
def opening_hours(shop):
global calls
calls += 1
return f"{shop}: 9am-6pm"
for t in (0, 10, 29, 31, 40):
now = float(t)
opening_hours("kl-central")
print("calls to the slow function:", calls)
# calls to the slow function: 2One call at t=0, hits at 10 and 29, a miss at 31 (the entry expired at 30), and a hit at 40. This version never deletes expired entries, so it grows with the number of distinct arguments. That’s fine for a handful of shops, and exactly the leak a size limit fixes.
Intermediate: patterns, invalidation, eviction and HTTP
Every cache design answers four questions: who fills the cache, what happens on a write, how stale entries get out, and what leaves when it’s full. The first two are the pattern, the third is invalidation, the fourth is eviction.
Cache-aside: the default
In cache-aside (also called lazy loading), the application talks to both the cache and the database. On a read it checks the cache; on a miss it reads the database and stores the result. On a write it updates the database, then deletes the cached copy.
class Database:
"""A stand-in for Postgres that counts how often it's asked."""
def __init__(self):
self.rows = {"p1": {"name": "Kopi beans", "price": 3500}}
self.reads = self.writes = 0
def read(self, key):
self.reads += 1
return self.rows.get(key)
def write(self, key, row):
self.writes += 1
self.rows[key] = row
db = Database()
cache = {} # stands in for Redis
def get_product(product_id):
"""Cache-aside: the application checks the cache, and fills it on a miss."""
key = f"product:{product_id}"
if key in cache:
return cache[key]
row = db.read(product_id)
cache[key] = row # in Redis: SET key value EX ttl
return row
def update_price(product_id, price):
row = dict(db.read(product_id), price=price)
db.write(product_id, row) # 1. the database is the truth: write it first
cache.pop(f"product:{product_id}", None) # 2. then delete the cached copy, don't overwrite it
for _ in range(1000):
get_product("p1")
print("after 1,000 reads, database reads:", db.reads)
update_price("p1", 3200)
print("price after update:", get_product("p1")["price"], "| database reads:", db.reads)
# after 1,000 reads, database reads: 1
# price after update: 3200 | database reads: 3
A thousand reads cost the database one query. The update read the row once more to change it, and the next read refilled the cache with the new price.
Two details in update_price matter more than they look.
Write the database first, then touch the cache. If you delete the key first, another request can miss, read the old row (your write hasn’t happened yet) and put it straight back. Then your write lands, and the cache holds the old price until the TTL ends.
Delete; don’t set. Two writers racing can set their values in the opposite order from their database commits, leaving the loser’s value cached. A delete is safe in any order: the next reader fetches whatever the database says now. The write path also doesn’t need to know how the cached form is built.
Cache-aside is my default. Only data someone asks for gets cached, the cache can be emptied at any time without losing anything, and if it’s down the application can still read the database.
Read-through and write-through
In read-through, the cache itself loads a missing entry, by calling a loader you gave it. Java’s Caffeine LoadingCache works this way, and so does a CDN, which fetches from your origin when it misses. It behaves like cache-aside, with the loading logic in one place.
In write-through, every write goes to the cache and the database together, before the write returns:
class WriteThrough:
"""Every write goes to the cache and the database before it returns."""
def __init__(self, db):
self.db, self.cache = db, {}
def set(self, key, value):
self.db[key] = value # the database first: if this fails, the cache never lies
self.cache[key] = value
def get(self, key):
return self.cache[key] if key in self.cache else self.db.get(key)
profiles = WriteThrough(db={})
profiles.set("user:7", {"name": "Aisyah"})
print("write-through, cache and database agree:", profiles.cache == profiles.db)
# write-through, cache and database agree: True
Reads after a write are fresh hits. The costs: every write pays twice, and you cache everything written, read or not. It suits data with a single owner and writer.
Write-behind
Write-behind (or write-back) accepts the write into the cache and saves it to the database later, usually in batches. It turns many small writes into a few big ones:
class WriteBehind:
"""Writes land in the cache now and reach the database later, in batches."""
def __init__(self, db):
self.db, self.cache, self.dirty = db, {}, set()
self.db_writes = 0
def set(self, key, value):
self.cache[key] = value
self.dirty.add(key) # remember to save it; no database work yet
def flush(self): # run by a background worker every second or so
for key in self.dirty:
self.db[key] = self.cache[key]
self.db_writes += 1
self.dirty.clear()
db = {}
views = WriteBehind(db)
for n in range(1, 1001): # a popular page is viewed 1,000 times in a second
views.set("views:home", n)
views.flush()
print("database value:", db["views:home"], "| database writes:", views.db_writes)
views.set("views:home", 1001) # if the process dies now, this write is gone
print("cache says", views.cache["views:home"], "but database says", db["views:home"])
# database value: 1000 | database writes: 1
# cache says 1001 but database says 1000
A thousand writes became one. The price is in the last line: until the flush, the newest value exists only in the cache, and a crash loses it. Fine for view counters and analytics; I wouldn’t use it for anything a customer would notice losing.
Refresh-ahead
Refresh-ahead reloads a popular entry before it expires, from a background job or from a read that notices expiry is near. It wastes a few refreshes, and it’s the cleanest cure for the stampede in Advanced.
| Pattern | Who fills the cache | On a write | Main risk | Good for |
|---|---|---|---|---|
| Cache-aside | The app, on a miss | Update DB, delete key | Stale set races (Advanced) | Almost everything |
| Read-through | The cache’s loader | Same as cache-aside | Same as cache-aside | Libraries, CDNs |
| Write-through | The writer | DB and cache together | Caching unread data | Single-owner data |
| Write-behind | The writer | Cache now, DB later | Losing writes | Counters, analytics |
| Refresh-ahead | A timer or early reads | Usually cache-aside | Wasted refreshes | Hot, slow keys |
Invalidation: three tools
Invalidation is how stale entries get out. You have three tools, and good designs combine them.
- TTL. The entry expires on its own. No coordination, always works, but blunt: stale for up to the full TTL after a change.
- Explicit delete. The code that changes the data deletes the key. Precise, but only if every writer does it: the admin panel, the import script, the migration, the other service. Miss one and the cache is wrong until the TTL saves you.
- Versioned keys. Put a version number in the key and bump it when the data changes. Readers build the key from the current version, so they stop finding the old entry:
cache = {}
versions = {"catalog:shop-9": 1} # in production this number lives in the database row
def catalog_key(shop_id):
return f"catalog:{shop_id}:v{versions[f'catalog:{shop_id}']}"
def get_catalog(shop_id):
key = catalog_key(shop_id)
if key not in cache:
cache[key] = f"catalog built at version {versions[f'catalog:{shop_id}']}"
return cache[key]
print(get_catalog("shop-9"))
versions["catalog:shop-9"] += 1 # any product edit bumps the version in the same transaction
print(get_catalog("shop-9"))
print("keys in cache:", sorted(cache)) # the old entry is never read again; its TTL cleans it up
# catalog built at version 1
# catalog built at version 2
# keys in cache: ['catalog:shop-9:v1', 'catalog:shop-9:v2']
Versioned keys shine when one change should invalidate many entries: bump one shop’s version and every cached search, page and count for it is invalid at once. The price is reading the current version first.
My rule: always set a TTL, even when you invalidate explicitly. Explicit invalidation makes the cache correct today; the TTL limits the damage from the writer you haven’t thought of. The last section quotes what happened in ezychat.io when that backstop was a day long.
Eviction: who leaves when the cache is full
When the cache is full and a new entry arrives, an eviction policy chooses a victim. Three cover most of what you’ll meet:
- LRU (least recently used) evicts what hasn’t been read for longest, betting that recent use predicts the next.
- LFU (least frequently used) evicts what’s been read the fewest times. It protects favourites, but notices slowly when they change.
- FIFO (first in, first out) evicts the oldest entry, however popular. Simplest, usually worst.
An LRU cache is a hash map plus a doubly linked list: the map finds an entry in one step, and the list keeps entries in order of use, so moving one to the front and dropping one from the back are one step each too.
How much does the policy matter? I simulated it. Real traffic is skewed: a few keys get most requests. A common model is the Zipf distribution, where the second most popular key gets half the requests of the first, the third a third, and so on. Here are 50,000 such requests over 1,000 keys:
import random
from bisect import bisect
from collections import Counter, OrderedDict, deque
from itertools import accumulate
def zipf_stream(n_keys, skew, length, seed=1):
"""Key i (0 = most popular) is requested with probability proportional to 1 / (i + 1) ** skew."""
rng = random.Random(seed)
weights = [1 / (i + 1) ** skew for i in range(n_keys)]
cum = list(accumulate(weights))
return [bisect(cum, rng.random() * cum[-1]) for _ in range(length)]
def lru_hits(stream, size):
cache, hits = OrderedDict(), 0
for key in stream:
if key in cache:
hits += 1
cache.move_to_end(key)
else:
cache[key] = True
if len(cache) > size:
cache.popitem(last=False)
return hits / len(stream)
def fifo_hits(stream, size):
cache, order, hits = set(), deque(), 0
for key in stream:
if key in cache:
hits += 1
else:
cache.add(key)
order.append(key)
if len(cache) > size:
cache.discard(order.popleft())
return hits / len(stream)
def lfu_hits(stream, size):
counts, hits = {}, 0 # key -> uses while cached
for key in stream:
if key in counts:
hits += 1
counts[key] += 1
else:
if len(counts) >= size:
del counts[min(counts, key=counts.get)]
counts[key] = 1
return hits / len(stream)
stream = zipf_stream(1000, 1.0, 50_000)
top = Counter(stream).most_common(10)
print("share of requests for the top 10 keys:", round(sum(c for _, c in top) / len(stream), 3))
for size in (10, 50, 100, 200):
print(f"cache {size:>3} of 1000 keys:", "LRU", round(lru_hits(stream, size), 3),
"LFU", round(lfu_hits(stream, size), 3), "FIFO", round(fifo_hits(stream, size), 3))
uniform = zipf_stream(1000, 0.0, 50_000)
print("uniform, cache 100:", "LRU", round(lru_hits(uniform, 100), 3))
# share of requests for the top 10 keys: 0.391
# cache 10 of 1000 keys: LRU 0.211 LFU 0.346 FIFO 0.182
# cache 50 of 1000 keys: LRU 0.46 LFU 0.565 FIFO 0.406
# cache 100 of 1000 keys: LRU 0.577 LFU 0.66 FIFO 0.521
# cache 200 of 1000 keys: LRU 0.695 LFU 0.747 FIFO 0.644
# uniform, cache 100: LRU 0.103
Three lessons. Skew is what makes small caches work: room for 10% of the keys answered 58% of requests under LRU, against 10% on uniform traffic. On stable popularity, LFU beats LRU beats FIFO. And the gap between policies is smaller than the gain from doubling the cache.
Now let popularity move halfway through, the way it does when a product launches or a story breaks:
before = zipf_stream(1000, 1.0, 25_000, seed=1)
after = [(k + 500) % 1000 for k in zipf_stream(1000, 1.0, 25_000, seed=2)] # new favourites
shifted = before + after
for name, fn in (("LRU", lru_hits), ("LFU", lfu_hits), ("FIFO", fifo_hits)):
total = fn(shifted, 100)
first = fn(before, 100)
second_half = (total * len(shifted) - first * len(before)) / len(after)
print(f"{name}: before the shift {first:.3f}, after it {second_half:.3f}")
# LRU: before the shift 0.578, after it 0.578
# LFU: before the shift 0.659, after it 0.053
# FIFO: before the shift 0.522, after it 0.521
LFU collapsed from 66% to 5%. The old favourites hold large counts, so every newcomer arrives with a count of 1 and is evicted next. The cache is frozen full of yesterday’s news. That’s why production LFU implementations age their counters: Redis decays an idle key’s counter as time passes, and Caffeine’s W-TinyLFU keeps a frequency sketch that it periodically halves. Plain LFU, as taught, is a trap.
Try both effects yourself. All three policies see the same requests, and the bars at the bottom compare them:
Try it
Cache simulator
Requests for 100 keys, some far more popular than others, hit a cache with room for a few. Change the size, the skew and the policy, then shift popularity or run a scan and watch which policy recovers.
Last requests hit miss
In the cache least recently used first
- Requests
- 2,000
- Hit ratio
- 45%
- Avg read
- 11.6 ms
Same requests, every policy hit ratio, last 500
After 2,000 requests with room for 10 of 100 keys, LRU hits 45% of the last 500 requests, an average read of 11.6 ms. Same traffic: LRU 45%, LFU 56%, FIFO 38%.
The simulator needs JavaScript. What you see is its state after 2,000 requests with skew 1.0 and room for 10 keys.
HTTP caching: headers that browsers and CDNs obey
Browsers and CDNs are caches you instruct with the Cache-Control response header. The directives you’ll use most:
| Directive | Meaning |
|---|---|
max-age=N |
Any cache may use this response for N seconds without asking. |
s-maxage=N |
Shared caches (CDNs, proxies) use N instead of max-age. Browsers ignore it. |
public |
Shared caches may store it, even if the request carried credentials. |
private |
Only the user’s own browser may store it. Never a CDN. |
no-cache |
Store it, but check with the server before every use. |
no-store |
Don’t store it anywhere. |
must-revalidate |
Once it’s stale, don’t use it without a successful check. |
immutable |
It will never change while fresh, so don’t even check on reload. |
stale-while-revalidate=N |
After expiry, serve the stale copy for up to N seconds while refetching in the background. |
The most useful trick in the table is splitting max-age from s-maxage. max-age=0, s-maxage=86400 says: browsers, check every time; CDN, keep it a day. The CDN is close to the user, so checking is cheap, and the CDN is the cache you can tell to forget.
Checking is called revalidation. The server sends a fingerprint of the response, an ETag. Next time the browser sends it back in If-None-Match, and if nothing changed the server answers 304 Not Modified with no body:
import hashlib
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError
from urllib.request import Request, urlopen
BODY = b"<h1>Price list</h1>"
ETAG = '"' + hashlib.sha256(BODY).hexdigest()[:16] + '"'
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.headers.get("If-None-Match") == ETAG:
self.send_response(304) # "what you have is still right"
self.send_header("ETag", ETAG)
self.end_headers()
return
self.send_response(200)
self.send_header("ETag", ETAG)
self.send_header("Cache-Control", "public, max-age=0, must-revalidate")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{server.server_port}/prices"
first = urlopen(url)
print(first.status, first.headers["ETag"], len(first.read()), "bytes")
try:
urlopen(Request(url, headers={"If-None-Match": first.headers["ETag"]}))
except HTTPError as revalidated: # urllib reports a 304 as an "error"
print(revalidated.code, revalidated.headers["ETag"], len(revalidated.read()), "bytes")
server.shutdown()
# 200 "918a726bc29fc23a" 19 bytes
# 304 "918a726bc29fc23a" 0 bytes
A 304 still costs a round trip, but no body, and often none of the expensive work.
Files that never change once published can skip revalidation entirely. Build tools put a hash of the content in the file name (app.3f9a2c.js), so a changed file gets a new URL and each URL’s content is permanent: cache it for a year with immutable. The HTML that references it is what changes, and that stays on a short leash. This site uses exactly that split.
To remove something from a CDN, you invalidate (purge) it by path or wildcard, and on some CDNs by tag. A purge takes seconds to minutes to reach every edge, so for assets prefer new URLs: a new name can’t be stale anywhere.
Exercise · IntermediateTrace LRU and FIFO by hand
A cache holds 3 entries. Requests arrive for keys 1, 2, 3, 1, 4, 2, 5, 1. For LRU and for FIFO, mark each request hit or miss, and give the contents at the end. Which policy wins on this trace, and does that mean it’s better?
Show solution
LRU: 1, 2, 3 miss and fill the cache. 1 hits and becomes most recent: order 2, 3, 1. 4 misses and evicts 2. 2 misses and evicts 3. 5 misses and evicts 1. 1 misses and evicts 4. One hit, contents 2, 5, 1.
FIFO: 1, 2, 3 fill it. 1 hits but nothing moves. 4 evicts 1, the oldest arrival. 2 hits. 5 evicts 2. 1 misses and evicts 3. Two hits, contents 4, 5, 1.
from collections import OrderedDict
def trace(policy, requests, size=3):
cache, hits = OrderedDict(), []
for key in requests:
hit = key in cache
hits.append(hit)
if hit and policy == "lru":
cache.move_to_end(key)
if not hit:
cache[key] = True
if len(cache) > size:
cache.popitem(last=False)
return ["hit" if h else "miss" for h in hits], list(cache)
requests = [1, 2, 3, 1, 4, 2, 5, 1]
print("LRU: ", *trace("lru", requests))
print("FIFO:", *trace("fifo", requests))
# LRU: ['miss', 'miss', 'miss', 'hit', 'miss', 'miss', 'miss', 'miss'] [2, 5, 1]
# FIFO: ['miss', 'miss', 'miss', 'hit', 'miss', 'hit', 'miss', 'miss'] [4, 5, 1]FIFO wins here, which proves nothing: any policy loses to a trace built against it. On 50,000 skewed requests LRU came out ahead. Judge a policy on your traffic, not on one sequence.
Exercise · IntermediateWrite the headers
Give a Cache-Control header for each response:
/assets/app.3f9a2c.js, a build file with a content hash in its name./index.htmlof a static site behind a CDN, deployed a few times a week.GET /api/me, the logged-in user’s profile.- A public product list API that may be up to 30 seconds stale.
- A PDF of a customer’s invoice.
Show solution
public, max-age=31536000, immutable. The name changes when the content does, so this URL’s content is permanent.public, max-age=0, s-maxage=86400, must-revalidate, plus a CDN invalidation on deploy. Browsers revalidate on every visit (cheap, mostly 304s from a nearby edge); the CDN keeps it for a day or until the deploy purges it.private, no-cache, orno-storeif it contains anything sensitive. Neverpublic: a shared cache would hand one user’s profile to the next.public, max-age=30, orpublic, max-age=10, s-maxage=30, stale-while-revalidate=30if you want browsers fresher than the CDN and no user ever waiting on a refetch.private, no-store, served through a short-lived signed URL. An invoice should never sit in a shared cache, and arguably not on a shared computer’s disk either.
Advanced: stampedes, races and the arithmetic of hit ratios
What is a cache stampede?
A cache stampede (or thundering herd) happens when a popular entry expires and many requests miss at once. Each does what cache-aside says: read the database and fill the cache. If a key is read 200 times a second and takes half a second to rebuild, about 100 requests run the same query before the first finishes. The database slows down under that load, which keeps the key missing for longer.
Here it is with 50 threads standing in for 50 concurrent requests:
import threading
import time
db_queries = 0
count_lock = threading.Lock()
def slow_query():
"""The expensive thing we cache: a 200 ms report query."""
global db_queries
with count_lock:
db_queries += 1
time.sleep(0.2)
return "top sellers"
def naive(cache, key):
value = cache.get(key)
if value is None:
value = slow_query()
cache[key] = value
return value
fill_lock = threading.Lock()
def single_flight(cache, key):
value = cache.get(key)
if value is not None:
return value
with fill_lock: # one caller computes; the rest queue here
value = cache.get(key) # check again: someone may have filled it while we waited
if value is None:
value = slow_query()
cache[key] = value
return value
def stampede(get, n=50):
global db_queries
db_queries, cache = 0, {} # the hot key has just expired
start = threading.Barrier(n)
def request():
start.wait()
get(cache, "report:top-sellers")
threads = [threading.Thread(target=request) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
return db_queries
print("naive: ", stampede(naive), "database queries for 50 requests")
print("single-flight:", stampede(single_flight), "database query for 50 requests")
# naive: 50 database queries for 50 requests
# single-flight: 1 database query for 50 requests
Single-flight (or request coalescing) means one caller does the work and the rest wait for its result. The second cache.get inside the lock matters: without it, every waiter would rebuild the value in turn once it got the lock. Go’s golang.org/x/sync module ships this as singleflight; in production use one lock per key, not one for everything.
A lock in one process only coalesces that process’s requests; twenty servers still send twenty queries. To coalesce across servers, take the lock in Redis:
import threading
import time
import uuid
import redis
r = redis.Redis(port=6399)
RELEASE = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""")
queries = 0
def build_report():
global queries
queries += 1
time.sleep(0.2)
return b"top sellers"
def get_report(key="report:top-sellers", ttl=60):
value = r.get(key)
if value is not None:
return value
token = uuid.uuid4().hex
# Only one caller across every server gets the lock; it expires on its own if that caller dies.
if r.set(f"lock:{key}", token, nx=True, px=5000):
try:
value = build_report()
r.set(key, value, ex=ttl)
return value
finally:
RELEASE(keys=[f"lock:{key}"], args=[token]) # delete the lock only if it's still ours
for _ in range(50): # everyone else waits briefly for the winner's result
time.sleep(0.02)
value = r.get(key)
if value is not None:
return value
return build_report() # the winner is too slow: give up waiting and compute it
r.delete("report:top-sellers", "lock:report:top-sellers")
start = threading.Barrier(50)
threads = [threading.Thread(target=lambda: (start.wait(), get_report())) for _ in range(50)]
for t in threads:
t.start()
for t in threads:
t.join()
print("database queries for 50 concurrent misses:", queries)
# database queries for 50 concurrent misses: 1
Every part of that lock handles a failure. nx=True lets only one caller create it. px=5000 makes it expire, so a crashed holder can’t block the key forever. The release script deletes the lock only if it still holds our token, so a slow winner can’t delete someone else’s. And waiters give up after a second and compute it themselves, because a cache that makes requests wait forever is worse than none.
A gentler variant serves the stale value while one caller refreshes it: give entries a soft expiry that starts a refresh and a hard one after which the value is gone. That’s stale-while-revalidate, applied inside your application.
Early refresh without a lock
Locks have moving parts. An alternative is to refresh before expiry and pick the refresher at random, so that usually only one request does it. The best-known version is XFetch, from “Optimal Probabilistic Cache Stampede Prevention” (Vattani, Chierichetti and Lowenstein, VLDB 2015). Each read rolls a die, weighted so the chance of refreshing rises as expiry approaches:
import math
import random
def should_refresh_early(now, expiry, delta, beta=1.0, rng=random.random):
"""XFetch: refresh before `expiry` with a probability that rises as it gets close.
delta is how long the recompute takes. 1 - rng() is in (0, 1], so its log is <= 0
and the subtraction moves `now` forward by a random, usually small, amount."""
return now - delta * beta * math.log(1 - rng()) >= expiry
rng = random.Random(7).random
expiry, delta = 60.0, 0.5 # cached at t=0 for 60 s; rebuilding takes 0.5 s
for seconds_left in (10, 3, 1, 0.5, 0.1):
trials = 100_000
hits = sum(should_refresh_early(expiry - seconds_left, expiry, delta, rng=rng) for _ in range(trials))
print(f"{seconds_left:>4} s before expiry: {hits / trials:.4f} chance each request refreshes")
# 10 s before expiry: 0.0000 chance each request refreshes
# 3 s before expiry: 0.0027 chance each request refreshes
# 1 s before expiry: 0.1351 chance each request refreshes
# 0.5 s before expiry: 0.3678 chance each request refreshes
# 0.1 s before expiry: 0.8202 chance each request refreshes
The chance is exp(-seconds_left / (delta × beta)). Scaling by delta, the rebuild time, is the clever part: a slow rebuild starts earlier. beta above 1 refreshes earlier still. A busy key is almost always refreshed by one of its own readers before it expires, and a quiet key expires normally, which is fine: a quiet key can’t stampede.
Pure XFetch doesn’t guarantee one refresher: requests arriving while the first refresh runs can roll the die too. I simulated 2,000 refresh cycles at 200 requests a second with a half-second rebuild: on average 2.65 recomputes per refresh, 18 in the worst cycle. Far better than a hundred, and a “refresh in progress” flag brings it to one, which is what the demo does:
Try it
A hot key expires
One popular key is cached for 4 seconds and takes a while to rebuild. Pick a strategy, change the traffic and the rebuild time, and compare how many database queries each one sends.
| Strategy | DB queries | Most at once | Waited | Longest wait |
|---|---|---|---|---|
| No protection | 201 | 101 | 201 | 500 ms |
| Single-flight lock | 2 | 1 | 200 | 500 ms |
| Early refresh | 4 | 1 | 0 | 0 ms |
No protection, 200 requests a second, 500 ms to rebuild: 201 database queries in 10 seconds, at most 101 at once. Every request that arrives while the key is missing runs its own query.
The controls need JavaScript. The chart shows ten seconds with no protection at 200 requests a second and a 500 ms rebuild; the table compares all three strategies on the same requests.
No protection sends a query per waiting request. The lock sends one, but everyone arriving during the rebuild waits for it. Early refresh sends a few more queries than the lock, because it refreshes before it has to, and in exchange nobody waits. For a hot key where latency matters, that’s the trade I’d take.
Negative caching
What should the cache do when the answer is “not found”? Caching nothing is harmless until a broken client, a crawler or an attacker asks for the same missing key a thousand times a second. Negative caching stores the absence too, with a shorter TTL:
MISSING = object() # "we looked, and there's nothing": different from "we haven't looked"
db_reads = 0
users = {"42": "Aisyah"}
cache = {} # key -> (value, expires_at)
def find_user(user_id, now, ttl=300, negative_ttl=30):
global db_reads
entry = cache.get(user_id)
if entry and now < entry[1]:
value = entry[0]
return None if value is MISSING else value
db_reads += 1
value = users.get(user_id)
if value is None:
cache[user_id] = (MISSING, now + negative_ttl) # remember the absence, briefly
return None
cache[user_id] = (value, now + ttl)
return value
for second in range(100): # a broken client asks for user 999 every second
find_user("999", now=float(second))
print("database reads for 100 lookups of a missing user:", db_reads)
users["999"] = "Ravi" # the user signs up at t=100
print("at t=100:", find_user("999", now=100.0), "| at t=121:", find_user("999", now=121.0))
# database reads for 100 lookups of a missing user: 4
# at t=100: None | at t=121: Ravi
A hundred lookups cost four reads. The cost shows in the last line: Ravi signed up at t=100 and stayed invisible until the negative entry expired. Keep the negative TTL short, and delete the key on create, as you would on update.
One rule I’d never bend: don’t cache errors as answers. A timeout isn’t “not found,” and caching it turns a one-second blip into a minute of wrong answers.
Hot keys
Sharding spreads keys across servers, but each key lives on one. A hot key (the home page, a celebrity’s profile, a flash-sale product) can max out one node while the rest sit idle. The fixes, in the order I’d reach for them:
- A tiny in-process cache in front, for a second or two. A key read 50,000 times a second across 20 servers becomes 20 Redis reads a second.
- Replicate the key as
product:7#0toproduct:7#7: read a random copy, write and delete all eight. - Find them first with
redis-cli --hotkeys, which works only under an LFU eviction policy, since that’s what makes Redis track frequency.
The race between update and invalidate
Cache-aside has one race every team meets eventually. A reader misses, reads the database, then pauses (a garbage collection, a slow network). Meanwhile a writer updates the row and deletes the key. Then the reader wakes and writes the old value into the cache:
db = {"price:p1": 3500}
cache = {}
# Each step is one thing one actor does. The order is the one a busy server can produce.
steps = [
("reader", "cache miss on price:p1"),
("reader", "reads the database: 3500"),
("writer", "updates the database to 3200"),
("writer", "deletes price:p1 from the cache"),
("reader", "writes what it read (3500) into the cache"),
]
read_value = None
for who, what in steps:
if what.startswith("reads"):
read_value = db["price:p1"]
elif what.startswith("updates"):
db["price:p1"] = 3200
elif what.startswith("deletes"):
cache.pop("price:p1", None)
elif what.startswith("writes"):
cache["price:p1"] = read_value
print(f"{who:>6}: {what}")
print("database:", db["price:p1"], "| cache:", cache["price:p1"], "<- stale until the TTL runs out")
# reader: cache miss on price:p1
# reader: reads the database: 3500
# writer: updates the database to 3200
# writer: deletes price:p1 from the cache
# reader: writes what it read (3500) into the cache
# database: 3200 | cache: 3500 <- stale until the TTL runs out
No single step is a bug, the window is small, and nobody can reproduce it when it happens. Four defenses:
- A TTL, so the wrong value can’t last forever. This is the minimum.
- Delete after the transaction commits. Deleting while it’s still open lets a reader refill the key from the old, still-committed row. ezychat.io has a long comment about exactly this, quoted in the last section.
- Versions. Store the row’s version with the value and refuse to overwrite a newer one, or use versioned keys so the late write lands where nobody reads.
- Leases. Facebook’s paper “Scaling Memcache at Facebook” (NSDI 2013) describes them: a miss hands the reader a lease token, a delete cancels outstanding leases, and a set without a valid lease is refused.
Leases are simple to model:
import itertools
class LeasingCache:
"""A miss hands out a lease; a delete cancels it; a set needs a lease that is still valid."""
def __init__(self):
self.values, self.leases = {}, {}
self.tokens = itertools.count(1)
def get(self, key):
if key in self.values:
return self.values[key], None
token = next(self.tokens)
self.leases[key] = token
return None, token
def delete(self, key):
self.values.pop(key, None)
self.leases.pop(key, None) # any reader holding a lease now holds a dead one
def set(self, key, value, token):
if self.leases.get(key) != token:
return False # the data changed after this reader looked: refuse the old value
del self.leases[key]
self.values[key] = value
return True
db = {"price:p1": 3500}
cache = LeasingCache()
_, lease = cache.get("price:p1") # reader: miss, gets lease 1
old = db["price:p1"] # reader: reads 3500
db["price:p1"] = 3200 # writer: updates the database
cache.delete("price:p1") # writer: invalidates, which kills lease 1
print("reader's late set accepted?", cache.set("price:p1", old, lease))
value, lease = cache.get("price:p1") # the next reader misses and fills it correctly
print("next reader's set accepted?", cache.set("price:p1", db["price:p1"], lease), "| cached:", cache.get("price:p1")[0])
# reader's late set accepted? False
# next reader's set accepted? True | cached: 3200
The paper uses leases against stampedes too, by handing out a lease per key only every so often.
Serialization isn’t free
Every object you put in Redis is serialized (turned into bytes) and parsed on the way out. For small values that’s nothing; for 200 products it isn’t:
import json
import pickle
import timeit
# A typical cached API answer: 200 products with a handful of fields each.
products = [
{"id": i, "name": f"Product {i}", "price_cents": 1000 + i, "tags": ["coffee", "beans"], "in_stock": i % 3 != 0}
for i in range(200)
]
as_json = json.dumps(products).encode()
as_pickle = pickle.dumps(products, protocol=pickle.HIGHEST_PROTOCOL) # trusted, local data only
for name, dumps, loads, blob in (
("json", lambda: json.dumps(products).encode(), lambda: json.loads(as_json), as_json),
("pickle", lambda: pickle.dumps(products, protocol=pickle.HIGHEST_PROTOCOL), lambda: pickle.loads(as_pickle), as_pickle),
):
n = 2000
encode_us = timeit.timeit(dumps, number=n) / n * 1e6
decode_us = timeit.timeit(loads, number=n) / n * 1e6
print(f"{name:>6}: {len(blob):>6} bytes, encode {encode_us:.0f} us, decode {decode_us:.0f} us")
# json: 20647 bytes, encode 113 us, decode 113 us
# pickle: 8353 bytes, encode 38 us, decode 54 us
Decoding that JSON took 113 µs on my laptop, more than the 96 µs round trip that fetched it, so the hit cost twice what the latency table suggested. For big values on a hot path, measure the decode, cache smaller pieces, or keep a parsed copy in process memory.
Pickle is faster and smaller, and I still wouldn’t use it in a shared cache: unpickling runs code chosen by whoever wrote the bytes, so anyone who can write to your Redis can run code in every server that reads it. JSON can only produce data.
The quieter problem is schema drift: you deploy code that renames a field while the cache is full of entries in the old shape, and the new code crashes reading them. Put a schema version in the key (ent:v3:…) and bump it when the shape changes, so new code never looks at old entries. ezychat.io learned this one the hard way; the comment is in the last section.
How big should the cache be?
With hit ratio h, a hit costing t_hit and a miss costing t_hit + t_miss (you checked the cache first):
def effective_ms(hit_ratio, hit_ms, miss_ms):
"""Average time per read. A miss pays for the cache check and the database."""
return hit_ratio * hit_ms + (1 - hit_ratio) * (hit_ms + miss_ms)
reads_per_second = 20_000
for h in (0.0, 0.80, 0.90, 0.99, 0.999):
print(f"hit ratio {h:>5.1%}: {effective_ms(h, hit_ms=0.5, miss_ms=20):5.2f} ms per read, "
f"database sees {reads_per_second * (1 - h):>6,.0f} reads/s")
# hit ratio 0.0%: 20.50 ms per read, database sees 20,000 reads/s
# hit ratio 80.0%: 4.50 ms per read, database sees 4,000 reads/s
# hit ratio 90.0%: 2.50 ms per read, database sees 2,000 reads/s
# hit ratio 99.0%: 0.70 ms per read, database sees 200 reads/s
# hit ratio 99.9%: 0.52 ms per read, database sees 20 reads/s
Read the last column. Latency barely improves past 99%, but database load drops tenfold with every extra nine: 200 queries a second at 99%, 20 at 99.9%.
That’s also the most dangerous property of a good cache. At 99.9% the database is sized for 20 reads a second; empty the cache (a restart, a flush, a failover) and it gets 20,000. The cache is now load-bearing, and its failure is your database’s failure.
To estimate a hit ratio you need the working set and the skew. LRU over 100,000 keys and a million requests (cold start included):
| Cache holds | Skew 0.8 | Skew 1.0 |
|---|---|---|
| 1% of keys | 20.5% hits | 50.6% hits |
| 5% of keys | 37.0% | 66.5% |
| 10% of keys | 46.7% | 73.5% |
| 20% of keys | 58.8% | 80.6% |
Skew matters more than size: at skew 1.0, a cache of 1% of the keys beats 10% at 0.8. Measure your access log before buying memory, and measure entries too: on Redis 7.2, a 1 KB JSON value with a 32-byte key and a TTL used 1,336 bytes (MEMORY USAGE), about 300 bytes of overhead.
Redis: maxmemory and eviction policies
Redis caps its memory with maxmemory, and maxmemory-policy decides what happens at the cap:
noeviction: refuse writes. This is the open-source default, which surprises people who think of Redis as a cache.allkeys-lru,allkeys-lfu,allkeys-random: evict any key.volatile-lru,volatile-lfu,volatile-random,volatile-ttl: evict only keys that have a TTL (volatile-ttlpicks the one closest to expiring).
Here’s what the first two do when about 20 MB of entries are pushed into a 5 MB budget:
import json
import redis
r = redis.Redis(port=6399)
r.flushall()
value = json.dumps({"id": 1, "name": "x" * 1000}) # about 1 KB of JSON
r.set("product:1", value, ex=300)
print("bytes Redis uses for one 1 KB entry:", r.memory_usage("product:1"))
r.config_set("maxmemory", "5mb")
for policy in ("noeviction", "allkeys-lru"):
r.flushall()
r.config_set("maxmemory-policy", policy)
r.config_resetstat()
stored = 0
try:
for i in range(20_000): # about 20 MB of data into a 5 MB budget
r.set(f"product:{i}", value, ex=300)
stored += 1
except redis.exceptions.ResponseError as e:
print(f"{policy}: write {stored + 1} failed: {str(e)[:40]}...")
info = r.info()
print(f"{policy}: keys kept {r.dbsize()}, evicted {info['evicted_keys']}")
r.config_set("maxmemory", "0")
# bytes Redis uses for one 1 KB entry: 1336
# noeviction: write 2910 failed: command not allowed when used memory > '...
# noeviction: keys kept 2909, evicted 0
# allkeys-lru: keys kept 2902, evicted 17080
With noeviction, writes started failing after about 2,900 entries (the count varies a little by run), so the app must handle errors from a component whose job is to be disposable. With allkeys-lru, Redis quietly dropped 17,080 old entries.
Redis’s LRU is approximate: it samples maxmemory-samples keys (5 by default) and evicts the best candidate. Its LFU keeps an 8-bit logarithmic counter per key and decays it over time (lfu-decay-time, a minute by default), the aging our plain LFU lacked.
The policy gets serious when one Redis does more than caching. Queues, sessions and locks often share the node, usually without a TTL:
import json
import redis
r = redis.Redis(port=6399)
value = json.dumps({"id": 1, "name": "x" * 1000})
r.config_set("maxmemory", "5mb")
for policy in ("allkeys-lru", "volatile-lru"):
r.flushall()
r.config_set("maxmemory-policy", policy)
r.rpush("queue:emails", *[f"job {i}" for i in range(100)]) # work, not cache: no TTL
for i in range(20_000):
r.set(f"product:{i}", value, ex=300) # cache entries: all have a TTL
if i % 100 == 0:
r.get(f"product:{i // 2}") # keep some cache keys warm
print(f"{policy}: queued jobs left {r.llen('queue:emails')}")
r.config_set("maxmemory", "0")
r.config_set("maxmemory-policy", "noeviction")
# allkeys-lru: queued jobs left 0
# volatile-lru: queued jobs left 100
Under allkeys-lru, the untouched queue was evicted and 100 jobs vanished without an error. Under volatile-lru, only keys with a TTL could go. My recommendation: a dedicated cache gets allkeys-lru (or allkeys-lfu for stable popularity); a shared node gets a volatile-* policy, a TTL on every cache write and an alarm on evictions. Better still, give queues their own instance.
Exercise · AdvancedSingle-flight for asyncio
Write a SingleFlight class for asyncio with one method, await group.do(key, fn). If a call for key is already running, wait for its result instead of calling fn again. Errors must reach every waiter, and once a call finishes, the next do must start a new one.
Show solution
Keep a future per key for the call in progress. The first caller creates it and runs fn; later callers await it:
import asyncio
class SingleFlight:
"""Concurrent callers asking for the same key share one call to `fn`."""
def __init__(self):
self.running = {} # key -> asyncio.Future for the call in progress
async def do(self, key, fn):
if key in self.running:
return await asyncio.shield(self.running[key]) # join the call already running
future = asyncio.get_running_loop().create_future()
self.running[key] = future
try:
result = await fn()
future.set_result(result)
return result
except Exception as e:
future.set_exception(e) # every waiter sees the same error
raise
finally:
del self.running[key] # the next miss starts a fresh call
calls = 0
async def load_report():
global calls
calls += 1
await asyncio.sleep(0.2)
return {"top": ["kopi", "teh"]}
async def main():
group = SingleFlight()
results = await asyncio.gather(*(group.do("report", load_report) for _ in range(100)))
print("callers:", len(results), "| calls to load_report:", calls, "| all equal:", all(r == results[0] for r in results))
await group.do("report", load_report)
print("a later miss starts a new call:", calls)
asyncio.run(main())
# callers: 100 | calls to load_report: 1 | all equal: True
# a later miss starts a new call: 2asyncio.shield stops one waiter’s cancellation (a client disconnecting) from cancelling the shared call. The finally forgets the call, so a failure isn’t remembered. There’s no await between the check and the insert at the top, so on one event loop they can’t race. It coalesces within one process; pair it with the Redis lock to coalesce across servers.
Expert: layers, attackers, scale and LLMs
Multi-layer caches and consistency
Big systems stack caches: an in-process L1 per server for the hottest keys, a shared L2 such as Redis behind it, then the database. L1 absorbs hot keys; L2 holds the long tail and keeps servers in agreement.
Staleness adds up. An L1 entry copied from L2 can hold a value that was already five minutes old. Worst-case staleness is the sum of the TTLs along the path, five minutes and five seconds here. Say that number when someone asks how fresh the data is.
Invalidate from the bottom up: database, then L2, then L1s. Clear L1 first and a server can refill it from the L2 copy you haven’t deleted yet.
Fan-out messages get lost. Redis pub/sub is fire-and-forget, so a server that’s reconnecting never hears the message. Short L1 TTLs are the backstop. For a stronger guarantee, send invalidations through something durable, such as a stream read with offsets or the database’s change feed.
Users notice one more thing: reading their own writes. Someone saves their profile, reloads, and sees the old name from another server’s L1. Send the writer’s next read past the caches, or have the writing server update its own L1.
Cache poisoning and other security problems
A cache serves one stored answer to many people, so whoever controls what’s stored controls what everyone sees. That’s cache poisoning. James Kettle’s 2018 PortSwigger research, “Practical Web Cache Poisoning,” showed that headers a CDN leaves out of its cache key (such as X-Forwarded-Host) can still change what the origin generates: one malicious request gets cached under the normal URL and served to every later visitor. The defense is to leave nothing that changes the response out of the key: strip unknown headers at the edge and list the ones that matter in Vary.
Its sibling, web cache deception (Omer Gil, Black Hat USA 2017), tricks a CDN into caching a private page, for example via /account/settings/x.css on a server that ignores the trailing part. The CDN sees .css and caches the victim’s page for the next visitor. The origin should mark private responses private or no-store, and the CDN should obey the origin rather than guess from extensions.
Inside your application, the same mistake looks ordinary: a key that forgets someone. Cache the dashboard under dashboard instead of dashboard:{org_id}:{user_id} and tenant A sees tenant B’s numbers. Leave the permission scope out of a search cache and a junior employee sees what only managers should. When the key gets long, hash it, but hash all of it.
Also bound keys built from user input (hash or length-limit them), or an attacker fills your memory with junk.
Distributed caches and sharding
One Redis node eventually runs out of memory or throughput. The naive way to spread keys is hash(key) % N, until the day you add a node:
import hashlib
def node_for(key, nodes):
h = int.from_bytes(hashlib.md5(key.encode()).digest()[:8], "big")
return h % nodes
keys = [f"product:{i}" for i in range(100_000)]
moved = sum(node_for(k, 4) != node_for(k, 5) for k in keys)
print(f"adding a fifth cache node moves {moved / len(keys):.0%} of keys")
# adding a fifth cache node moves 80% of keys
Moving 80% of keys means 80% of the cache misses at once: a stampede triggered by adding capacity. Consistent hashing puts keys and nodes on a ring so that adding a node moves only about one key in N. Redis Cluster hashes keys into 16,384 fixed slots and moves whole slots between nodes; braces in a key ({org:7}:dashboard) make only that part count, keeping related keys together.
Replicas add read throughput, but lag their primary by a few milliseconds: one more source of staleness to add to the sum.
Cache warming
An empty cache is cold. After a key-format change, a flush, a failover or a new region, every request misses. At a 99.9% hit ratio that’s a thousandfold jump in database load. Ways to survive it:
- Ask the capacity question first. If the database can’t serve full traffic, a cold cache is an outage you haven’t had yet.
- Warm from evidence: load the top few thousand keys from yesterday’s logs before taking traffic. Under skew, they’re most of the hits.
- Shift traffic gradually, 5% then 20%, so misses arrive at a rate the database can take.
- Coalesce: single-flight makes a cold start cost one query per key, not per request.
- Protect the database anyway with a connection pool sized to what it can take and fast 503s when it’s full.
Measuring and alerting on hit ratio
Redis counts hits and misses for you:
import redis
r = redis.Redis(port=6399)
r.flushall()
r.config_resetstat()
for i in range(100):
r.set(f"product:{i}", "x", ex=300)
for i in range(400):
r.get(f"product:{i % 125}") # 100 of the 125 keys asked for exist
stats = r.info("stats")
hits, misses = stats["keyspace_hits"], stats["keyspace_misses"]
print(f"hits {hits}, misses {misses}, hit ratio {hits / (hits + misses):.1%}")
# hits 325, misses 75, hit ratio 81.2%
That number is server-wide: it mixes every cache, lock and session on the node, and a 30% cache can hide behind a 99% one. Count hits and misses in your application, per cache name. What I’d alert on:
- A drop in a cache’s hit ratio against the same hour last week: usually a changed key format, a shorter TTL or evictions.
- Evictions, if the node also holds anything that isn’t a cache.
- Database reads per second, the number the cache exists to keep down.
- The CDN’s hit ratio. A drop after a deploy often means a new query parameter or header is splitting the cache key.
A hit ratio can mislead: 99% on cheap keys and 50% on expensive ones may do less good than the reverse. When it matters, weight misses by cost.
Caching in LLM apps
Model calls are slow and billed per token, and three very different things get called caching.
Provider prompt caching happens at the provider. When requests share a long prefix (system prompt, tool definitions, a document), the provider reuses its processed form and charges less. OpenAI and Gemini do it automatically for long repeated prefixes; Anthropic’s API asks you to mark where the cacheable prefix ends. No invalidation and no wrong answers; the only way to lose it is to put something that changes, like a timestamp, near the start. Stable parts first, per-request parts last.
Exact response caching stores the answer under a key built from the full request. It’s safe for deterministic-in-spirit tasks (extract these fields, classify this message) when the key holds everything that shaped the answer: model ID, prompt version, tools, retrieved context, tenant. Leave one out and a prompt change is ignored for a whole TTL.
Semantic caching returns the answer to a similar cached question, found by embedding. It raises the hit ratio, and it’s where I’d be most careful, because similar isn’t same:
import math
from collections import Counter
def similarity(a, b):
"""Cosine similarity of word counts: a crude stand-in for comparing embeddings."""
x, y = Counter(a.lower().split()), Counter(b.lower().split())
dot = sum(x[w] * y[w] for w in x)
return dot / (math.sqrt(sum(v * v for v in x.values())) * math.sqrt(sum(v * v for v in y.values())))
cached_question = "can i cancel my booking for tomorrow"
for question in ("can i cancel my booking for tomorrow please",
"can i change my booking for tomorrow",
"what time do you open tomorrow"):
print(f"{similarity(cached_question, question):.2f} {question}")
# 0.94 can i cancel my booking for tomorrow please
# 0.86 can i change my booking for tomorrow
# 0.15 what time do you open tomorrow
Real embeddings beat word counts, but the problem stays: “cancel” and “change” are one word apart and need different answers, and a threshold loose enough for rephrasings will catch some different questions. Add the other costs (an embedding and a vector search on every lookup, a shared index that can leak across tenants, answers that go stale with the knowledge base) and my default is: prompt caching always, exact-match where the task allows, semantic only for FAQ-style answers, per tenant, with a strict threshold and evals that include near-miss questions.
The interview walk-through
“Add caching” shows up inside almost every design interview. A full pass, with numbers:
The prompt: a product catalogue API for an online store. 5 million products, about 2 KB of JSON each. 40,000 reads a second at peak, about 50 product updates a second. Prices must be correct within a minute. Stock is checked again at checkout.
1. Requirements and the staleness budget. Product details may be minutes old, price at most 60 seconds; stock is advisory on the page and authoritative at checkout. Writing these down is half the design.
2. Estimates.
- Data: 5M × 2 KB = 10 GB, about 11.5 GB with Redis overhead. That fits a small cluster, so cache everything that’s requested.
- Misses: with everything cacheable, misses come from expiry and invalidation. Each key that’s read often enough to stay cached costs about one miss per TTL. Say a million products are that popular: with a 10-minute TTL that’s 1,000,000 / 600 ≈ 1,700 misses a second; with a one-hour TTL, about 280, plus 50 a second from updates. The longer TTL is affordable because writes invalidate explicitly.
- Database: about 330 reads a second instead of 40,000. Easy for one Postgres primary with a replica.
3. Design. Cache-aside on Redis (three shards, one replica each), keyed product:{id}:v{schema}, TTL one hour. Writes update Postgres, then delete the key after commit. A small in-process L1 (two seconds) in each API server for the hottest products.
4. The price problem. With a one-hour TTL, a lost invalidation shows a wrong price for an hour, breaking the 60-second rule. Deliver invalidations reliably (an outbox row in the same transaction, applied by a worker), and also give price its own key with a 30-second TTL as the backstop.
5. Deep dives the interviewer may pick.
- Flash sale: one product gets 20,000 reads a second. L1 absorbs most of it; single-flight plus early refresh on that key stops a stampede when it expires.
- Redis outage: the database can’t take 40,000 reads a second, so fail open behind a concurrency limit, serve L1 entries past their TTL, and shed load with 503.
- New region or flushed cluster: warm the top keys from logs, shift traffic gradually.
- CDN: anonymous product pages can be cached at the edge with
s-maxage=30andstale-while-revalidate, which takes most read traffic off the API entirely. - Observability: per-cache hit ratio, database reads per second, evictions, and price-staleness sampling.
Exercise · ExpertCache a URL shortener
Design the caching for a URL shortener’s redirect path. 100 million short links, 50,000 redirects a second at peak, very skewed (a few links go viral). Links never change, but a link reported as abuse must stop redirecting within one minute. Random scanners request codes that don’t exist. Cover the layers, TTLs, the HTTP status and headers, the takedown, missing codes and a viral link.
Show solution
A strong answer covers these points:
- Memory. About 500 bytes an entry with overhead: 50 GB for all 100M, 5 GB for the hottest 10%, which under heavy skew serves most redirects. Cache on demand with LRU or LFU.
- Layers. Redis (cache-aside by short code) behind an in-process L1. Links are immutable, so TTLs could be long, but the takedown rule caps them.
- The takedown sets the TTLs. Anything that can’t be invalidated reliably must expire within a minute: L1 at 30 seconds. Redis keeps a long TTL and is deleted on takedown after the database commit, with pub/sub to drop L1 copies.
- HTTP status matters. Browsers cache a
301 Moved Permanentlyfor a very long time unless told otherwise, so a takedown might never reach a browser that already has it. Use302(or307) withCache-Control: public, max-age=60(or less), so browsers and any CDN forget within the minute. - CDN (optional):
s-maxage=60and a purge on takedown takes viral links off your servers. - Missing codes. Negative-cache “not found” for 30–60 seconds and delete the entry when a code is created. Under LRU, scanners’ junk entries are evicted first.
- Viral link. L1 absorbs it; single-flight on L1 misses keeps a restart from stampeding Redis.
- Database load. 2,500 reads a second at 95%, 500 at 99%. Size for the cold-cache case or protect it with a concurrency limit.
- Metrics. Hit ratio per layer, negative-cache size, and takedown-to-effect time, measured with test takedowns.
In production: the caches behind ezychat.io and this site
Two sets of real decisions: the CDN headers on this site, which you can check yourself, and the caches inside ezychat.io, whose code comments record why each TTL is what it is. Excerpts are trimmed from the real code.
This site: two Cache-Control policies and one invalidation
tarif.dev is static HTML in an S3 bucket behind CloudFront. The deploy workflow uploads it in two passes with different headers, then clears the CDN:
# Hashed build assets never change: cache them for a year.
- name: Upload fingerprinted assets
run: |
aws s3 sync dist/_astro s3://tarif-dev-portfolio/_astro \
--delete \
--cache-control "public, max-age=31536000, immutable"
# Everything else (HTML, feeds, sitemap, OG images, CV) must revalidate,
# so visitors and crawlers always get the current version.
- name: Upload pages and files
run: |
aws s3 sync dist s3://tarif-dev-portfolio \
--delete \
--exclude "_astro/*" \
--cache-control "public, max-age=0, s-maxage=86400, must-revalidate"
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
/_astro/files get a year andimmutable. Astro puts a content hash in every built file’s name (the home page portrait isportrait.DPDHmL-F_1KEcFr.jpg), so a URL’s content never changes.immutabletells browsers not to revalidate even on reload; Firefox and Safari honor it, and Chrome reached the same result by changing how reload works.- Everything else gets
max-age=0, s-maxage=86400, must-revalidate. HTML can’t carry a hash in its URL, so browsers check every time, and because S3 sends an ETag the check is a cheap 304. CloudFront may keep its copy for a day, so the check is answered at the edge.must-revalidateforbids serving a stale copy without checking. - The invalidation makes the day-long
s-maxagesafe. Without it, CloudFront could serve yesterday’s HTML for a day after a deploy. One wildcard path is simpler than working out which pages changed, and since the workflow also runs daily so future-dated posts appear on their day, “everything may have changed” is the honest assumption. - Assets, then pages, then invalidation. New HTML names new asset files, so those must be in the bucket first.
You can watch it work. While writing this, I requested the home page twice: x-cache: Miss from cloudfront, then x-cache: Hit from cloudfront. Sending its ETag back in If-None-Match got HTTP/2 304, x-cache: Hit from cloudfront and age: 9: the edge answered from a nine-second-old copy without touching S3. The portrait came back with cache-control: public, max-age=31536000, immutable.
One trade-off I accept: --delete removes the previous build’s hashed files, so an old tab that lazily requests an old file would miss. The site uses full page loads, so every navigation brings fresh HTML that names the new files; a long-lived single-page app should keep old assets around for a while.
ezychat.io: the model policy, cached for a minute
Which model handles which step of an AI reply is data in ezychat.io: a model_policies table changed only by database migrations, as the production note on pinning model IDs explains. Every AI reply resolves it, so it’s read constantly and written almost never:
_POLICY_TTL = 60
async def get_active_policy(tier: str) -> ResolvedPolicy:
"""Redis-cached active policy row for a tier; fail-open to the DB."""
try:
redis = get_redis()
raw = await redis.get(_policy_key(tier))
if raw is not None:
return ResolvedPolicy.from_json(raw)
policy = await _load_from_db(tier)
await redis.set(_policy_key(tier), policy.to_json(), ex=_POLICY_TTL)
return policy
except Exception:
return await _load_from_db(tier)
The docstring explains why a TTL, not explicit invalidation:
model_policiesis platform-global reference data written only by migrations/platform tooling, so the cache is a short-TTL row snapshot per tier (the authz_VER_TTLprecedent: out-of-band writers can only be picked up by expiry, and 60s bounds that window) rather than a version-key pair.
A migration runs outside the application, so no app code is there to delete the key. The TTL is the invalidation, and 60 seconds is the promise: a new policy serves within a minute, with no redeploy. An invalidate_policy_cache() exists for in-app tooling, and its docstring is clear about which mechanism carries the weight: “expiry covers the migration path regardless.”
Is it a stampede risk? There are two keys, one per tier, read by every AI reply, so they’re as hot as keys get. But a miss costs one indexed single-row read, so a few dozen concurrent misses barely register. That’s why I’d add no lock here: stampede protection earns its complexity when the rebuild is expensive.
Versioned snapshots for permissions and plans
Permissions and plan entitlements are read on nearly every request. Both caches use the version-key design from Intermediate: the database holds a version per organization that every relevant change bumps, a small Redis key caches that version, and a cached snapshot is used only if it was built from the same version. In-app changes delete the version key, so every snapshot for that organization stops matching at once.
The version key has a short TTL, and the comment above it records why:
The version key must stay short-lived: in-app permission mutations delete it via invalidate_org, but migrations (and any other out-of-band change) can only bump organizations.perm_version in the database. A long-lived cached version would keep validating stale snapshots against itself until it expired — with 24h this left every org’s permissions frozen for up to a day after a permission-seeding migration. 60s bounds that window to a minute at the cost of one single-row read per org per minute.
That’s the TTL-backstop rule, learned from the version that didn’t have a short one.
The entitlement cache also puts the shape of its JSON in the key (ent:v3:{org_id}). Its comment is the schema-drift story from Advanced:
nothing tracked the layout of the JSON until a cached entry outlived a code change that renamed a limits key, and a stale entry validated itself against a perfectly current
evand then raised KeyError deep inside a request.So the schema rides in the key: an entry written by older code is not looked up at all, it simply ages out on its own TTL.
The comment logs each bump; for version 3 it notes that without one, “every org with a warm cache raises KeyError on the first comment answered, for up to the cache TTL.”
Invalidate after the commit, not before
The module that switches product features on and off per organization caches their state under one key per organization. Its invalidation helper describes the update/invalidate race better than I can:
Invalidating before the commit is not enough, and the gap is not theoretical.
db.app_session()holds the transaction open until the FastAPI dependency closes — i.e. after the handler has returned — so between an in-handlerinvalidate()and that commit there are several more round trips (readiness recompute, entitlement read, disable counts, serialization). Any concurrent reader in that window — another request’s route guard, the nav shell, the outbox consumer’s beat tick — misses the cache, reads the old still-committed row, and repopulates the key withex=60.
The consequence it spells out: for up to a minute, a module the admin just disabled reports as enabled, and the public booking page keeps taking bookings. So the write path deletes twice, eagerly and from an SQLAlchemy after_commit hook, “which is the one that actually closes the window.” The module also explains why it skips version keys: one writer path and one key per organization, “so a direct DELETE of that single key on every write is already precise invalidation.”
Keys that carry everything that changes the answer
The retrieval pipeline behind ezychat.io’s AI answers has two caches, both lessons in key design. The query-embedding cache rules out the approximate matching discussed in Expert:
Exact-match by construction: the key is the sha256 of the normalized query text plus the embedding model id, so identical questions reuse their vector and nothing approximate is ever served. Normalization is case-fold + whitespace-collapse only — anything smarter (stemming, typo folding) would make two different queries share a vector, which is exactly the accuracy risk this cache design forbids.
The result cache stores which knowledge-base chunks answered a question, under ai:rr:{org_id}:{kb_version}:{model}:{scope}:{qhash}. The organization keeps tenants apart. The kb_version is bumped “in the same transaction” as any change retrieval can see, so no cached result outlives an edit. The scope token exists because “two employees with different collection grants must never share cached results.” Degraded (unreranked) results are never cached; empty ones are, because, as its handbook page puts it, “a repeat miss costs nothing”: negative caching, invalidated by the version in the key.
Other caches follow the habit. The WhatsApp template list is keyed by organization and channel, because “one tenant’s approved names must never be served for another’s.” A cache of model answers for tuning sessions keys on a hash of the whole prompt (“The prompt is its own version”), because a hand-assembled key would be “a second description of the prompt, and the first time the two disagreed the cache would answer a question nobody asked.”
Fail open, because the database is the truth
The Redis caches quoted here all fall back to the database (or the vendor) when Redis has trouble. The rate limiter’s docstring states it as a codebase-wide rule, “matching every other Redis path in this codebase (caches, circuit breaker, debounce),” and the entitlement module says what it means for caches:
“Fail-open to DB” is availability language, not security: DB resolution is the ground truth, so a Redis outage degrades latency, never correctness.
That’s only true because the caches are built to be losable. The booking availability cache is “advisory only”: the booking is always checked again inside the database transaction, “the only source of truth for whether a slot is actually free.” A cache you can lose without being wrong is a cache you can fail open on.
The same module has a real latency number. Availability search used to fetch each (resource, day) entry with its own GET, and “the round trips — ~2.7 ms each — were the dominant cost of an availability query.” One MGET and one write-back pipeline made it “two, whatever the window.” When a request needs many entries, batching can matter more than the hit ratio.
One Redis, three jobs
In ezychat.io’s Terraform, the Redis replication group’s description is “sessions, queues, and caches,” and the alarm on evictions fires on the first one. Its comment:
Redis evictions mean the node is discarding keys to stay inside maxmemory. On this stack that is not a cache miss — the Celery broker lives here, so an eviction can silently drop queued work.
That’s the queue experiment from Advanced as a production concern. The cache writes in this section all pass an expiry (ex=), so cache entries clean themselves up whatever happens to memory.
Prompt caching on Gemini
Production AI on ezychat.io runs on Gemini, which caches long repeated prompt prefixes implicitly. From the adapter:
Gemini uses implicit caching keyed on the request prefix, so the adapter’s whole job is preserving the frozen-first block ordering
ModelRequestalready guarantees — frozen system blocks become the head ofsystemInstruction, tools follow deterministically. No explicit CachedContent objects at this stage: the stable block sizes in this pipeline are below the point where explicit context caching pays for its storage-per-hour cost.
The prompt compiler’s docstring promises “Zero volatile bytes: no timestamps, no org names, nothing that varies between renders” in the prefix, with per-turn context “after the cache boundary.” Because one changing byte would silently zero the discount, the daily cost ledger tracks a cache-read share per organization (cached over input tokens), and names what a busy organization stuck near zero means: “a volatile byte before the cache boundary silently zeroing the prompt-cache hit rate.” The price sheet notes cached input at a tenth of the input price for the Gemini models it lists.
Exact response caching appears where the task allows. Reading a document image is “a pure function of the pixels and the checklist it was read against,” so readings are cached by a hash of both, per organization, because “one afternoon of testing spent forty model calls re-reading four documents.”
What I’d copy
- Write the staleness budget next to every TTL, and keep a TTL even where you invalidate explicitly. The day-long backstop on permissions is the cautionary tale.
- Delete after commit, not before, and delete rather than set.
- Put everything that changes the answer into the key: tenant, permission scope, data version, schema version, model and prompt version. Hash the key if it gets long, but hash all of it.
- Fail open only when the cache is advisory. If losing the cache can make you wrong, it isn’t a cache any more.
- Use one cache policy per kind of file at the CDN: fingerprinted forever, everything else revalidated, and an invalidation on deploy.
- Alarm on evictions whenever cache entries share memory with anything you can’t afford to lose.
Cheat sheet
| Topic | The short version |
|---|---|
| When to cache | Repeated reads, expensive misses, tolerable staleness. A cache saves what the miss costs. |
| Hit ratio | Database load = reads × (1 − h). Each extra nine cuts it tenfold. |
| TTL | A promise about staleness. Always set one, even with explicit invalidation. |
| Cache-aside | Read: cache, else DB and fill. Write: DB first, then delete the key. The default. |
| Write-through | Write cache and DB together. Fresh reads, caches unread data. |
| Write-behind | Cache now, DB later in batches. Fast, can lose writes. Counters only. |
| Invalidation | TTL (backstop), delete (precise), versioned keys (one bump invalidates many). |
| Eviction | LRU by default; LFU only with aging; FIFO rarely. Skew matters more than policy. |
| HTTP | Hashed assets: max-age=31536000, immutable. HTML: max-age=0, s-maxage=… plus purge on deploy. no-cache ≠ no-store. Personal data: private. |
| Stampede | Single-flight lock (SET NX PX, compare-and-delete), serve stale, or early refresh (XFetch). |
| Negative caching | Cache “not found” briefly; delete on create. Never cache errors. |
| Hot keys | In-process L1, key replicas, --hotkeys with an LFU policy. |
| Stale-set race | TTL, delete after commit, versions, or leases. |
| Serialization | Decoding can cost more than the round trip. No pickle in shared caches. Schema version in the key. |
| Redis memory | maxmemory + policy. Default is noeviction. Shared node: volatile-*, TTL on every cache key, alarm on evictions. |
| Layers | Staleness adds up across layers. Invalidate bottom up. Short L1 TTLs back up lost messages. |
| Security | Key on everything that changes the response; Vary; private/no-store for personal pages. |
| Sharding | hash % N moves most keys on resize; consistent hashing or slots move few. |
| LLM apps | Stable prompt prefixes first; exact-match response caches with full keys; semantic caching with great care. |
Check yourself
0 / 8
-
A cache in front of your database has a 99% hit ratio at 30,000 reads a second. How many reads a second reach the database, and what happens if the cache is flushed?
Answer: 300; the database sees 30,000. Misses are 1% of 30,000. With an empty cache every read is a miss, a hundredfold jump, which is why a very effective cache becomes load-bearing.
-
In cache-aside, why does the write path delete the key instead of writing the new value into the cache?
Answer: Two racing writers can set values in the wrong order; a delete is safe in any order. Sets from concurrent writers can land in the opposite order from their database commits. After a delete, the next reader fetches whatever the database says now.
-
Popularity shifts suddenly to a new set of keys. Which eviction policy, without aging, keeps the worst hit ratio afterwards?
Answer: LFU. Old favourites hold large use counts, so every newcomer is the next to be evicted. In the simulation LFU fell from 66% to 5% while LRU stayed at 58%.
-
What does
Cache-Control: no-cachetell a browser?Answer: Store it, but check with the server before each use.
no-cacheforces revalidation. The directive that forbids storing isno-store. -
A key read 300 times a second takes 400 ms to rebuild. With no protection, roughly how many database queries run when it expires?
Answer: About 120. Every request that arrives during the 0.4 s rebuild misses and runs its own query: 300 × 0.4 = 120.
-
A Redis node holds both cache entries (all with TTLs) and a job queue (no TTL). Which
maxmemory-policyprotects the queue?Answer:
volatile-lru. Volatile policies only evict keys that have a TTL. In the experiment,allkeys-lruevicted all 100 queued jobs andvolatile-lrukept them. -
Why does tarif.dev send HTML with
max-age=0, s-maxage=86400and invalidate/*on every deploy?Answer: So CloudFront answers browsers' revalidations for a day, and a deploy makes it fetch the new pages. Browsers check every time, cheaply, against the nearby edge; the edge keeps a day-long copy; the invalidation drops that copy the moment new HTML is uploaded.
-
A semantic cache returns a cached answer for any question with similarity above 0.85. What's the main risk?
Answer: Different questions that look alike, such as cancel versus change a booking, get the wrong answer. Similarity isn't sameness. A threshold loose enough to catch rephrasings also catches near-miss questions, and the user gets a confident wrong answer.