◐ ai-teacher

🐍 Backend with Python · Chapter 11

Caching: where, how long, and how to invalidate

A cache is a copy of the truth that is allowed to be wrong for a while — your whole job is deciding how wrong, for how long, and who gets told when it changes.

Study this lesson free in the app →

The basics

A cache is a fast copy of an answer you already computed, kept so the next request skips the slow work — a database query, a partner API call, a heavy render.

The problem it solves: the same expensive question gets asked thousands of times a minute, and the answer barely changes.

The mental model: every cached value is a photocopy with an expiry stamp. The original (your database) is the truth. The copy is cheaper to read but can drift. You must decide three things, and each is a trade-off, not a default:

  1. Where the copy lives — the browser, a CDN, your process's memory, or a shared Redis.
  2. How long it may be stale — the TTL (time to live).
  3. How it dies early when the truth changes — invalidation.

Words you must know 4

Hit / miss
found in cache vs. had to go to the source. The hit ratio decides how much load the database still sees.
Cache-aside
the app checks the cache, and on a miss reads the DB and fills the cache. The default pattern.
ETag
a fingerprint of a response; the client sends it back and gets a bodiless 304 Not Modified if nothing changed.
Stampede
a hot key expires and hundreds of requests hit the database at the same instant.

Explained

The specials board at a Udupi hotel

Lunch rush. Twenty waiters, one kitchen. If every waiter walked into the kitchen to ask "is the bisi bele bath still on?" the cook would never cook.

So the cook writes the day's specials on a chalkboard by the door. Waiters read the board — fast, no queue. That board is a cache.

Every caching decision in a backend is one of these four.

Where a cached answer can come from

The closer to the user, the faster the hit and the harder it is to take back. You can delete a Redis key in a millisecond; you cannot reach into a phone and delete what max-age=86400 told it to keep for a day.

A TTL cache, line by line

Ran with fake time: 1450, 1450 at +29.9 s, None at +30 s, and the dict is empty afterwards.

The price that would not change

from functools import lru_cache

PRICES = {"masala-dosa": 80}

@lru_cache(maxsize=2)
def price(item):
    return PRICES[item]

print(price("masala-dosa"), price("masala-dosa"))
PRICES["masala-dosa"] = 95
print(price("masala-dosa"))
print(price.cache_info())

HTTP caching: Cache-Control, ETag and 304

@app.get("/menu")
def menu(request: Request):
    body = json.dumps(MENU, sort_keys=True).encode()
    etag = '"' + hashlib.sha256(body).hexdigest()[:16] + '"'
    headers = {"ETag": etag, "Cache-Control": "public, max-age=60"}
    if request.headers.get("if-none-match") == etag:
        return Response(status_code=304, headers=headers)
    return Response(body, media_type="application/json", headers=headers)

@app.get("/me/wallet")
def wallet(response: Response):
    response.headers["Cache-Control"] = "no-store"
    return {"balance_paise": 125000}

With FastAPI's TestClient, this printed:

200 public, max-age=60 "f35f0525ab68d85f" 73   # first fetch: 73-byte body
304 0                                           # sent If-None-Match: empty body
200 True                                        # menu changed → new ETag, full body
no-store                                        # wallet: never stored anywhere

max-age=60 = reuse without asking for 60 s. ETag = after that, ask "has it changed?" and get a cheap 304. no-store for anything personal or money-shaped.

Cache-aside, write-through, and the two ways to invalidate

Cache-aside (lazy): read cache → on miss read DB → store. Writes go to the DB and then delete the key.

def update_city(key, city):
    db[key]["city"] = city
    cache.pop(key, None)        # delete, don't overwrite

Run: Pune Pune db reads: 1 → update → Kochi db reads: 2.

Why delete instead of set? Two writers race: A writes DB, B writes DB, B sets cache, A sets cache — the cache now holds A's older value until TTL. A delete can't go stale; worst case is one extra miss.

Write-through: every write updates DB and cache together. Reads are always warm, writes are slower, and you cache things nobody reads.

Invalidation strategies, cheapest first:

Diwali sale, 8:00:00 PM IST

The home-feed key had a 300 s TTL, set when the sale banner went live at 7:55. At 8:00:00 it expired. Traffic: 18,000 req/s. Rebuilding the feed: a 400 ms query.

In those 400 ms, ~7,200 requests missed and each fired the same query at a 50-connection Postgres pool. Queries queued, the rebuild took 9 s, and everything else on that DB timed out with it.

