Home/Learn/FastAPI/Caching — Redis, TTLs & Invalidation

Caching — Redis, TTLs & Invalidation

Advanced
Async & Performance

Cache-aside with redis.asyncio: read the cache, miss → compute and SETEX with a TTL, write → invalidate. Design keys with versioned prefixes, guard hot keys against stampedes, and set HTTP Cache-Control for the layers you don't own.

Overview

When the same expensive query runs a thousand times a minute, you stop recomputing it. The workhorse pattern is cache-aside: try Redis first, on miss compute from the DB and store with a TTL, and on writes delete the affected keys so readers rebuild fresh data. Redis (async client, shared via lifespan like everything else) is the standard store because it is shared across workers and instances — an in-process dict cache holds N inconsistent copies under N workers, which is fine for static reference data and wrong for anything that changes. The two famous hard parts: invalidation (deleting the right keys on write — kept tractable by structured key naming and versioned prefixes) and the cache stampede (a hot key expires and 500 concurrent requests all hit the DB at once — bounded by per-key locks or short "recompute grace" windows). HTTP caching headers extend the same thinking to browsers and CDNs — cache layers you do not operate.

Cache-Aside with redis.asyncio

GET → miss → compute → SETEX, and DELETE on write. The decorator-free explicit version first — you should see every moving part once before hiding it.

GET → miss → SETEX; write → DELETE; TTL as the safety net
# pip install redis
import json
import redis.asyncio as aioredis
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.redis = aioredis.from_url("redis://localhost:6379/0",
                                        decode_responses=True)
    yield
    await app.state.redis.aclose()

app = FastAPI(lifespan=lifespan)

CACHE_VER = "v2"          # bump when the cached SHAPE changes (see below)

def drives_key(branch: str) -> str:
    return f"placement:{CACHE_VER}:drives:open:{branch}"     # structured, greppable

@app.get("/drives/open")
async def open_drives(branch: str, request: Request):
    r = request.app.state.redis
    key = drives_key(branch)

    if (cached := await r.get(key)) is not None:             # 1. try cache
        return json.loads(cached)                            #    sub-ms hit

    drives = await expensive_drives_query(branch)            # 2. miss → DB (200ms)
    await r.setex(key, 300, json.dumps(drives))              # 3. store, TTL 5 min
    return drives

@app.post("/drives", status_code=201)
async def create_drive(payload: dict, request: Request):
    drive = await insert_drive(payload)                      # write to DB first
    # 4. INVALIDATE — readers rebuild on next request:
    await request.app.state.redis.delete(drives_key(payload["branch"]))
    return drive

# TTL doctrine: every key gets one, even with explicit invalidation —
# TTL is the safety net for the delete you forgot. Hot + rarely changing
# → longer TTL; user-visible-fresh (seat counts) → 10-30s micro-TTL still
# absorbs 99% of reads at 1000 rps.

Keys, Stampedes & HTTP Cache Headers

Version your key prefix instead of hunting stale entries; single-flight a hot recompute; and use Cache-Control/ETag to recruit browsers and CDNs as your outermost cache tier.

Versioned prefixes, single-flight locks, public vs private headers
# ── Key discipline ──
# pattern:  app:{version}:{entity}:{qualifier}
#   placement:v2:drives:open:CS
#   placement:v2:student:1042:profile
# Deploy changes the cached JSON shape? bump v2 → v3: all old keys are
# instantly orphaned (and expire via TTL) — no scan-and-delete migration.
# Per-user caches: NEVER cache across users by accident — the user id
# belongs IN the key, or you serve Asha's profile to Ravi under load.

# ── Stampede: hot key expires, 500 requests hit the DB together ──
import asyncio
_locks: dict[str, asyncio.Lock] = {}

async def get_or_compute(r, key: str, ttl: int, compute):
    if (hit := await r.get(key)) is not None:
        return json.loads(hit)
    lock = _locks.setdefault(key, asyncio.Lock())
    async with lock:                          # one computer per key PER WORKER
        if (hit := await r.get(key)) is not None:   # re-check: someone filled it
            return json.loads(hit)
        value = await compute()
        await r.setex(key, ttl, json.dumps(value))
        return value
# Cross-instance single-flight → Redis SET key NX EX (a distributed lock),
# or serve the stale value while ONE request refreshes (stale-while-revalidate).

# ── The cache tier you don't run: browsers & CDNs ──
from fastapi import Response

@app.get("/courses")                          # public, same for everyone
async def courses(response: Response):
    data = await get_courses()
    response.headers["Cache-Control"] = "public, max-age=300"   # CDN + browser
    return data

@app.get("/me/orders")                        # private, per-user
async def my_orders(response: Response):
    response.headers["Cache-Control"] = "private, no-store"     # never shared!
    return await load_orders()
# A CDN caching a per-user response under a shared URL is a data breach,
# not a performance bug. public/private on Cache-Control is that line.

Key Points to Remember

  • 1Cache-aside: read cache → miss computes + SETEX with TTL → writes DELETE keys
  • 2Redis is shared across workers/instances; in-process dicts drift under multiple workers
  • 3Versioned key prefixes make shape changes safe; user ids belong in per-user keys
  • 4Stampedes need single-flight (locks / SET NX / stale-while-revalidate); Cache-Control recruits CDNs

Interview Questions

Sign in to ask Aria
1

Design caching for a results-day portal: 50k students refreshing marks — layers, TTLs, invalidation.

HardZerodha
2

What is a cache stampede and how do you prevent one on a hot key?

HardHotstar
3

A CDN served one user's order history to another — which header discipline failed?

HardFlipkart

Ask Aria about Caching — Redis, TTLs & Invalidation

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…