Home › Systems building blocks › Caching patterns
algorithm medium

Cache invalidation

Problem A service keeps copies of data on many cache servers or edge nodes. When the source changes, some copies keep serving the old value, and nobody can find all copies quickly or tell a late refill from a fresh one.

Make writes remove cached copies by key, group tag or prefix, broadcast the removal to every node, and guard refills with tokens so stale values never return.

delete by key O(1) per node; by tag O(keys carrying the tag); by prefix O(log n + k) on an ordered map; broadcast O(nodes), batched
cachinginvalidationpurgesurrogate-keysleasesstale-reads
Overview

Update the source of truth first, then delete the cached copy; never write the new value into the cache. A delete is idempotent, so replays are harmless. One race remains: a reader fetched the old value, stalled, and sets it after the delete. A lease token, voided by the delete, makes that late set fail; a TTL bounds anything else.

Key facts

Use when

  • Social feeds: posts, counts and timelines in memcache or Redis in front of a database.
  • CDNs and web publishing: one article edit must remove its page, section pages and image variants.
  • E-commerce catalogs: a price change must reach every page listing the SKU.
  • Ephemeral posts (stories): set the TTL to the remaining lifetime; delete only on early removal.

Avoid when

  • A few seconds of staleness is fine: use a plain TTL.
  • Content is immutable per URL: version the name.

Deep dive

How it works

Parts:

  • Source of truth — database; its commit log orders every change
  • Cache nodes — key to (value, expiry), a lease table, a tag-to-keys index, ordered keys for prefix ranges
  • Purge messages — Key(k), Tag(t), Prefix(p); all idempotent
  • Purge relay — tails the commit log, batches, sends to every node or region, retries until acknowledged
  • TTL — the backstop for a lost or late purge
  1. Read. Hit: return. Miss: the node issues a lease token, or answers "wait" if a live lease exists, so one reader refills (Facebook: one token per key per 10 s).
  2. Fill. The reader reads the database, then sets with its token. The node accepts only a live token, then stores the value with TTL and tags.
  3. Write. Commit to the database, then emit purges for the affected keys, tags or prefixes. Purging before the commit lets a reader re-cache the old row.
  4. Purge. Remove the key and void its lease. A tag purge removes each key in the index; a prefix purge walks an ordered range. Both cancel all in-flight leases (the index knows only cached keys).
  5. Propagate. The relay delivers each message to every node at least once; duplicates converge.
  6. Expire. TTL removes what a lost purge left.
 writer ─SQL─► DB ─commit log─► relay ─batch─► node 1 ┐
                  (ordered)       │ retry      node 2 ├ delete / tag / prefix, void leases
                                  └──────────► node N ┘
 reader ─ get ─► node: Hit | Miss(token) | Wait
        └ read DB, set(token) ─► accepted only if the token survived

Code

use std::collections::{BTreeMap, HashMap, HashSet};

#[derive(Debug, PartialEq)]
pub enum Get { Hit(String), Miss(u64), Wait } // Miss carries a lease token

#[derive(Default)]
pub struct Node {
    data: BTreeMap<String, (String, u64)>,  // key -> (value, expires_at); ordered for prefix ranges
    leases: HashMap<String, (u64, u64)>,    // key -> (token, lease expires_at)
    tags: HashMap<String, HashSet<String>>, // tag -> cached keys
    next_token: u64,
}

impl Node {
    pub fn get(&mut self, k: &str, now: u64) -> Get {
        if let Some((v, exp)) = self.data.get(k) {
            if *exp > now { return Get::Hit(v.clone()); }
            self.data.remove(k);                      // lazy TTL expiry
        }
        if matches!(self.leases.get(k), Some((_, e)) if *e > now) { return Get::Wait; }
        self.next_token += 1;                         // one filler per key per lease term
        self.leases.insert(k.into(), (self.next_token, now + 10));
        Get::Miss(self.next_token)
    }
    /// Fill after a miss; fails if a delete or purge voided the lease meanwhile.
    pub fn set_leased(&mut self, k: &str, v: &str, tok: u64, tags: &[&str], ttl: u64, now: u64) -> bool {
        if !matches!(self.leases.get(k), Some((t, _)) if *t == tok) { return false; }
        self.leases.remove(k);
        self.set_blind(k, v, tags, ttl, now);
        true
    }
    /// Unconditional set: the racy path.
    pub fn set_blind(&mut self, k: &str, v: &str, tags: &[&str], ttl: u64, now: u64) {
        self.data.insert(k.into(), (v.into(), now + ttl));
        for t in tags { self.tags.entry(t.to_string()).or_default().insert(k.into()); }
    }
    pub fn delete(&mut self, k: &str) { self.data.remove(k); self.leases.remove(k); }
    pub fn purge_tag(&mut self, tag: &str) {
        for k in self.tags.remove(tag).unwrap_or_default() { self.data.remove(&k); }
        self.leases.clear(); // the index knows only cached keys: cancel all in-flight fills
    }
    pub fn purge_prefix(&mut self, p: &str) {
        let keys: Vec<String> = self.data.range(p.to_string()..)
            .take_while(|(k, _)| k.starts_with(p)).map(|(k, _)| k.clone()).collect();
        for k in keys { self.data.remove(&k); }
        self.leases.clear();
    }
}

pub enum Purge { Key(String), Tag(String), Prefix(String) }

pub struct Cluster { pub nodes: Vec<Node>, pub db: HashMap<String, String> }

impl Cluster {
    /// Idempotent on every node, so a log or pub/sub replay may deliver it twice.
    pub fn apply(&mut self, p: &Purge) {
        for n in &mut self.nodes {
            match p { Purge::Key(k) => n.delete(k), Purge::Tag(t) => n.purge_tag(t), Purge::Prefix(x) => n.purge_prefix(x) }
        }
    }
    /// Database first, then purge (in production a commit-log relay sends the purge).
    pub fn write(&mut self, k: &str, v: &str) {
        self.db.insert(k.into(), v.into());
        self.apply(&Purge::Key(k.into()));
    }
}

fn main() {
    let mut c = Cluster { nodes: vec![Node::default(), Node::default()], db: HashMap::new() };
    c.db.insert("user:1".into(), "v1".into());
    // Blind set: the reader holds v1 while the writer commits v2 and deletes.
    let Get::Miss(_) = c.nodes[0].get("user:1", 0) else { panic!() };
    let seen = c.db["user:1"].clone();
    c.write("user:1", "v2");
    c.nodes[0].set_blind("user:1", &seen, &[], 60, 1);
    assert_eq!(c.nodes[0].get("user:1", 2), Get::Hit("v1".into()));  // stale until the TTL
    assert!(matches!(c.nodes[0].get("user:1", 61), Get::Miss(_)));   // TTL is the safety net
    // Same interleaving with a lease: the delete voids the token, the late set fails.
    let Get::Miss(tok) = c.nodes[1].get("user:1", 0) else { panic!() };
    assert_eq!(c.nodes[1].get("user:1", 0), Get::Wait);              // second reader waits
    c.write("user:1", "v3");
    assert!(!c.nodes[1].set_leased("user:1", &seen, tok, &[], 60, 1));
    // Tag and prefix purge, delivered twice: same end state.
    for n in &mut c.nodes {
        n.set_blind("post:1", "a", &["feed:7"], 60, 0);
        n.set_blind("img:9/s", "c", &[], 60, 0);
    }
    for _ in 0..2 { c.apply(&Purge::Tag("feed:7".into())); c.apply(&Purge::Prefix("img:9/".into())); }
    for n in &mut c.nodes {
        assert!(matches!(n.get("post:1", 1), Get::Miss(_)) && matches!(n.get("img:9/s", 1), Get::Miss(_)));
    }
}

The asserts show a blind late set keeping v1 until the TTL, and a lease rejecting it.

Complexity and constants

  • Tag purge: one index lookup, then one removal per key. Prefix purge: one range seek, then one removal per match. Fan-out: O(nodes) messages per purge.
  • mcsqueal: only 4% of deletes hit a cached key; most purge traffic removes nothing.
  • Measured on this Mac (Apple M4 Pro), in process, one thread, 16 nodes x 100,000 keys, 100 keys per tag (3 runs): tag purge 97-113 ns per key removed (89-103K node-applications/s); prefix purge of 100 contiguous keys 71-73 ns per key (137-142K node-applications/s). No network: a CPU floor only.
  • Staleness window = relay delay + retries. Facebook: four nines of deletes applied within 1 s in the master region, three nines elsewhere.

Efficiency

  • Cost: O(keys removed) CPU per node plus one message per node; about 100 ns per removed key in process (measured above). At scale the bound is the network and relay delay.
  • Levers:
    • batch deletes per destination — 18x more deletes per packet (Nishtala et al.) — many databases feeding many cache servers
    • one lease holder per key — peak database queries 17K/s to 1.3K/s (same paper) — hot, frequently written keys
    • soft purge: mark stale, serve while revalidating — avoids origin spikes (Fastly docs) — content that tolerates seconds of staleness
    • skip nodes that cannot hold the key — a per-node Bloom filter digest might save messages (inference, not measured) — large fleets
  • Naive → standard → state of the art:
    • TTL only · stale up to the TTL
    • delete after the database write, from the app · fresh normally, stale after a lost delete or late set
    • commit-log deletes, batched, with leases and TTL · seconds of staleness, bounded by relay delay

