Cache-aside: the default caching pattern

Caching and Redis Junior Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Order the steps

Order the cache-aside read path for user 42.

  • SET user:42 with a TTL
  • GET user:42 from Redis
  • Return the user to the caller
  • On a miss, SELECT the user from PostgreSQL

Choose an answer, then open Show answer below to check it.

Show answer

Answer: B D A C

  1. GET user:42 from Redis Hit returns immediately.
  2. On a miss, SELECT the user from PostgreSQL The database is the source of truth.
  3. SET user:42 with a TTL Next readers hit the cache.
  4. Return the user to the caller Same result either way.

30-second answer

The application owns the logic: read from the cache; on a miss, read from the database, store the result with a TTL, and return it. On writes, update the database and then delete the cache entry so the next read reloads it. It's simple and the cache holds only what's read. The costs: the first read after expiry is slow, concurrent misses can stampede the database, and a race between a read and a write can leave a stale value until the TTL.

Practise saying it · 30 s. No recording, just a timer

Explain cache-aside.

Say your answer out loud, then check which of these points you covered:

  • Read: cache, then DB, then fill
  • Write: DB, then delete key
  • TTL safety net
  • Stampede and stale races

Deep dive

Delete rather than update the cache on writes; updating from two writers can store an older value last.

Always set a TTL as a safety net for stale entries.

Cache misses for missing rows too (negative caching) or attackers can hammer the database with ids that don't exist.

Serialise values compactly and version the key format (user:v2:42) so a deploy can change the shape.

Cache-aside with a TTL Ran on Python 3.13.5 · mypy --strict
Python 3.13
import json
from typing import cast

import redis

r = redis.Redis(decode_responses=True)
DB = {42: {"id": 42, "name": "Ana"}}
db_reads = 0


def get_user(user_id: int) -> dict[str, object]:
    global db_reads
    key = f"user:v1:{user_id}"
    cached = cast(str | None, r.get(key))
    if cached is not None:
        user: dict[str, object] = json.loads(cached)
        return user
    db_reads += 1
    row = DB[user_id]
    r.set(key, json.dumps(row), ex=300)
    return dict(row)


get_user(42)
get_user(42)
ttl = cast(int, r.ttl("user:v1:42"))
print("db reads:", db_reads, "ttl > 0:", ttl > 0)

Common mistakes

  • Writing to the cache before the database commits.
  • Caching without TTLs, so a missed invalidation lives forever.
  • Using the cache as the only copy of data.

Senior take & follow-ups

Walk through read and write paths, then name stampedes, the stale race and TTLs.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why delete instead of set on write?

    Show hint

    Two concurrent writers can set values out of order; a delete just forces a reload.

  2. What's read-through?

    Show hint

    The cache library loads from the database itself on a miss; same idea, logic moved into the cache layer.

Suggest a fix if something here is wrong or out of date.