Career OS

Databases 04 — Caching & Consistency

Your SplitEase group-balance endpoint hits the database on every load, and the same five friends refresh it forty times a day to see who owes what. That is the same expensive join, recomputed for an answer that barely changes between refreshes. A cache is how you stop paying for the same answer twice — and the moment you add one, you inherit the hardest problem in computer science: keeping two copies of the truth from drifting apart.

The Goal

By the end of this module you can:

  • Explain what a cache buys you (latency and database load) and what it costs you (a second copy of the truth that can lie)
  • Choose between cache-aside, write-through, and write-behind from the read/write shape of the workload, not from habit
  • Set a TTL on purpose and say what staleness window it accepts
  • Name the two hard problems — invalidation and the stampede — and apply a concrete fix for each
  • Justify Redis as the default cache and read its docs to back the choice
  • Place any system on the consistency spectrum from strong to eventual, and say where caching forces you to sit

The Lesson

Why a cache exists at all

A read from PostgreSQL on a warm query is a few milliseconds; a read from Redis held in RAM is a few hundred microseconds, and it never touches your database’s CPU or disk. The cache is a small, fast layer of recently-used answers sitting in front of the slow, authoritative store.

Two things improve at once:

  • Latency drops — the user sees the answer roughly ten times faster.
  • Database load drops — the expensive query runs once and serves a thousand reads from memory, so your one Postgres instance survives traffic that would otherwise need a bigger machine.

The trade is the whole rest of this module: the cache is now a second copy of the truth. The database is the source of truth; the cache is a fast lie that is usually right. Every caching decision is really a decision about how wrong, and for how long, you can afford it to be.

Cache-aside — the default you will write 90% of the time

In cache-aside (also called lazy loading) the application code talks to both the cache and the database, and the cache knows nothing about your database. The flow on a read:

flowchart TD
    A[Request group balance] --> B{In cache}
    B -->|Hit| C[Return cached value]
    B -->|Miss| D[Query PostgreSQL]
    D --> E[Write result into cache with TTL]
    E --> F[Return value]

Read it line by line. Ask the cache first. On a hit, return immediately — the database never woke up. On a miss, fall back to PostgreSQL, then write that answer into the cache with a TTL so the next reader gets a hit. The cache fills itself lazily, only with data someone actually asked for.

Writes are the other half. In cache-aside you do not update the cache on a write — you delete the key (invalidate it), and let the next read repopulate it:

// pseudocode — the cache-aside write
UPDATE expense SET amount_paise = 50000 WHERE id = 42;   -- source of truth first
redis.del("group:7:balance");                            -- then evict the stale answer

Why delete instead of overwrite? Because computing the new cached value yourself is one more place to get it wrong, and two writers overwriting in the wrong order is a classic bug. Deleting is the safe move: throw the stale answer away and let the read path — which already knows how to compute it correctly — rebuild it on demand.

The three patterns, side by side

The pattern is defined by who writes the cache and when. There are three you must be able to name and place:

PatternOn a readOn a writeConsistencyBest when
Cache-asideApp checks cache, falls back to DB, fills cacheApp writes DB, then deletes the cache keyGood — cache only holds what was read; stale window until next readRead-heavy, the default for almost everything
Write-throughApp reads from cache (always warm for written keys)App writes cache and DB together, synchronouslyStrong-ish — cache and DB updated in lockstepWrites must be readable instantly; can tolerate slower writes
Write-behindApp reads from cacheApp writes cache only; cache flushes to DB later, asynchronouslyWeak — DB lags the cache; crash can lose writesExtreme write throughput where some loss is acceptable (metrics, counters)

The honest summary: cache-aside is what you reach for by default — simple, resilient (if the cache dies, reads just get slower, they don’t break). Write-through keeps the cache always-fresh for written keys at the cost of making every write do two writes. Write-behind is fast and dangerous — a crash between the cache write and the database flush loses data, so you only use it where losing some writes is genuinely fine. For money in SplitEase, write-behind is disqualified on sight.

TTL — the expiry date on every cached answer

TTL (time to live) is how long a key lives before Redis deletes it automatically. It is your safety net: even if you forget to invalidate, a stale value cannot outlive its TTL.

SET group:7:balance "..." EX 300     -- expires in 300 seconds