Implementation notes

  • Drive deletes from the commit log, not the web tier: lost deletes can be replayed (Nishtala et al.). See change data capture and transactional outbox.
  • HTTP caches must drop the target URI after a non-error response to an unsafe method, and may drop same-origin Location and Content-Location URIs (RFC 9111 4.4).
  • Header limits: Cloudflare Cache-Tag 16 KB (about 1,000 tags); Fastly Surrogate-Key 1 KB per token, 16 KB per header.
  • Redis CLIENT TRACKING pushes an invalidation to each connection that read a key (RESP3); BCAST with PREFIX pushes by prefix. A disconnect flushes the client's local cache.

Pitfalls

  • Purge before the commit. A reader refills the old row in between. Database first.
  • Set instead of delete on write. Two writers' sets can reorder.
  • Late refill after a delete. Lease tokens, or a hold-off that rejects add after a delete (Facebook: 2 s on cold-cluster deletes), close it.
  • Shield or tier races. A purged edge refills from an unpurged upper tier. Fastly advises purging twice, about 2 s apart for URL and key purges; see origin shielding.
  • Pub/sub as the only channel. Events are lost while a subscriber is disconnected; keep a TTL or a log.
  • Tag index leaks. Entries for expired keys dangle until their tag is purged.
  • Purge storms. A wide tag empties the cache; the origin sees a stampede.

Variants and alternatives

Versioned keys / generation countersput a generation in the key (feed:{gen}:u7) and bump it to drop a group; old entries age out. Needs no tag index; costs one extra lookup per read. (General practice; no source read.)
Version numbers in messagesTAO tags invalidations and refills with a version so late ones are ignored.
Remote markersa marker set before a replica-region write sends reads to the master region until replication catches up (Nishtala et al.).
Stale-while-revalidateserve the old value while one request refreshes.

In production

Facebook memcache and TAO; Fastly surrogate keys; Cloudflare purge by tag and prefix; Varnish xkey; Redis and Valkey client-side caching.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
mcrouterC++facebook/mcrouterbuild from source (repo wiki)————memcached router used at Facebook and Instagram
memcachedCmemcached/memcachedbrew install memcached————the cache nodes; delete and add
ValkeyCvalkey-io/valkeybrew install valkey————CLIENT TRACKING, keyspace notifications (Redis has the same)
varnish-modules (xkey)Cvarnish/varnish-modulesbuild from source (./bootstrap)————surrogate keys; repo says maintenance mode, known scalability limits
mokaRustmoka-rs/mokacargo add moka --features sync————in-process; invalidate_entries_if (enable support_invalidation_closures())

Sources

  • Rajesh Nishtala et al., Scaling Memcache at Facebook, NSDI 2013. paper
  • Nathan Bronson et al., TAO: Facebook's Distributed Data Store for the Social Graph, ATC 2013. paper
  • Fielding, Nottingham, Reschke, HTTP Caching, RFC 9111, 2022. RFC
  • Fastly, Working with surrogate keys, Soft purges. docs
  • Cloudflare, Purge cache (tags, prefix, limits). docs
  • Redis, Client-side caching, CLIENT TRACKING, Keyspace notifications. docs

Builds on

Used by

semantic cache · plannedCaching LLM responses keyed by embedding similarity of prompts, with thresholds and invalidation

Systems (6):

Alternatives and related

Pub/sub fan-outA publisher must deliver one message to many live subscribers, such as chat rooms, presence updates or price ticks, without a single slow subscriber exhausting the broker's memory.Transactional outboxA service must change its database and tell other services about it, but the database and the message broker cannot commit together. A crash between the two writes leaves the state and the announcement disagreeing: a lost event or a phantom one.Change data captureTeams must keep a search index, cache, warehouse or other service in step with a live database, deletes included, without changing every writer, polling the tables or losing changes in a crash.
stale while revalidate · plannedServing cached data past TTL while refreshing in the background, with request coalescing
request coalescing · plannedCollapse concurrent identical requests into one backend fetch (singleflight)
origin shielding · plannedRoute cache misses through one intermediate tier to cut origin load
LRU and CLOCKA cache or page pool that is full must choose which entry to evict, and concurrent access must not serialize on a lock for every hit.Consistent hashingWhen nodes join or leave a cluster, spreading keys by simple modulo reshuffles almost everything. This decides which node owns each key so that only a small share of keys move when membership changes.Bloom filterSystems waste disk reads or lookups on keys that are not present (LSM-tree tables, joins, caches). This answers "definitely absent" cheaply in a tiny amount of memory.