Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 11 of 20

Caching with Redis

Speed up an API by caching with Redis using the cache-aside pattern, keep the cache fresh with TTLs and invalidation, measure the speed-up, and build a simple rate limiter with atomic counters.

Every morning at 8 am, hundreds of Brew & Bean customers open the app and ask for the same thing: the menu. If every request runs a database query, you repeat identical work hundreds of times. A cache keeps a copy of an answer in a fast place so the next request can skip the slow work.

What you'll build: a FastAPI app where GET /menu is served from Redis after the first request, a price update that clears the stale copy, and a simple rate limiter built on a Redis counter. You will measure the speed difference yourself.

Prerequisites: Python 3.10+, basic FastAPI, and Redis running locally. The easiest way is Docker. Redis itself was introduced in *When to use NoSQL*.

What is caching, and when does it help?

Think of the water bottles in the fridge at the shop counter. Fetching one from the warehouse (the database) takes ten minutes, so you keep a few at the counter (the cache). Customers get theirs instantly. But you must refill the fridge, and a bottle might be past its date: caches have the same two problems, freshness and capacity.

Caching helps when data is read often, changes rarely, and is expensive to produce (a menu, product pages, a heavy report). It does not help for data that is different on every request or must always be exactly current (a bank balance, stock count at checkout).

Where caching can happen

Cache layerWhereExample
Browser / CDNNear the userCache-Control header on images and public GET responses
Application cacheA fast store your server talks toRedis holding the menu JSON
Database cacheInside the databaseQuery result and page caches, managed by the database itself

This lesson covers the application cache with Redis. The pattern is called cache-aside (or lazy loading):

Cache-aside

  1. 1A request arrives. Look for the key in Redis.
  2. 2Hit: return the cached value. Done, no database work.
  3. 3Miss: query the database.
  4. 4Store the result in Redis with an expiry (TTL).
  5. 5Return the result.

Start Redis

Terminal
docker run --name redis -p 6379:6379 -d redis

Starts Redis in the background on its standard port 6379. Stop it later with docker stop redis.

Terminal
docker exec -it redis redis-cli ping

Checks the server is up.

Expected output
PONG

No Docker? On Windows you can run Redis inside WSL, and on macOS with Homebrew; check the official Redis documentation for current install steps. Many cloud providers also offer a small free Redis tier. Terms and limits change, so check their pages.

Build the cached menu

Setup

  1. 1Create a folder cache-demo, make and activate a virtual environment.
  2. 2Install: pip install fastapi uvicorn redis.
  3. 3Save the code below as main.py.
  4. 4Run: uvicorn main:app --reload.
Terminal
pip install fastapi uvicorn redis
main.py
import json
import time

import redis
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
app = FastAPI(title="Brew & Bean cache demo")

MENU_KEY = "menu:v1"
MENU_TTL_SECONDS = 300
ORDERS_PER_MINUTE = 5