The same shape, reproduced with asyncio — 500 concurrent readers of one cold key:

async def get_locked(key):
    if key in cache:
        return cache[key]
    async with locks.setdefault(key, asyncio.Lock()):
        if key in cache:            # filled while we waited
            return cache[key]
        cache[key] = await load_from_db(key)
        return cache[key]
naive  db calls: 500
locked db calls: 1

Fixes, in order: one rebuilder per key (the lock above, per process; a Redis SET NX lock across pods), jittered TTLs (300 s ± 10% put at most 196 of 10,000 keys in any one second), and serve-stale-while-revalidating for hot keys.

Gotchas

Words to know

TTLTime to live — how long a cached value may be served before it counts as expired.
Cache-asideThe app reads the cache, falls back to the DB on a miss and fills the cache; writes invalidate the key.
Write-throughEvery write updates the DB and the cache together, so reads are always warm.
ETagA fingerprint of a response. The client returns it in If-None-Match; the server replies 304 with no body if unchanged.
Cache stampedeA hot key expires and many concurrent misses all rebuild it at once, overloading the source.
Single-flightCollapse concurrent requests for the same key into one in-flight load whose result everyone shares.
Hit ratioHits ÷ total lookups. The source sees traffic × (1 − hit ratio).

In two minutes

Test yourself

1. What does a function decorated with functools.lru_cache return after the database row it read has changed?

  1. The new value — lru_cache expires entries after 128 seconds
  2. The new value — lru_cache re-checks its source on every call
  3. The old value, until that entry is evicted by size or cache_clear() is called
  4. It raises a StaleCacheError
Show the answer

The old value, until that entry is evicted by size or cache_clear() is called — lru_cache keys on arguments only and has no TTL. The 128 is maxsize (entries), not seconds — that's the tempting wrong option.

2. Why should a cache-aside write path delete the key instead of setting the new value?

  1. Two concurrent writers can set the cache in the wrong order and leave an older value cached; a delete can't go stale
  2. Deleting is faster than setting in Redis
  3. Setting a key resets its TTL to zero
  4. Redis forbids overwriting keys
Show the answer

Two concurrent writers can set the cache in the wrong order and leave an older value cached; a delete can't go stale — Writer A and B both update the DB; if A's cache-set lands after B's, the cache holds A's older value until TTL. A delete just causes one extra miss that reads the current truth.

3. Why add random jitter (say ± 10%) to cache TTLs?

  1. To make cache hits faster
  2. To stop attackers guessing keys
  3. Redis requires non-round TTLs
  4. So keys written together don't all expire in the same second and stampede the database together
Show the answer

So keys written together don't all expire in the same second and stampede the database together — Keys filled at the same moment (deploy, warm-up) expire at the same moment. Jitter spreads the rebuilds; in our run, 10,000 keys with 300 s ± 10% put at most 196 in any one second.

4. How does an ETag let a server skip sending an unchanged response?

  1. The server stores the ETag and pushes updates to clients
  2. The client sends the ETag back in If-None-Match; if it still matches, the server returns 304 with an empty body
  3. The browser compares ETags locally and never contacts the server
  4. The CDN rewrites the body to an empty string
Show the answer

The client sends the ETag back in If-None-Match; if it still matches, the server returns 304 with an empty body — A conditional GET still costs a round trip, but the body (often the expensive part) is skipped. Our run: 73-byte body first, 0-byte 304 on the second request.

5. Trap: what is the difference between Cache-Control: no-cache and no-store?

  1. They are synonyms
  2. no-cache forbids storing; no-store forbids revalidating
  3. no-cache allows storing but requires revalidation before every reuse; no-store forbids storing at all
  4. no-cache only applies to CDNs
Show the answer

no-cache allows storing but requires revalidation before every reuse; no-store forbids storing at all — The name misleads: no-cache responses ARE cached, they just can't be reused without asking the server (usually with an ETag). For wallets and tokens you want no-store.

6. What happens when 500 concurrent requests miss the same hot key in a naive cache-aside read?

  1. 500 identical database queries fire at once
  2. One query fires; the cache library queues the rest automatically
  3. The cache returns None to 499 of them
  4. Redis blocks all but the first
Show the answer

500 identical database queries fire at once — Nothing coordinates the misses. We measured it: naive = 500 DB calls, per-key asyncio.Lock with a re-check = 1.

There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →