Cheatsheet for caching strategies in FastAPI.

HTTP cache headers

@app.get("/posts")
async def posts(response: Response):
    response.headers["Cache-Control"] = "public, max-age=60, s-maxage=120"
    return await db.list_posts()

max-age for browsers; s-maxage for shared (CDN) caches.

Conditional GET (ETag)

import hashlib

@app.get("/users/{id}")
async def get_user(
    id: int,
    response: Response,
    if_none_match: str | None = Header(None),
    db = Depends(get_db),
):
    user = await db.get(User, id)
    body = json.dumps(user.dict(), sort_keys=True).encode()
    etag = hashlib.sha256(body).hexdigest()[:16]
    if if_none_match == etag:
        return Response(status_code=304)
    response.headers["ETag"] = etag
    response.headers["Cache-Control"] = "private, max-age=60"
    return user

Last-Modified

from email.utils import format_datetime

if_modified_since: str | None = Header(None)

if user.updated_at <= parse(if_modified_since):
    return Response(status_code=304)

response.headers["Last-Modified"] = format_datetime(user.updated_at)

Redis cache (manual)

import json
from datetime import timedelta

async def get_user_cached(redis, db, user_id: int):
    key = f"user:{user_id}"
    if hit := await redis.get(key):
        return json.loads(hit)
    user = await db.get(User, user_id)
    if user:
        await redis.set(key, json.dumps(user.dict()), ex=300)
    return user.dict() if user else None

# Invalidate on write
async def update_user(redis, db, user_id, **changes):
    user = await db.get(User, user_id)
    for k, v in changes.items(): setattr(user, k, v)
    await db.commit()
    await redis.delete(f"user:{user_id}")

fastapi-cache (decorator)

uv add fastapi-cache2 redis
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache
from redis.asyncio import Redis

@asynccontextmanager
async def lifespan(app):
    app.state.redis = Redis.from_url("redis://redis")
    FastAPICache.init(RedisBackend(app.state.redis), prefix="myapp")
    yield

@app.get("/posts")
@cache(expire=60)
async def posts():
    return await db.list_posts()

Cache-aside pattern

async def get(key, fetch_fn, ex=300):
    if (h := await redis.get(key)) is not None:
        return json.loads(h)
    val = await fetch_fn()
    await redis.set(key, json.dumps(val), ex=ex)
    return val

posts = await get("posts:hot", lambda: db.list_hot_posts())

Stale-while-revalidate (SWR)

async def get_swr(key, fetch_fn, fresh_for=60, stale_for=600):
    raw = await redis.get(key)
    meta = await redis.get(f"{key}:ts")
    now = time.time()
    if raw and meta and now - float(meta) < fresh_for:
        return json.loads(raw)
    if raw and meta and now - float(meta) < fresh_for + stale_for:
        # Return stale; refresh in background
        asyncio.create_task(refresh(key, fetch_fn))
        return json.loads(raw)
    val = await fetch_fn()
    await redis.set(key, json.dumps(val))
    await redis.set(f"{key}:ts", str(now))
    return val

Invalidation via pub/sub

async def invalidate(key):
    await redis.delete(key)
    await redis.publish("cache.invalidate", key)

Other replicas listening clear local in-memory caches.

Per-user cache key

key = f"feed:{user_id}:{page_token}"

Always namespace by tenant + user.

Cache stampede protection

async def get_with_lock(key, fetch_fn, ex=300):
    if (v := await redis.get(key)) is not None:
        return json.loads(v)
    lock = f"{key}:lock"
    if await redis.set(lock, "1", nx=True, ex=10):
        try:
            val = await fetch_fn()
            await redis.set(key, json.dumps(val), ex=ex)
            return val
        finally:
            await redis.delete(lock)
    # Someone else is fetching — wait briefly + retry
    await asyncio.sleep(0.1)
    return await get_with_lock(key, fetch_fn, ex=ex)

Or: probabilistic early expiration to avoid synchronized renewals.

CDN caching (Cloudflare et al.)

response.headers["Cache-Control"] = "public, max-age=60, s-maxage=600"
response.headers["CDN-Cache-Control"] = "max-age=600"
response.headers["Cloudflare-CDN-Cache-Control"] = "max-age=600"

CDN-specific headers override the generic for that CDN.

When NOT to cache

  • User-specific data without per-user key.
  • Mutating endpoints (POST/PUT/DELETE).
  • Endpoints with auth that varies (set Vary: Authorization).

Read this next

If you want my fastapi-cache + Redis SWR reference, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .