Keeping cached data fresh
Open in the interactive app: practice, quiz, mock interview →In one minute
- A cache holds copies; invalidation is how copies stop being served after the source of truth changes.
- Every strategy picks a point on a line between staleness (how long a wrong value can be served) and cost (extra reads, writes, and moving parts).
- TTLs bound staleness without coordination; explicit invalidation shrinks it but introduces races between readers and writers.
- The classic bug is a slow reader writing an old value into the cache after a writer invalidated it; leases, versions or delayed second deletes fix it.
- Decide the acceptable staleness per data type first, then pick the cheapest mechanism that meets it.
How it works
TTL only
Each entry expires after a fixed time. No writer needs to know the cache exists, and staleness is bounded by the TTL. It suits data where minutes of staleness are acceptable (recommendations, counts, profile pictures). The cost is a miss on every expiry even when nothing changed, and a hard ceiling on freshness.
Invalidate on write
In the cache-aside pattern, the application writes the database and then deletes the cache key, so the next reader repopulates it. Deleting is preferred over updating the cache, because two concurrent writers updating the cache can apply in the opposite order from their database commits, leaving the cache permanently wrong. Deleting converges: whoever reads next gets the committed value.
The stale-set race
Deleting is still not enough. Reader R misses, reads value v1 from the database, then pauses (GC, slow network). Writer W commits v2 and deletes the key. R resumes and sets v1 into the cache. The cache now holds v1 with a full TTL, and nothing will fix it until expiry. Fixes:
- Leases. On a miss the cache hands the reader a token. A delete invalidates outstanding tokens, so R's late set is rejected.
- Versioned values. Store a version (row version or commit timestamp) with the value, and only accept sets with a newer version than what the cache has seen, including deletes as tombstones.
- Delayed double delete. Delete again a few hundred milliseconds after the write. Cheap, but probabilistic.
- Delete with a hold-off. Memcached deletes can carry a hold-off time during which
addfor that key fails, so a late fill bounces. Facebook issued every delete to a warming cluster with a two-second hold-off (Facebook NSDI 2013).
Invalidation from the database log
Application-level deletes are lost if the process crashes between commit and delete, and every write path must remember to do them. Tailing the database's change log (MySQL binlog, PostgreSQL logical replication) and issuing deletes from it guarantees every committed change produces an invalidation, in commit order, at the cost of a pipeline with its own lag. Debezium's Postgres connector, for example, reads logical decoding output through the built-in pgoutput plugin and a replication slot it creates for itself (Debezium docs).
Measure staleness, do not assume it
Invalidation bugs hide in rare error paths, so mature teams measure consistency from outside. Meta's Polaris acts like a cache server to receive invalidations, then queries replicas and reports keys still stale after 1, 5 or 10 minutes. With it, Meta raised TAO's cache consistency from six nines to ten nines of writes consistent within five minutes. One bug it caught: an invalidation hit a transient error, the error handler dropped the key only if the cached version was older, the stale value carried the newest version, and so it stayed forever (Meta engineering).
Many layers and regions
Real systems cache in several places: in-process, a shared cache cluster, a CDN, the browser. Each layer needs its own mechanism, and the outermost (browser) can only be controlled by TTL and versioned URLs. A content-hashed URL served with Cache-Control: max-age=31536000, immutable never needs invalidating, and the immutable flag stops browsers revalidating it on reload (RFC 8246). Across regions, invalidations must reach every regional cache, usually by replicating the database and running the log-tailing invalidator in each region, so a region never invalidates before its replica has the new data.
Down the stack
Layer 1The contract
A cache layer should state its staleness bound: "a read may return a value up to N seconds older than the latest commit", or "reads after an invalidation completes see the new value". TTL gives the first form with N equal to the TTL. Log-driven invalidation gives N equal to pipeline lag, typically under a second, with a guarantee that every commit eventually invalidates. Without leases or versions, neither form holds strictly, because the stale-set race can extend staleness to a full TTL.
Layer 2The mechanism
Memcached as deployed at Facebook issues a 64-bit lease token on a miss (Facebook NSDI 2013); a delete invalidates any outstanding lease for that key, and a set carrying an invalidated token is refused. Their invalidation daemon, mcsqueal, tails the MySQL commit log, extracts keys from annotated statements, batches deletes and sends them to routers in each region. Debezium does the same against PostgreSQL logical decoding or the MySQL binlog, publishing change events to Kafka for an invalidator service to consume. CDNs support surrogate keys (cache tags): responses are labelled with keys like product-42, and one purge call removes every cached URL carrying that tag. A soft purge marks matching objects stale instead of deleting them, so the next request is served the old copy while the CDN refetches (Fastly docs). Redis has no lease primitive, so teams implement compare-on-version sets with a Lua script, or with WATCH and MULTI, where the transaction aborts if a watched key changed (Azure caching guidance).
Layer 3The machine
A memcached or Redis delete is a single small request, roughly 100 to 300 microseconds round trip inside a data centre, so invalidation itself is cheap; the cost is in the misses it causes. Log tailing reads the binlog sequentially from disk or page cache, and its lag is dominated by batching intervals and cross-region replication, typically 10 ms to a few seconds. A CDN purge must reach hundreds of edge locations. Cloudflare reported a global median of 149 ms for tag, hostname and prefix purges in August 2024, down from about 1.6 seconds in May 2022 (Cloudflare); slower providers take seconds to minutes. Memory matters too: a cache with tombstones or version entries stores extra bytes per key, and at 500 million keys an extra 16 bytes each is 8 GB.
Why it's built this way
- Databases are expensive to read at high rates, so we copy hot data into memory close to readers.
- Copies drift when the source changes, and pushing every change to every copy is costly, so TTL bounds drift cheaply.
- Tighter freshness needs explicit invalidation, and concurrent readers and writers race, so caches add leases or versions.
- Application code forgets or crashes before invalidating, so the commit log, which records every change in order, becomes the invalidation source.
- Outer caches (browsers, CDNs) cannot be reached reliably, so immutable versioned URLs replace invalidation there.
Diagram
sequenceDiagram
participant R as "Reader"
participant C as "Cache"
participant DB as "Database"
participant W as "Writer"
R->>C: get product:42
C-->>R: miss, lease token L1
R->>DB: SELECT (reads v1)
W->>DB: UPDATE commit v2
W->>C: delete product:42 (voids L1)
R->>C: set v1 with L1
C-->>R: rejected, lease invalid
R->>C: next get misses, reads v2Worked example
A product catalog serves 50,000 reads per second with a 98% hit rate from a Redis cluster; the database handles the 1,000 misses per second. Merchants make 200 price updates per second.
With TTL only at 10 minutes, a price change can be wrong for 10 minutes, which breaks checkout when the cart price differs from the charged price. With cache-aside deletes, staleness drops to milliseconds, but consider the stale-set race. A reader's miss-to-set window is about 5 ms (database read plus serialisation). Each update deletes one key; the race needs a concurrent miss on that same key inside the window. For popular products, with 50 reads per second on one key at 2% miss probability, the expected misses in a 5 ms window are 50 x 0.02 x 0.005 = 0.005 per update. Across 200 updates per second that is about one stale entry per second, or 86,000 per day, each lasting up to the TTL. Leases bring that to near zero.
The team adds log-based invalidation via change data capture so batch jobs that update prices directly in SQL also invalidate. Pipeline lag is about 300 ms p99, so for checkout the service reads the price from the primary database rather than the cache, a deliberate exception covering 2% of traffic.
In the wild
- Facebook, 2010: an invalidation loop took the site down for 2.5 hours. An automated system checked cached configuration values and, when one looked invalid, replaced it from the persistent store. On 23 September an invalid value was written to the persistent store itself, so every client saw it as invalid and queried a database cluster to fix it, hundreds of thousands of queries per second. Clients also treated any database error as an invalid value and deleted the cache key, so failures created more misses. The only way to break the loop was to stop all traffic to the cluster, which meant turning the site off, then letting users back gradually. The correction system was disabled (Facebook).
- Meta, 2022: measuring and fixing TAO cache consistency. Meta built Polaris to check, from the client's point of view, that every invalidation reached every cache replica, and consistency tracing to reconstruct a key's history. Together they raised TAO's consistency from 99.9999% to 99.99999999% of writes consistent within five minutes, and let on-call engineers find a version-check bug in an invalidation error path in under 30 minutes (Meta engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| TTL only | No coordination, simple | Stale up to TTL; misses on unchanged data | Staleness of minutes is acceptable |
| Delete on write (cache-aside) | Fresh within milliseconds | Stale-set race, every writer must remember | Most read-heavy app data |
| Write-through update | Cache always populated | Out-of-order updates, writes to cold keys | Small hot datasets with one writer |
| Log-driven invalidation (CDC) | Catches every commit, ordered | Pipeline to operate, lag | Many write paths, strict correctness |
| Versioned keys or URLs | No invalidation needed | Old versions linger until evicted | Static assets, immutable snapshots |
Pitfalls in production
- Updating instead of deleting. Two writers race and the cache keeps the older value. Delete, and let readers repopulate.
- Invalidating before commit. A reader refills from the old row before the transaction commits. Invalidate after commit, or from the commit log.
- Forgotten write paths. Admin tools, migrations and batch jobs bypass the delete. Use CDC so the database is the trigger.
- Derived keys. A product change must also invalidate category pages and search results that embed it. Managed caches have the same gap: DAX updates its item cache on writes but never invalidates its query cache, which stays stale until its TTL (DAX docs). Track dependencies with tags or keep derived caches on short TTLs.
- Cross-region ordering. A region invalidates before its replica has the new data, then refills with old data. Run invalidation from each region's own replica log.
Leadership lens
- Write down the staleness budget per data type; it settles most arguments about mechanism.
- Ask in review: list every path that writes this data, and show how each one invalidates.
- Prefer CDC-driven invalidation once more than a couple of services write the same data.
- Identify the few flows that must bypass the cache entirely (payments, inventory reservation) rather than making the whole cache strongly consistent.
Check yourself
Why is deleting a key safer than updating it on write?
Show answer
Concurrent updates can reach the cache in a different order from their database commits and leave an old value. A delete makes the next reader fetch the committed value.
How can a reader put a stale value into the cache after a writer deleted it?
Show answer
The reader fetched the old row before the write, then set it after the writer's delete; nothing stops the late set without leases or versions.
Why drive invalidation from the database log rather than application code?
Show answer
The log records every committed change in order, including from batch jobs and tools, and survives application crashes between commit and delete.
Sources and further study
Primary
- RFC 8246: HTTP Immutable Responses: why versioned URLs plus
immutableremove the need to invalidate browser caches. - RFC 5861: stale-while-revalidate and stale-if-error: letting HTTP caches serve a stale copy while a fresh one is fetched.
Systems docs
- DynamoDB Accelerator: consistency models: write-through item cache, an uninvalidated query cache, and what happens when writers bypass the cache.
- Fastly: purging: surrogate keys, soft purge and purge rate limits.
- Cloudflare: Instant Purge: how a CDN moved purges from a central hub to a distributed index, with latency numbers.
- Debezium PostgreSQL connector: change data capture from logical decoding, the usual source for log-driven invalidation.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 11 "Stream Processing", section "Databases and Streams": change data capture and keeping derived data such as caches in sync.
- Understanding Distributed Systems (2nd ed.), Roberto Vitillo, chapter "HTTP caching": freshness, validation and how CDNs and browsers decide what is stale.
Other curricula
- donnemartin/system-design-primer: calls invalidation "a difficult problem" and contrasts object-level with query-level caching, where one changed row can stale many entries.
- Caching guidance (Azure Architecture Center): expiration, invalidating client-side caches, and optimistic versus pessimistic concurrency on shared entries.
Watch
- NSDI '13 - Scaling Memcache at Facebook (USENIX): the conference talk covering leases and regional invalidation.
- Lecture 16: Cache Consistency: Memcached at Facebook (MIT 6.824): Robert Morris dissects the paper's consistency races.
Free resources
- Scaling Memcache at Facebook (NSDI 2013 paper): leases, mcsqueal and cross-region invalidation in detail.
- Cache made consistent (Meta engineering): how Meta measures and fixes cache inconsistencies.
- Caching challenges and strategies (AWS Builders' Library): practical lessons from Amazon services.
- Cache-Aside pattern (Azure Architecture Center): the base pattern and its consistency caveats.
- RFC 9111: HTTP Caching: freshness and validation rules for browser and CDN layers.