# Pretend this is a slow database
DB_MENU = {
    "espresso": {"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120},
    "cappuccino": {"id": "cappuccino", "name": "Cappuccino", "category": "coffee", "price": 180},
    "cold-brew": {"id": "cold-brew", "name": "Cold Brew", "category": "coffee", "price": 220},
    "masala-chai": {"id": "masala-chai", "name": "Masala Chai", "category": "tea", "price": 90},
}


def load_menu_from_db() -> list[dict]:
    time.sleep(0.5)  # simulate a slow query
    return list(DB_MENU.values())


@app.get("/menu")
def get_menu():
    # 1. Try the cache
    try:
        cached = r.get(MENU_KEY)
        if cached is not None:
            return {"source": "cache", "items": json.loads(cached)}
    except redis.RedisError:
        pass  # Redis down: carry on without the cache

    # 2. Cache miss: ask the database
    items = load_menu_from_db()

    # 3. Save for next time, with an expiry
    try:
        r.set(MENU_KEY, json.dumps(items), ex=MENU_TTL_SECONDS)
    except redis.RedisError:
        pass
    return {"source": "database", "items": items}


class PriceUpdate(BaseModel):
    price: int = Field(gt=0, le=5000)


@app.put("/menu/{item_id}/price")
def update_price(item_id: str, body: PriceUpdate):
    if item_id not in DB_MENU:
        raise HTTPException(status_code=404, detail="No such item")
    DB_MENU[item_id]["price"] = body.price  # 1. change the source of truth
    try:
        r.delete(MENU_KEY)  # 2. invalidate the stale cache
    except redis.RedisError:
        pass
    return DB_MENU[item_id]


def rate_limit(request: Request):
    """Fixed window: at most ORDERS_PER_MINUTE requests per client per minute."""
    window = int(time.time() // 60)
    key = f"ratelimit:orders:{request.client.host}:{window}"
    try:
        count = r.incr(key)  # atomic, even with many requests at once
        if count == 1:
            r.expire(key, 60)  # the counter cleans itself up
    except redis.RedisError:
        return  # fail open: do not block customers because Redis is down
    if count > ORDERS_PER_MINUTE:
        raise HTTPException(
            status_code=429,
            detail="Too many orders, please wait a minute",
            headers={"Retry-After": "60"},
        )


@app.post("/orders", dependencies=[Depends(rate_limit)], status_code=201)
def create_order():
    return {"status": "received"}

load_menu_from_db sleeps for half a second to stand in for a slow database. decode_responses=True makes Redis return text instead of bytes.

Measure the difference

Terminal
curl -s -o /dev/null -w "%{time_total}s\n" http://127.0.0.1:8000/menu
curl -s -o /dev/null -w "%{time_total}s\n" http://127.0.0.1:8000/menu
curl -s http://127.0.0.1:8000/menu

-w "%{time_total}" prints how long the request took. -s -o /dev/null hides the body. On Windows PowerShell use curl.exe and "%{time_total}" the same way.

Typical output (your times will vary)
0.54s
0.01s
{"source":"cache","items":[{"id":"espresso","name":"Espresso", ...}]}

The first request pays for the "database" (about half a second). Later requests come straight from Redis. You can also look inside Redis:

Terminal (redis-cli)
docker exec -it redis redis-cli
> GET menu:v1
> TTL menu:v1

GET shows the cached JSON; TTL shows the seconds left before it expires (at most 300 here).

The hard part: keeping it fresh

Phil Karlton's old joke says the two hard things in computer science are naming things and cache invalidation. If you change a price in the database but the cache still holds the old menu, customers see wrong prices. There are two basic tools:

TTL (expiry): every cached value dies after a set time. Simple and safe: the worst case is data a few minutes old. Pick the TTL by asking, "how stale can this be before it hurts?" A menu: minutes. A stock count: seconds or no cache.

Explicit invalidation: when you change the data, delete (or update) the cached copy. The PUT /menu/{item_id}/price route does this with r.delete(MENU_KEY). Always change the database first, then delete the cache, so a failure in between doesn't leave a new cache entry that contradicts the database.

Terminal
curl -X PUT http://127.0.0.1:8000/menu/espresso/price -H "Content-Type: application/json" -d '{"price":130}'
curl -s http://127.0.0.1:8000/menu

The first call changes the price and clears the cache; the second shows "source":"database" with the new price, then it is cached again.

Classic caching problems

ProblemWhat happensTypical fix
Stale dataCache shows an old value after the database changedShort TTL, delete the key on every write
Cache stampedeA popular key expires and hundreds of requests hit the database at onceAdd a little random jitter to TTLs; or let one request rebuild while others wait
Wrong keyCached data for user A returned to user BPut every input that changes the answer into the key, e.g. orders:user:42
Unbounded memoryCache fills RAMAlways set a TTL; configure a Redis maxmemory limit and eviction policy

Key design is a security issue

Never cache private, per-user data under a shared key, and be careful caching anything that depends on who is logged in. A cache key must include every input that changes the answer.

Redis for rate limiting

Redis counters are atomic, which makes them ideal for rate limiting: limiting how many requests a client can make, so a script cannot flood your endpoints. The rate_limit dependency in the code uses a fixed window: a key per client per minute, increased with INCR, expiring after 60 seconds. After 5 orders in a minute the client gets 429 Too Many Requests with a Retry-After header.

Terminal (bash)
for i in 1 2 3 4 5 6 7; do curl -s -o /dev/null -w "%{http_code} " -X POST http://127.0.0.1:8000/orders; done; echo

Sends seven order requests in a row.

Expected output
201 201 201 201 201 429 429

Fixed windows are simple but allow a burst at the boundary (5 at 10:00:59 and 5 at 10:01:00). More precise designs (sliding window, token bucket) use the same Redis primitives.

Plan for Redis being down

Both the cache and the limiter in the code wrap Redis calls in try/except redis.RedisError. Decide what should happen when Redis is down. For the menu cache, carrying on without it is right (slower, not broken). For a security rate limiter, you might choose to block instead. It is a business decision, so make it deliberately.

HTTP caching, briefly

You can also let browsers and CDNs cache responses. Add a header such as Cache-Control: public, max-age=60 to a GET response that is the same for everyone, and clients may reuse it for 60 seconds without asking your server at all. Never add it to responses with private data. See *HTTP in depth* for headers.

How this comes up in interviews

Common questions: "What is cache-aside?", "How do you invalidate a cache?", "What is a cache stampede?", "What would you cache in an e-commerce site and what would you never cache?", "Redis vs Memcached?" (Redis has richer data structures and persistence options; Memcached is a simpler memory cache), and "What if Redis goes down?" Answer with the trade-offs: freshness, memory, and failure behaviour.

Prompt · Decide what to cache
I run [describe your app]. Here are my endpoints and how often each is called and changes: [list]. For each endpoint, tell me whether to cache it, the cache key format, a sensible TTL, and what event should invalidate it. Flag any endpoint where caching could leak one user's data to another.

What to expect: A good answer caches only read-heavy, slow, shared data, includes user ids in per-user keys, and explains the risk for personalised endpoints.

Try it: Cache single menu items

Add GET /menu/{item_id} that returns one item. Cache each item under the key menu:item:<item_id> with a TTL of 120 seconds, and make update_price also delete that key.

Try it: Measure hit rate

Add two Redis counters, stats:menu:hits and stats:menu:misses, increased in get_menu. Add GET /stats returning both and the hit ratio. Call /menu 20 times and read it.

Key takeaways

  1. A cache stores a copy of an expensive answer in a fast place; it helps most for data that is read often and changes rarely.
  2. Cache-aside: check the cache, on a miss load from the database, store with a TTL, return.
  3. Freshness is the hard part: use TTLs and delete the key when the data changes (change the database first).
  4. The cache key must include every input that changes the answer, or you will leak data between users.
  5. Redis atomic counters give you rate limiting; decide deliberately what happens if Redis is down.

Check yourself

Finishing the quiz marks the lesson as done.

1. In the cache-aside pattern, what happens on a cache miss?

2. You update a menu price in the database. What is the safest order?

3. Which data is a poor candidate for caching?

4. What is a cache stampede?

5. Why is Redis's INCR command useful for rate limiting?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free