TTL is a deliberate trade, not a default you copy:

  • Short TTL (seconds): fresher data, more cache misses, more database load. Good for fast-changing data where staleness is visible to users.
  • Long TTL (hours/days): fewer misses, lighter database, but a longer window where the cache can be wrong. Good for data that rarely changes — a friend’s display name, a currency table.

The number you pick is a statement about acceptable staleness. “TTL 300s on the balance” means “I accept that a balance can be up to five minutes out of date.” Say that sentence out loud before you pick a number — if it sounds wrong for the data, the number is wrong.

The two hard problems

There are only two hard things in computer science: cache invalidation and naming things. — Phil Karlton

It is a joke that is also true. Here are both hard problems, made concrete.

Problem 1 — invalidation. When the underlying data changes, every cached copy derived from it is now a lie, and you have to find and kill all of them. Easy for one key (del group:7:balance). Hard when one write affects many cached answers: editing one expense changes that group’s balance, every member’s “what I owe” view, the group’s activity feed, maybe a leaderboard. Miss one key and a user stares at a wrong number with no error to explain it — the worst kind of bug, because nothing crashed. The defenses: keep cache keys derivable from the data that changed (so you know exactly what to evict), and lean on TTL as the backstop for the keys you forget.

Problem 2 — the cache stampede (the thundering herd). A hot key expires. In the microsecond after it vanishes, a thousand concurrent requests all miss, all fall through to PostgreSQL, and all run the same expensive query at once. The cache existed precisely to stop that query from running a thousand times — and at the moment of expiry it does exactly that, often hard enough to take the database down.

flowchart TD
    A[Hot key expires] --> B[1000 requests arrive in the same instant]
    B --> C[All miss the cache]
    C --> D[All hit PostgreSQL with the same query]
    D --> E[Database overwhelmed - the stampede]

Three real fixes:

  • Locking / single-flight: the first request to miss takes a lock and recomputes; the rest wait for it and read the freshly-set value. One database query instead of a thousand.
  • Stale-while-revalidate: serve the slightly-expired value immediately while one background task refreshes it. Users never see a miss.
  • Jittered TTL: add a random offset to each key’s TTL so a batch of keys created together do not all expire on the same tick — spreading expiries spreads the load.

Knowing these by name is an interview and on-call superpower: “the dashboard fell over at midnight” is almost always a stampede when a daily-TTL batch expired together.

Redis — the default, and why

When someone says “add a cache,” they almost always mean Redis. It is an in-memory key-value store: data lives in RAM (microsecond reads), with optional persistence to disk so a restart does not start cold. It is the default for honest reasons — not hype:

  • Speed: in-memory, single-threaded for commands, routinely 100k+ ops/sec on a small box.
  • Right data structures: not just strings — hashes, sorted sets, lists. A leaderboard is a Redis sorted set in three commands; a rate-limiter is an INCR with a TTL.
  • Built-in TTL and eviction: EX for expiry, and maxmemory policies (like allkeys-lru) that throw out least-recently-used keys when RAM fills, so the cache self-manages.
  • Ubiquity: every cloud has a managed Redis, every language has a client, every team has someone who knows it. Boring and proven beats clever and unknown.

Read the official patterns at redis.io/docs — the data-types and key-expiry pages are the two to start with. The free tier of a managed Redis (or running it in Docker locally — see the Docker track) is plenty to learn on.

A caution worth its own line: Redis defaults to RAM, and RAM is finite and not free. An eviction policy and a memory limit are not optional in production — without maxmemory, Redis fills RAM and the OS kills it, taking your cache (and the database it was protecting) down with it.

Where caching bit a real system

The generic-but-true story every backend engineer eventually lives: a profile page caches the user object with a 24-hour TTL. A user changes their display name; the write updates Postgres but the code forgot to evict the cache key. For 24 hours the user sees their new name on the edit screen (read straight from the DB) and their old name everywhere the cached object is used. No error, no crash, no log line — just a number, or a name, that is quietly wrong until the TTL saves you. Support cannot reproduce it because their cache holds a different value.

The lesson burned in: a cache turns a loud failure (slow query) into a silent one (wrong answer). That trade is usually worth it — but only if invalidation is treated as a first-class part of every write path, not an afterthought. The day you add a cache, “what do I evict?” becomes part of the definition of done for every write.

The consistency spectrum

Caching is one move on a bigger map. Consistency is the guarantee about whether everyone reading the data sees the same value at the same time. It is a spectrum, not a switch:

flowchart LR
    A[Strong consistency] --> B[Read-your-own-writes]
    B --> C[Eventual consistency]
    A -.->|"slower, simpler to reason about"| A
    C -.->|"faster, can read stale"| C
  • Strong consistency: every read returns the most recent write, always. A single PostgreSQL primary with no cache gives you this. Simplest to reason about, hardest to scale.
  • Read-your-own-writes: you always see your own changes immediately, even if others might briefly see the old value. The pragmatic middle — exactly the replication-lag trap from the last module, where you route a user’s reads to the primary right after they write.
  • Eventual consistency: all copies converge eventually, but a read right now might be stale. This is what a cache (and an async replica) gives you. Fast and scalable, but the application must be designed to tolerate a brief wrong answer.

A cache pushes you toward the eventual end: the cached copy lags the source of truth by up to its TTL. That is fine for a group’s activity feed and unacceptable for the balance shown on a “settle up — pay ₹4,200” button, where a stale number means the wrong amount of money moves. The skill is matching the consistency you choose to the cost of being wrong for that specific piece of data — cache the feed hard, never cache the number on the pay button, and you have made the spectrum work for you.

Check The Concept

How This Shows Up At Work

  • The silent-wrong-value bug. A user updates something, sees it correctly on the edit page, but the old value persists everywhere else for hours. No error, no crash. The first engineer to suspect “stale cache, missing invalidation” instead of staring at the database saves the afternoon. This is the single most common caching bug in production.
  • The midnight stampede postmortem. A dashboard or report falls over at the same time every day. The cause is almost always a batch of keys created together with the same TTL, all expiring on the same tick and stampeding the database. “Add jitter to the TTLs” is the one-line fix that makes you look senior.
  • The code-review question. A teammate adds a cache but updates the key on write instead of deleting it. You ask “what happens if two requests write at once?” — overwrite ordering is a real bug; deletion is the safe pattern. Being able to argue why delete-over-update is the seniority signal.
  • The interview staple. “How would you cache this endpoint, and how do you keep the cache from going stale?” A strong answer names cache-aside, picks a TTL with a stated staleness budget, and volunteers the stampede problem before being asked. Follow-up they love: “what if the cached data must never be stale?” — answer: then it should not be cached, or it needs write-through plus invalidation, and you accept slower writes.

Build This

You will run a real Redis locally and watch cache-aside behave — hit, miss, expiry, and a forced stale value — with your own eyes. Free, no cloud account.

  1. Start Redis in Docker (no install needed; needs Docker running):
docker run --rm -p 6379:6379 --name learn-redis redis:7

Leave that terminal running. In a second terminal, open the Redis CLI inside it:

docker exec -it learn-redis redis-cli
  1. Simulate a cache-aside read by hand. First a miss, then fill the cache, then a hit:
GET group:7:balance

Expected: (nil) — a miss. Now write the computed answer with a 30-second TTL (this is what your app does after falling back to the database):

SET group:7:balance "Asha owes 4200" EX 30
GET group:7:balance

Expected: "Asha owes 4200" — a hit. Check how long it has left:

TTL group:7:balance

Expected: a number counting down (e.g. 27). Wait past 30 seconds, GET again — back to (nil). You just watched a TTL expire a key automatically.

  1. Simulate an invalidation. Re-set the key, then delete it the way a cache-aside write would after updating Postgres:
SET group:7:balance "Asha owes 4200" EX 300
DEL group:7:balance
GET group:7:balance

DEL returns (integer) 1 (one key removed); the GET returns (nil). The next read in a real app would miss and refill with the correct new balance. That is the entire cache-aside write path in two commands.

  1. Break it on purpose #1 — create a stale value. Set a balance, then “change the database” in your head without deleting the key:
SET group:7:balance "Asha owes 4200" EX 300
GET group:7:balance

Imagine an expense was just edited and the real balance is now "Asha owes 5000" — but you forgot the DEL. Every GET for the next 300 seconds returns the wrong number, with no error. That is the silent-wrong-value bug, reproduced in your own terminal. The only thing saving you is the TTL.

  1. Break it on purpose #2 — feel the stampede. Set a key with a 5-second TTL, then imagine 1000 requests arriving at second 6. With one key gone, all 1000 miss and hit the database at once. You cannot easily spin up 1000 clients here, but say the fix out loud as you re-create the key with jitter — a random TTL between, say, 280 and 320 seconds — so a batch of keys never expires on the same tick:
SET group:7:balance "Asha owes 4200" EX 297

