Learn why caches work, where to place them, how the cache-aside pattern, TTLs and eviction behave, and how to measure hit ratio without serving stale data.
What you'll learn: By the end of this lesson you will be able to explain why caching speeds up a system, decide where in the stack a cache belongs, implement the cache-aside pattern step by step, choose a TTL and an eviction policy, and recognise data that should not be cached.
A librarian gets the same question twenty times a day: "Where is the book on gardening?" The first time she walks to the shelves. After that she keeps a sticky note on her desk. Answering from the note takes two seconds instead of two minutes. But there is a risk: if the book is moved and she forgets to update the note, she confidently sends readers to the wrong shelf.
That is a cache: a small, fast store that keeps copies of results that are expensive to produce, so the next request can skip the expensive work. Everything in this lesson is a variation on two ideas: the speed-up is large, and the staleness risk is real.
The running example is an online marketplace. Thousands of shoppers open the same popular product pages, and each page view would otherwise trigger several database queries for details, seller, price and stock.
The core idea
A cache sits between a requester and a slower source of truth (a database, another service, a file system, or a heavy computation). When data is asked for:
Cache hit: the data is in the cache. Return it fast.
Cache miss: it is not. Fetch from the source, usually store a copy in the cache, then return it.
The hit ratio is hits divided by total requests. It decides how useful the cache is.
Approximate numbers show why this matters. Reading from memory takes on the order of 100 nanoseconds to a microsecond. A network round trip inside a data centre is on the order of a half millisecond. A database query that touches disk or does real work can take from a few milliseconds up to hundreds. So a hit from a nearby in-memory store is often many times faster than the database, and it takes load off the database too. These are rough orders of magnitude, not guarantees. (See estimation for how to use such numbers.)
Why caches work
Caches pay off because of locality, the habit of access patterns to repeat.
Temporal locality: something used recently is likely to be used again soon. A product featured in a campaign will be requested many times in the next hour.
Skewed popularity: a small fraction of items receive a large fraction of requests. Real traffic often has a "head" of hot items and a long tail of rarely requested ones. You can cache the head and serve most traffic.
Caches do not help when every request is unique or the data changes faster than it is read.
A simple model: suppose a cache hit takes 1 ms and a miss (cache check plus database read) takes 21 ms. These are illustrative assumptions. With a 90% hit ratio, the average is 0.9 x 1 + 0.1 x 21 = 3.0 ms. At a 50% hit ratio, it is 11 ms. Raising the hit ratio is where most of the benefit comes from.
Where to cache
A request can be answered at many layers. The closer to the user, the faster, and the bigger the saving.
Read it as a chain of chances to answer early: each box to the right is slower and more expensive, so every hit on the left saves the work of everything after it.
Layer
What it stores
Typical use
Cost or risk
Browser
Files and responses on the user's device
Images, scripts, stylesheets via HTTP cache headers
You cannot easily force a user's browser to refresh
Its own buffer pool (memory holding recently used table and index pages)
Hot pages of tables and indexes
Mostly automatic; sized by the database memory
Cache as close to the user as safely possible, and treat each layer as an optimisation that must be safe to lose. If everything in a cache vanishes, the system should be slower but still correct.
The cache-aside pattern
The most common pattern is cache-aside (also called lazy loading). The application owns the logic. The cache is a passive store.
Reading: ask the cache for key product:123; on a hit, return it; on a miss, read the row from the database, store it in the cache with a TTL (time to live, an expiry time), and return it.
Writing: update the database (the source of truth), then delete the cached entry so the next read refetches fresh data.
sequenceDiagram
participant C as Client
participant A as App
participant R as Cache
participant D as Database
C->>A: get product 123
A->>R: GET product:123
R-->>A: miss
A->>D: SELECT product 123
D-->>A: row
A->>R: SET product:123 with TTL
A-->>C: product 123
C->>A: get product 123 again
A->>R: GET product:123
R-->>A: hit
A-->>C: product 123
The first request pays for the database read and fills the cache; every request after it, until the TTL passes or the entry is deleted, is a hit. Here is a minimal Redis version, deliberately short.
import json
TTL_SECONDS = 600# assumption: ten minutes of staleness is acceptable for descriptionsdefget_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached isnotNone:
return json.loads(cached) # cache hit
row = db.query("SELECT * FROM products WHERE id = %s", product_id)
if row isnotNone:
redis.set(key, json.dumps(row), ex=TTL_SECONDS)
return row # cache miss pathdefupdate_title(product_id, title):
db.execute("UPDATE products SET title = %s WHERE id = %s", title, product_id)
redis.delete(f"product:{product_id}") # invalidate; next read repopulates
Why delete instead of update on a write? If two writers update the same row at nearly the same time, their cache updates can land in a different order from their database updates, leaving the older value in the cache until the TTL expires. Deleting is simpler and tends to be safer, though races remain (a reader that fetched the old row just before the write can store it just after the delete), which is one reason every entry also gets a TTL. We explore these races and other write strategies in advanced caching.
NOTE
Cache-aside is resilient: if the cache is down, the app can read from the database directly. It does mean the first request after a miss is slower, and a sudden mass of misses can hammer the database. That risk, called a cache stampede, is covered in the advanced lesson.
TTL and eviction
Two separate mechanisms control what stays in a cache.
TTL (time to live)
A TTL is an expiry time attached to an entry. When it passes, the entry is removed or treated as a miss. TTL is your safety net for staleness: even if you forget to invalidate, wrong data lives at most as long as the TTL. Choosing a TTL is a business question, not a technical one: how stale can this data be before someone is harmed or annoyed?
Data
Reasonable staleness
TTL idea
Product description and images list
Minutes; edits should show soon
Moderate TTL plus delete on update
Review count and average rating
Seconds to minutes
Short, 30 to 60 seconds
Price and stock shown on the page
Seconds at most
Very short, plus invalidate on change
Versioned image files
Never changes
Very long (a year)
Price and stock at the moment of payment
No tolerance
Do not cache; read the database
Eviction policies
A cache has finite memory. When it fills, it must evict something.
LRU (least recently used): discard the item not used for the longest time. A good general default because of temporal locality.
LFU (least frequently used): discard the item used least often overall. Good when a stable set is consistently popular; slow to forget items that were once hot.
FIFO discards the oldest entry, ignoring usage; random is cheap and sometimes adequate.
Many systems, including Redis, offer several configurable policies, and some use approximate versions of LRU or LFU to save memory. Check your cache's documentation for exact behaviour.
stateDiagram-v2
[*] --> Absent
Absent --> Fresh: miss, load and store
Fresh --> Expired: TTL passes
Expired --> Fresh: next read reloads
Fresh --> Absent: deleted on write
Fresh --> Absent: evicted under memory pressure
An entry is only ever safe to serve while Fresh. Every arrow out of Fresh is either a cost you chose (TTL, invalidation) or one you did not (eviction), and a healthy design copes with all of them.
TIP
Size the cache for the hot working set (the items actually requested often), not for all your data. If the hot 10% of products covers most traffic, a cache that holds 10% can reach a high hit ratio.
What to cache and what not to
flowchart TD
Q1{"Is it read far more often than written?"} -->|no| N1["Do not cache"]
Q1 -->|yes| Q2{"Can users tolerate slightly stale data?"}
Q2 -->|no| N2["Read the source of truth, or cache with strict invalidation"]
Q2 -->|yes| Q3{"Is the response the same for many users?"}
Q3 -->|yes| Y1["Cache with a shared key and a TTL"]
Q3 -->|no| Y2["Cache per user key, or skip the shared layer"]
Walk any candidate through these three questions. Good candidates are read far more than written, expensive to compute, and shared by many users. Poor candidates include data that must always be exact (inventory at checkout, permissions that must apply immediately), data rarely re-read, very large objects that evict many small hot ones, and anything where an error response could be cached by mistake and served for the whole TTL.
WARNING
Never include a user-specific response under a shared cache key. If the key for "my recommendations" is just recs, the next user may see someone else's. Include the user ID in the key, or do not cache it in a shared layer.
Staleness and invalidation basics
Cache invalidation has a reputation as one of the hard problems in computer science, and the reason is simple: the cache and the source of truth are two copies of the same fact, and keeping two copies in agreement is hard when either can change at any time. You have three basic tools, usually combined.
Expire by TTL. Easiest, guaranteed to converge, but allows staleness up to the TTL.
Invalidate on write. When the data changes, delete or update the cached copy. Fresher, but you must find every place that writes and every key affected (a product appears on its page, in category lists and in search results).
Version the key. Include a version or content hash in the key or URL, such as image_v7.jpg. When data changes you use a new key and the old one simply expires. This avoids invalidation entirely and suits static files.
Caching exchanges freshness for speed. Decide the freshness you can afford first, then choose the simplest tool that provides it.
Measuring a cache
A cache you do not measure may be hurting you. Track at least these:
Metric
What it tells you
Typical action
Hit ratio
Fraction of requests served from cache
Low: review keys, TTL, size, or whether the data is cacheable
Latency of hits and misses
Whether the cache is actually faster
Slow hits suggest network or serialisation overhead
Eviction rate
How often useful entries are pushed out
High evictions with a low hit ratio: cache too small
Memory usage
Capacity headroom
Resize or shorten TTLs
Database load
The real goal of most caches
If it did not drop, the cache is not doing its job
Error and timeout rate
Health of the cache itself
Ensure the app degrades gracefully when it fails
Also set a timeout on cache calls. A hung cache that blocks every request is worse than no cache.
In practice: caching product pages for an online marketplace
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
An online marketplace lists about five million products from independent sellers. A Java application and PostgreSQL (one primary, one replica) serve the web and mobile apps; Prometheus and Grafana monitor them. Six engineers own the storefront. A product page needs the product row, the seller summary, the rating aggregate, and the current price and stock, which is four queries and a join. At a peak of about 600 page views per second (illustrative), database CPU sits near 80 percent, p95 page time is about 700 ms, and a seller campaign can tip the primary over. A small share of products (the top few percent, illustratively) draws most views.
Starting architecture
Marketplace starting architecture: every page view hits PostgreSQLShoppers to Load balancer; Load balancer to Storefront app; Storefront app to PostgreSQL (4 queries); Storefront app to Read replica (some reads); PostgreSQL to Read replicaShoppersShoppersLoad balancerLoad balancerStorefront app: x6Storefront appx6PostgreSQL: primaryPostgreSQLprimaryRead replicaRead replica4 queriessome reads
Every page view repeats the same four queries, even for a product that thousands of people have just viewed.
Options considered
Option
What it fixes
What it costs
Risk
Do nothing smaller: add indexes and one more replica
Slow individual queries; spreads reads
Replica bill, replication lag
Still pays the full query cost per view; lag can show old prices
Cache-aside in Redis for product, seller and rating; price and stock in a separate short-lived key
Most read load; per-field freshness
Redis to run, invalidation to build
Medium; stale price or stock if invalidation is missed
Cache whole rendered pages at the CDN
Maximum offload
Hard to personalise; purge complexity
Higher; wrong price shown to many users at once
The decision
Field
Decision record
Context
Read-heavy product pages overload the primary; the head of the catalogue is requested constantly.
Decision
Cache-aside in Redis. Product and seller keys use a 10 minute TTL with delete-on-update; rating uses 60 seconds; price and stock use their own key, 30 second TTL, deleted when a seller edits them. Checkout always reads the database.
Consequences, good
Large drop in database reads, faster pages, a graceful fallback when Redis is unavailable.
Consequences, bad
Prices on a page can be up to 30 seconds old; one more system to operate; cache keys must be designed carefully.
Revisit when
Hit ratio on product keys stays below 80 percent for a week, or the database falls over during a cold cache.
The target is a hit ratio of at least 90 percent on product keys and at least 70 percent on price keys, which you would verify rather than assume.
Rollout and verification
Ship it in three steps behind a flag. First run in shadow mode: the app still serves from the database but also reads the cache and logs any difference, so you can measure the mismatch rate before users see anything. Then serve from the cache for 5 percent of requests, then 25, 50 and 100, watching the signals. Finally, force a failure in staging: stop Redis and confirm pages still load from the database.
Cache-aside read path and seller write pathShoppers to Storefront app (view page); Seller to Storefront app (edit price); Storefront app to Redis (1 GET key); Storefront app to PostgreSQL (2 on miss read); Storefront app to Redis (3 SET with TTL); Storefront app to PostgreSQL (A update row); Storefront app to Redis (B delete key)ShoppersShoppersSellerSellerStorefront app: x6Storefront appx6RedisRedisPostgreSQLPostgreSQLview pageedit price1 GET key2 on miss read3 SET with TTLA update rowB delete key
Shoppers read through steps 1 to 3; a seller edit goes through the same app, which updates the database first (A) and only then deletes the cached key (B).
Dashboards to build: hit ratio per key family, database queries per second (should fall visibly), page p95 and p99, Redis memory and eviction rate, and the shadow-mode mismatch count.
The 2 a.m. failure
At 02:00 the managed Redis node restarts for maintenance and comes back empty. Every request misses at once.
sequenceDiagram
participant S as Shoppers
participant A as App pods
participant R as Redis
participant D as PostgreSQL
Note over R: node restarts empty
S->>A: thousands of requests
A->>R: GET
R-->>A: miss on every key
A->>D: queries for every miss
Note over D: CPU saturated, p99 climbs
The page says "database CPU above 90 percent and product page p99 above 3 s". Detection: hit ratio drops to near zero while database queries per second jump. The first three checks: INFO stats (keyspace hits and misses), INFO keyspace (key count near zero) and INFO server (uptime reset) on Redis, the database pg_stat_activity view (many identical product queries), and the app's cache error counters. Mitigation: enable the feature flag that sheds low-priority traffic or serves a simplified page, scale the app only if the database has headroom, and run a warm-up script that preloads the most viewed products. Follow-up fixes: add jitter to TTLs so keys do not expire together, coalesce concurrent misses for the same key into one database read (see advanced caching), and run Redis with a replica and automatic failover.
Architect's notes
Measure the skew first. Pull a day of page views and compute how many products cover 80 percent of traffic. That number sizes the cache and predicts the hit ratio before you build anything.
Decide freshness per field, not per page. Description, rating and price have different business tolerance. One TTL for the whole page is a smell.
Ask what happens on a cold cache. If the answer is "the database falls over", you have a hidden dependency on the cache, not an optimisation. Test it.
Cache keys are an API. Include everything the response depends on (language, currency, seller region). Review keys like you would review a public interface.
Name an owner for invalidation. Whoever writes the data owns deleting the key. In a design review, ask for the list of write paths that touch cached fields.
Defer clever write strategies. Cache-aside with TTL and delete-on-write covers most needs. Add write-through or refresh-ahead only when a measured miss cost justifies it.
Common mistakes
Caching without a TTL. Entries live until evicted, so stale or wrong data can persist for days. Set a TTL on practically every entry, even if it is long.
Assuming the cache is always right. It is a copy. Treat the database as the truth and design for the cache being empty, stale or down.
Shared keys for personal data. Leaks information between users. Build keys that include everything the response depends on.
Caching errors or empty results blindly. A failed lookup stored for ten minutes turns a blip into a long outage. Cache only successes, or give negatives a much shorter TTL.
No plan for a cold cache. After a restart or deploy, every request misses at once. Warm the cache, stagger expiries and be ready to shed load.
Optimising before measuring. Adding a cache to a query that is not a bottleneck adds complexity for no gain.
Never checking hit ratio. A cache with a 10% hit ratio is mostly overhead. Monitor and tune it.
Key takeaways
A cache trades freshness for speed. Decide how stale is acceptable before you design it.
Locality makes caching work. Repeated and skewed access means a small cache can serve most traffic.
Cache near the user when safe. Browser, CDN, application and database layers each help in different ways.
Cache-aside is the default pattern. Read the cache, fall back to the database on a miss, then populate; on writes, update the database and delete the cached copy.
TTL is your safety net. Even with invalidation, set an expiry so mistakes heal themselves.
Eviction manages memory. LRU is a sensible default; LFU suits stable popularity patterns.
Do not cache everything. Skip data that must be exact, rarely re-read, or personal under shared keys.
Measure hit ratio and database load. If the database load did not fall, the cache is not paying its way.
Practice exercises
Exercise 1 — Starter (30 min): A cache hit takes 2 ms and a miss takes 52 ms (assumptions). Compute the average latency for hit ratios of 50%, 80%, 90% and 99%. Tabulate the results and write two sentences about what you notice about the benefit of going from 90% to 99%.
Exercise 2 — Intermediate (2 hours): For a marketplace, make a table of ten data items (product description, image files, price, stock count, seller profile, review count, shopping cart, search results, shipping estimate, order status). For each, decide whether to cache it, at which layer, with what TTL and what invalidation approach, and justify any item you refuse to cache.
Exercise 3 — Advanced (half day): Run Redis locally (Docker works) and write a small service with a slow function that sleeps 100 ms to simulate a database query. Implement cache-aside, then measure latency and hit ratio under repeated requests drawn from a skewed distribution (for example 80% of requests for 20% of keys). Try three TTLs and two cache sizes with an LRU eviction setting, and report how hit ratio and average latency change. Note any surprising result.
Redis documentation — official reference for data types, TTLs and eviction configuration.
Enroll to keep reading
Lesson 8 of 12 · System Design Foundations: From Zero to Your First Design
Learn why caches work, where to place them, how the cache-aside pattern, TTLs and eviction behave, and how to measure hit ratio without serving stale data.
In the rest of this lesson
TTL and eviction
What to cache and what not to
Staleness and invalidation basics
Measuring a cache
In practice: caching product pages for an online marketplace
Architect's notes
Enrolling is free. It unlocks every lesson in System Design Foundations: From Zero to Your First Design, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.