Skip to content

    System Design

    System Design: Caching Basics

    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.

    By Published 15 min read

    Share

    This lesson is part of System Design Foundations: From Zero to Your First Design. Enroll for free, then sign in, to read it in full.

    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.

    Cache layers from browser to diskBrowser cache to CDN edge cache; CDN edge cache to Application; Application to Redis app cache; Redis app cache to Database buffer cache; Database buffer cache to DiskBrowser cacheBrowser cacheCDN edge cacheCDN edge cacheApplicationApplicationRedis app cacheRedis appcacheDatabase buffer cacheDatabasebuffer cacheDiskDisk

    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
    CDN Copies of content at edge locations Images, static pages (see CDN and object storage) Invalidation delay, request and bandwidth charges
    Application (in process) Objects in each app server's memory Tiny, hot, rarely changing data such as configuration Each server has its own copy, so copies can disagree
    Distributed cache (Redis, Memcached) Shared key-value data in memory Query results, rendered fragments, sessions, counters Another system to run; network hop; staleness
    Database 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 descriptions
    
    def get_product(product_id):
        key = f"product:{product_id}"
        cached = redis.get(key)
        if cached is not None:
            return json.loads(cached)          # cache hit
    
        row = db.query("SELECT * FROM products WHERE id = %s", product_id)
        if row is not None:
            redis.set(key, json.dumps(row), ex=TTL_SECONDS)
        return row                             # cache miss path
    
    def update_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.

    1. Expire by TTL. Easiest, guaranteed to converge, but allows staleness up to the TTL.
    2. 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).
    3. 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.

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments