Invalidation — Examples

Example 1 — The read that repopulates a stale value

A product price is cached under one key, deleted whenever the price changes. Under load the delete is not enough: a reader that fetched the old price just before the write stores it after the delete, and the copy stays wrong until its expiry. Replace the key so the old value becomes unreachable.

t0  reader  get product:7        -> miss
t1  reader  read source         -> price 19.99
t2  writer  update price        -> 24.99
t3  writer  del product:7
t4  reader  set product:7 = 19.99, ttl 300
t5  reader  get product:7        -> 19.99    stale for up to 300s

Solution

const version = row.updated_at.getTime();     // changes with every write
const key = `product:${id}:v${version}`;

await cache.set(key, JSON.stringify(row), "EX", 300);
// best effort: the writer also deletes the previous version's key

// t0..t1  reader fetches version 3 (19.99)
// t2      writer updates, version becomes 4, deletes product:7:v3
// t4      reader writes product:7:v3       (nobody will look it up again)
// t5      reader looks up product:7:v4     -> miss -> fresh 24.99

The version makes the old entry unreachable rather than absent, so the read that lost the race writes a key no later request will compute, and the short expiry bounds the damage if a write is missed. The delete is still sent, but as an optimisation rather than the correctness mechanism. The usual mistake is relying on the delete alone, which has no upper bound on staleness when it fails.

Example 2 — A delete that clears more than the write changed

Updating one product’s price invalidates the whole product namespace, which on a busy catalogue empties the cache and sends every read to the database until it refills. Narrow the invalidation to the keys the write actually affects, and keep the listing honest some other way.

update products set price = 2499 where id = 7;

DEL product:*

Solution

// the price change affects one product's current version only
await cache.del(`product:${id}:v${newVersion}`);

// the featured listing stores identifiers, not prices, so this write does not
// touch it; its own short expiry is what keeps it honest
await cache.set("products:featured", ids, "EX", 60);

A patterned delete scans the whole keyspace, can block other operations, and here destroys the hit rate that justified the cache. Invalidation should be derived from what changed: a price affects one product key, and a listing that holds only identifiers is not affected by a price at all, so its sixty-second expiry is enough. What usually goes wrong is clearing the namespace to feel safe, trading a small correctness risk for a large performance one.