(In real code the 297 would be 300 + random(-20, 20).) Confirm with TTL group:7:balance. Spreading expiries spreads the load — that is the whole jitter fix.

  1. Clean up: exit the CLI, then docker stop learn-redis in the first terminal.

Interview Practice

Real questions, answers in the dropdowns — read each one out loud as if to an interviewer before you peek.

Q1 — Walk me through how you would cache an expensive read endpoint. Which pattern, and why?

Cache-aside, almost every time. The flow: on a read, check Redis first; on a hit, return it; on a miss, query PostgreSQL, write the result into Redis with a TTL, return it. On a write, update PostgreSQL first (source of truth), then delete the cache key so the next read refills it correctly.

Why cache-aside: it is simple, it only caches data that was actually requested, and it is resilient — if Redis dies, reads just get slower, they do not break, because the app always knows how to fall back to the database. I would state a TTL with an explicit staleness budget (“60 seconds, the feed can be a minute stale”) and call out the stampede risk on hot keys. See cache-aside in this module.

Q2 — Cache-aside, write-through, write-behind: when would you pick each?
  • Cache-aside for read-heavy workloads where some staleness is tolerable — the default for almost everything.
  • Write-through when written data must be readable instantly and you can accept slower writes — every write goes to cache and database synchronously, so the cache is always warm and consistent for written keys.
  • Write-behind only for extreme write throughput where losing some writes is acceptable (metrics, counters, view counts) — it writes to the cache and flushes to the database asynchronously, so it is fast but a crash loses the un-flushed writes.

For anything involving money, write-behind is out immediately because of the data-loss window.

Q3 — What is cache invalidation and why is it called one of the two hard problems?

Invalidation is removing or refreshing cached copies once the underlying data changes, so nobody reads a stale value. It is hard because one data change can invalidate many derived cache entries (editing one expense affects the group balance, each member’s “what I owe,” the activity feed), and missing even one leaves a silently-wrong value with no error to point you at it. The defenses: keep cache keys derivable from the data that changed so you know exactly what to evict, and use TTL as a backstop for the keys you forget. The “silent wrong answer” failure mode is why it is genuinely hard, not just tedious.

Q4 — Explain a cache stampede and three ways to prevent it.

A stampede (thundering herd) happens when a hot key expires and many concurrent requests all miss at once, all falling through to the database to run the same expensive query simultaneously — often hard enough to overload it. The cache existed to stop that query running repeatedly, and at the instant of expiry it does the opposite.

Three fixes: (1) single-flight/locking — the first miss takes a lock and recomputes while the rest wait and read the fresh value; (2) stale-while-revalidate — serve the slightly-expired value immediately while one background task refreshes it; (3) jittered TTL — add a random offset so a batch of keys does not all expire on the same tick. A dashboard that falls over at the same time daily is almost always a same-TTL batch stampeding.

Q5 — What is a TTL, and how do you decide the value?

TTL (time to live) is how long a cached key lives before it is automatically deleted. You decide it by stating the acceptable staleness for that data: short TTL (seconds) means fresher data but more misses and database load; long TTL (hours) means lighter database but a longer window of possibly-wrong data. The number is literally a sentence — “TTL 300s on the balance” means “I accept a balance up to five minutes stale.” If that sentence sounds wrong for the data, the number is wrong. Every cached key should have a TTL even when you also invalidate explicitly, because the TTL is the backstop for invalidation bugs.

Q6 — Why Redis and not just a HashMap in your application's memory?

An in-process HashMap is faster still, but it is per-instance: with three app servers behind a load balancer you get three independent caches that disagree, and invalidating one does nothing to the other two. Redis is a shared cache all instances talk to, so there is one consistent cached copy, one place to invalidate, and it survives an app restart. Redis also gives you TTL/eviction policies, the right data structures (sorted sets for leaderboards, INCR for rate-limits), and persistence. For a single-instance app a local map can be fine; the moment you scale horizontally, you need a shared cache. See why Redis is the default.

Q7 — Define the consistency spectrum and say where caching puts you.

Strong consistency means every read returns the latest write (a single Postgres primary, no cache). Read-your-own-writes means you always see your own changes even if others briefly see the old value (the pragmatic middle — route a user’s reads to the primary right after they write). Eventual consistency means all copies converge eventually but a read now might be stale. A cache pushes you toward eventual: the cached copy lags the source of truth by up to its TTL. The skill is matching the level to the cost of being wrong for each piece of data — cache a feed hard, never cache the number that decides how much money moves. This is the same lag trap as in replication & sharding.

Q8 — A user updated their profile but still sees the old value on most pages. Diagnose it.

Classic stale cache from a missing invalidation. The write updated the database (which is why the edit page, reading straight from Postgres, shows the new value) but did not delete the cached object, so every page rendering from the cache shows the old value until the TTL expires. Diagnosis steps: confirm the database has the new value, then check whether a cache key for that object exists and is stale; the fix is to evict (or update) that key on the write path. The tell is “correct on the edit screen, wrong everywhere else” — that split is almost always source-of-truth-fresh versus cache-stale.

Where to Practice

ResourceWhat to do thereHow long
redis.io/docsRead the “Data types” and “Key eviction” (TTL/maxmemory) pages with your Docker Redis open beside you40 min
redis.io/docsRead the “Client-side caching” / caching patterns material and map each to the table in this module25 min
postgresql.org/docsRead the “Transaction Isolation” page once to see what consistency the database itself guarantees before any cache20 min

Check Yourself

  1. What two things does a cache improve, and what one thing does it cost you?
  2. Walk the cache-aside read path on a miss — every step, in order.
  3. On a write, why does cache-aside delete the key instead of overwriting it?
  4. Which of the three patterns can lose committed data on a crash, and exactly when?
  5. What is a TTL, and why should even an explicitly-invalidated key still have one?
  6. Name the two hard problems and one concrete fix for each.
  7. What is a cache stampede, and why does jittering TTLs help?
  8. Where on the consistency spectrum does a cache put a piece of data, and how do you decide whether that is acceptable?
Answers
  1. It improves latency (memory reads are ~10x faster than the database) and database load (one query serves many reads). It costs you a second copy of the truth that can drift stale — a fast lie that is usually right.
  2. Check the cache → miss → query PostgreSQL → write the result into the cache with a TTL → return the value. The next reader then gets a hit.
  3. Because recomputing the new value in the write path is an extra place to get it wrong, and two writers overwriting in the wrong order is a real bug. Deleting is safe: throw the stale answer away and let the read path rebuild it correctly on demand.
  4. Write-behind. It acknowledges the write from the cache and flushes to the database asynchronously, so a crash in the window before the flush loses that data. That is why it is banned for money.
  5. TTL is how long a key lives before automatic deletion. Even with explicit invalidation, the TTL is the backstop for the day you forget to evict a key — it bounds the worst-case staleness instead of letting a wrong value live forever.
  6. Invalidation (fix: keep keys derivable from the changed data so you know what to evict, plus TTL as backstop) and the stampede (fix: single-flight locking, stale-while-revalidate, or jittered TTLs).
  7. A stampede is many requests missing a hot key at once and all hitting the database with the same query. Jittering TTLs spreads expiries across time so a batch of keys created together does not all expire on the same tick and stampede simultaneously.
  8. A cache puts data toward the eventual end — the cached copy lags the source of truth by up to its TTL. It is acceptable when the cost of being briefly wrong is low (an activity feed) and unacceptable when a stale value moves the wrong amount of money (a pay-this-amount button).

Explain it out loud: Explain to an empty chair why the SplitEase activity feed can be cached with a 10-minute TTL but the “settle up — pay ₹4,200” number must never be cached — walk through cache-aside, what a stale value would cost in each case, and where each one sits on the consistency spectrum. If you stall on “what do I evict on a write,” re-read the invalidation section.

Why AI Can’t Do This For You

AI will happily wrap any endpoint in a cache and pick a TTL out of the air. What it cannot do is know your data: which reads are hot enough to be worth caching, which writes invalidate which keys, and — the one that matters most — which pieces of your data can never be stale because a wrong value moves real rupees. Caching is not a code pattern you apply; it is a per-field judgment about the cost of being wrong, and that cost lives in your business, not in a prompt.

The bug a cache creates is silent: no crash, no log, just a number that is quietly wrong until a TTL saves you or a user complains. Finding that bug means understanding the whole data flow — source of truth, cache key, invalidation path — at 11pm with a confused user on the line. The engineer who can trace “correct on the edit screen, wrong everywhere else” to a missing DEL in one write path is worth more than any tool that adds caches, and that tracing skill is built by reasoning through your own system, not by generating one.

Module done? Add it to today’s tracker

Saves your progress on this device.