Engineering Atlas › System Design Fundamentals

Why and where to cache

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

A copy, kept close

Every read has a source of truth: a row on disk, an object in a bucket, a response computed by a service. Reading it costs time (seeks, round trips, query planning, inference) and capacity (connections, CPU, IOPS). A cache keeps copies of recent answers in a faster medium, usually RAM closer to the reader, and answers repeat questions from them.

Being a copy explains everything else. It is smaller than the source, so something must decide what to keep (eviction). It can be out of date, so something must bound staleness (expiry and invalidation). And it can vanish at any moment, so the system must stay correct, just slower, when the cache is empty.

Why it works at all: locality and skew

If every key were equally likely, a cache holding 10% of the data would answer 10% of reads. Real workloads are heavily skewed. Product views, profiles, video segments and DNS names often follow a Zipf-like curve, where the k-th most popular item is read in proportion to 1/k^s with s near 1. Under that curve a cache holding 1% of 20 million keys serves roughly 70% of reads.

Temporal locality means something read now is likely to be read again soon. Spatial locality means data next to it is likely to be read next, which is why CPUs fetch 64-byte lines, the kernel reads ahead file pages and a CDN prefetches the next chunk of a stream. The set of items touched in a time window is the working set. If it fits, the hit ratio is high and stable; if it is slightly larger, the hit ratio can collapse, because a loop over N+1 items through an LRU cache of size N misses every time.

The arithmetic of hits and misses

Average access time is hit_time + miss_ratio x miss_penalty. With a 0.5 ms Redis hit and a 20 ms query, 90% hits average 2.5 ms and 99% average 0.7 ms. For capacity, the load reaching the source is reads x miss_ratio: at 50,000 reads per second, 5,000 queries at 90% hits and 500 at 99%. Frame caching in misses, because each point near the top of the curve is worth far more than one near the bottom.

Caches stack: browser, CDN, in-process map, Redis, database buffer pool. Each layer sees only the misses of the layer above, a colder and flatter slice of traffic, so a lower hit ratio deeper down is expected rather than a tuning failure.

Deciding what to cache

Good candidates are read far more than written, expensive to produce, tolerant of some staleness, and have a hot set that fits in memory: sessions, rendered pages, permission checks, feature flags, geocoding results. Poor candidates are values used to make irreversible decisions, such as a balance that authorises spending or stock counts that decide whether to sell.

The cache key must include every input that changes the answer. Forget the locale and a French user sees German prices; forget the tenant or auth scope and one customer sees another's data. HTTP encodes this as the Vary header, which lists the request headers a stored response depends on (RFC 9111), and many real cache incidents are key bugs rather than staleness bugs.

The same problem shape elsewhere

Caching is the "finding things fast" and "hot spots" shapes together. An AI platform meets it when it reuses the computed prefix of a long system prompt: provider prompt caches key on the exact prefix bytes, expire after a time to live and charge less for a hit, so the same questions apply. Anthropic's API, for example, needs a 100% identical prefix, keeps entries for 5 minutes by default (1 hour at extra cost) and bills a cache read at a fraction of the normal input price, typically a tenth (Anthropic docs). What must the key include, how long is a hit valid, and what does a miss cost?

Down the stack

Layer 1The contract

A cache promises less than people assume. A hit returns a value that was correct at some point within a staleness bound (a TTL, or the lag of an invalidation pipeline), and a lookup is much faster than the source. It does not promise that a key written a moment ago is still there: entries are evicted, expired, lost on restart or moved by resharding. It does not promise read-your-writes unless the write path touches the same cache the reader uses. A testable statement reads like: "p99 hit under 1 ms, staleness under 60 seconds, and the database survives 100% misses at 30% of peak."

Layer 2The mechanism

Every cache is a lookup structure plus per-entry metadata. A CPU cache is set-associative: an address splits into tag, set index and offset, and the hardware compares the tag against all 8 to 12 ways of one set in parallel. Linux keeps file pages in a per-file xarray at address_space.i_pages, so a read() of a cached page is a tree lookup and a copy. PostgreSQL's shared_buffers is an array of 8 KB buffers found through a hash table keyed on relation, fork and block number, each with a pin count and usage count. Redis stores a 24-bit access clock, or for LFU a 16-bit decay time plus an 8-bit logarithmic counter, in each object header (Redis source). It evicts without a separate list by sampling maxmemory-samples keys (5 by default) and evicting the best candidate (Redis docs). Caches differ far more in this metadata (recency, frequency, reference bits, expiry, dirty flags) than in their lookup structures. Which metadata pays off is an empirical question: a study of 153 production cache clusters at Twitter found many workloads more write-heavy than textbooks assume, TTLs that defined the working set, and FIFO doing as well as LRU on many of them (Yang et al.).

Mattson's stack algorithm (1970) is the standard tool for reasoning about size. For each access it records the stack distance: one plus the number of distinct keys touched since the previous access to the same key. An LRU cache of capacity C hits exactly when that distance is at most C, so one pass over a trace yields the hit ratio for every size, the miss-ratio curve. Exact tracking is too heavy to run in production, but sampling a hashed subset of keys works: SHARDS builds curves in 1 MB of memory with average miss-ratio error under 0.01 (Waldspurger et al.).

Layer 3The machine

The latency gaps span six orders of magnitude. An L1 hit is about 1 ns, L2 about 4 ns, L3 10 to 20 ns, DRAM 80 to 100 ns, an NVMe random read 50 to 100 us, a same-datacenter round trip 0.2 to 0.5 ms, and a cross-continent one 70 to 150 ms. A Redis GET spends a few microseconds in the server; nearly all of its 0.3 ms is the network.

Granularity matters as much as speed. CPUs move 64-byte lines, so two threads writing different variables in one line bounce it between cores (false sharing), and a scattered linked list misses where an array streams. The kernel caches 4 KiB pages, so one cold byte costs a page read; a database caches 8 KB or 16 KB pages, so a row's neighbours come free. The transfer unit sets how much spatial locality a cache can exploit and how much memory it wastes when there is none.

Why it's built this way

  1. Fast storage is small and expensive per byte, slow storage large and cheap, so systems put a little fast memory in front of a lot of slow memory.
  2. Access is skewed, so a small fast tier absorbs most reads.
  3. The fast tier cannot hold everything, so it evicts, and it holds copies, so it needs a staleness bound.
  4. Every copy can be rebuilt from the source, so caches may forget, which is what makes them cheap to run.

Diagram

flowchart LR
  R[Request] --> L1["In-process map (~1 us)"]
  L1 -->|hit| A[Answer]
  L1 -->|miss| RD["Redis (~0.3 ms)"]
  RD -->|hit| F1[Fill in-process map]
  F1 --> A
  RD -->|miss| DB["Database (~20 ms)"]
  DB --> F2[Fill Redis and map]
  F2 --> A

Worked example

A catalogue service handles 40,000 reads per second at peak; the database sustains 3,000 queries per second. There are 20 million products averaging 3 KB.

The miss budget is 3,000 / 40,000, at most 7.5%, so with headroom we target 2%. Replaying a day of logs through the stack-distance algorithm gives the curve: 1 million products (3 GB) miss 6%, 2 million (6 GB) 2.5%, 4 million (12 GB) 1.8%, 8 million (24 GB) 1.5%. The curve flattens because the long tail is read too rarely for any cache to help, so we pick 4 million, about 15 GB with key and allocator overhead.

At 1.8% misses the database sees 720 queries per second and average latency is 0.3 + 0.018 x 20 = 0.66 ms. Add an in-process map of the top 20,000 products (60 MB per server, 5-second TTL) that catches 40% of reads at 1 us. Every request pays that lookup, and Redis now misses 3% of the colder traffic it sees (still 1.8% of all reads), so latency is 0.001 + 0.6 x (0.3 + 0.03 x 20), about 0.54 ms, and Redis load drops by 40%.

The empty-cache test: after a full flush the database would see 40,000 queries per second, thirteen times its capacity. Amazon calls a service in this state "addicted to its cache" and advises running load tests with caches disabled to prove the safeguards work (AWS Builders' Library). The design therefore also needs warming and a rate limit on the miss path, covered in the cache warming and stampede lessons.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
In-process cacheMicrosecond reads, no networkCopy per server, per-server staleness, app heapTiny hot sets, config
Shared remote cache (Redis, Memcached)One copy per key, survives deploys, largeNetwork hop, another system to runShared data across a fleet
CDN or HTTP cacheRemoves origin traffic, close to usersPublic or carefully keyed content, purge delayStatic assets, public pages
Database buffer pool onlyNo extra system, always consistentStill pays query parsing and connection costWorking set fits in DB memory, modest QPS
No cacheSimplest, always freshSource carries full loadWrite-heavy data, or staleness is harmful

Pitfalls in production

Leadership lens

Check yourself

  1. Your cache's hit ratio rises from 95% to 98%. By what factor does load on the database fall?

    Show answer

    Misses go from 5% to 2% of reads, so database load falls by a factor of 2.5.

  2. Why can an LRU cache of 1,000 entries get no hits on a loop over 1,001 keys?

    Show answer

    Every key's stack distance is 1,001, one more than the capacity, so each key is evicted just before it is read again.

  3. A deeper cache layer has a 60% hit ratio while the layer above has 95%. Is the deeper one badly tuned?

    Show answer

    Not necessarily. It only sees the 5% that missed above, the colder and flatter tail, so a lower hit ratio is expected.

  4. Why do CPUs fetch 64 bytes when a program reads 4?

    Show answer

    Spatial locality: neighbouring bytes are likely to be read next, and a line costs about the same as a word because the trip to memory dominates.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →