Deciding What Invalidates What Before the Cache Bugs Arrive
An object cache with no invalidation plan serves stale data either on a timer or forever. How to choose between counter bumps, key deletes and versioned keys.
Tomas Lindqvist · @tlindqvist
Hardening and operations
- Topic
- INFRA
- Section
- Hosting & Infrastructure
- Published
- 27 MAY 2026
- Reading time
- 8 MIN
- Words
- 2097W
A price change saves in the WooCommerce admin and the product page reflects it on the next load. The shop archive above it does not, because a plugin cached the sorted product list for fifteen minutes and nothing told that entry a product had changed. Merchandising learns to wait. Annoying, but bounded.
The severe version runs the other way. A plugin caches a category's product count with no expiry at all, reasoning that an invalidation hook will clear it whenever a product moves between categories. The hook is registered on an action that fires for some of those moves and not others. The count is wrong from that afternoon onward, and no timer will correct it. Flushing Redis does not help for long, because the next read rebuilds the same wrong entry from the same source data.
Both failures come from one decision that gets made once and rarely revisited: what invalidates what. It is a correctness decision wearing performance clothing, and it belongs in the design of the cache rather than in a bug report three months later.
A TTL is a promise about time, not about truth
A TTL and an invalidation event are two different mechanisms that people collapse into one conversation. A TTL is a statement about age: this value is assumed acceptable until the clock runs out. An invalidation event is a statement about the source: the input changed, so the derived value no longer describes the current state.
They fail in opposite directions. A TTL-only cache is never permanently wrong, only wrong for a window you chose when you typed the number. If a price is stale for sixty seconds on a store that changes prices twice a day, nobody notices. If a stock count is stale for sixty seconds during a flash sale, one customer buys the last unit twice and somebody issues a refund.
An invalidation-only cache is exactly correct until the one write path you forgot, and then it is wrong with no upper bound. If the backing store is persistent, the entry survives restarts and cache flushes, which makes it the most confusing bug in this area: you clear Redis, watch other symptoms disappear, and this one returns on the next read.
These are not alternatives to weigh against each other. A TTL is the ceiling on how wrong you can be when an invalidation path is broken; invalidation is what keeps you correct in the ordinary case. Use both, and stop treating the TTL as the part that handles correctness.
WordPress clears a whole group by bumping one counter
Core solved this problem years ago in a way worth reading closely, because plenty of plugin caching copies the shape without understanding the trade.
WordPress stores cache entries in groups: posts, terms,
users, options and a few dozen others. Each group has a companion cache
entry holding a marker value, exposed through
wp_cache_get_last_changed( 'posts' ). Core bumps that marker whenever it clears caches in
the group. Keys built inside the group
include the marker, so the moment it changes, every key derived from it changes too. Old entries
become unreachable without anything having to enumerate or delete them.
Concretely, a cached query result is stored under a key that mixes the query arguments with the group's marker. Write a post meta row and the marker moves. The next identical query builds a different key, misses, and rebuilds from current data. The stale entry sits in Redis, unread, until expiry or eviction collects it.
// The shape core uses, reduced to its parts.
$group = 'myplugin_products';
$marker = wp_cache_get( 'last_changed', $group );
if ( ! $marker ) {
$marker = microtime();
wp_cache_set( 'last_changed', $marker, $group );
}
$key = 'list:' . md5( wp_json_encode( $args ) );
$key .= ':' . $marker;
$value = wp_cache_get( $key, $group );
if ( false === $value ) {
$value = myplugin_build_list( $args );
wp_cache_set( $key, $value, $group, 900 );
}
The bump itself is one line, placed in the write path rather than the read path:
function myplugin_bump_products_cache() {
// Any value will do. Core uses microtime(); the only requirement is that it changes.
wp_cache_set( 'last_changed', microtime(), 'myplugin_products' );
}
add_action( 'save_post_product', 'myplugin_bump_products_cache' );
add_action( 'woocommerce_update_product', 'myplugin_bump_products_cache' );
What you buy for that line is a guarantee about the shape of the failure: no reader returns a value derived from state older than the last bump. What you pay is precision. Editing one product's title discards every cached product list on the site, not only the lists containing that product. On a catalogue of two hundred products with a single editor, that is a good trade. On a catalogue of eighty thousand with a stock feed writing every minute, the cache is effectively always cold, and you want a narrower invalidation or no cache on that list at all.
The design choice to internalise is that core picked a coarse marker that cannot miss a write over a precise one that can. It errs in one direction only, and the direction is safe.
Where the bugs appear
Without a persistent object cache, every request starts with an empty cache, so a missing invalidation is invisible: nothing survives long enough to go stale. The bug arrives the week Redis is installed, and it looks like Redis caused it.
Three strategies, and the bug each one invites
Almost every invalidation you will write is one of three shapes. They differ in how precise they are, what they cost to maintain, and which mistake each one tends to produce.
| Strategy | Precision | Cost | Typical bug it produces |
|---|---|---|---|
| Bump a shared counter | Group-wide. Everything derived from the counter dies at once. | One cache write per mutation, no key bookkeeping. | A trivial write clears unrelated entries and the hit rate collapses. |
| Delete specific keys | Exact, for the keys you remembered. | You must know every key a write touches, on every code path. | One missed key stays stale and nothing raises an error. |
| Version the key by a hash of its inputs | Exact, for the inputs you hashed. | You must keep the hash input complete as dependencies change. | A new dependency is not in the hash, so changes to it are invisible. |
Bump a shared counter. This is the WordPress pattern above. It is the right default when the group is small, writes are rare, and a temporarily cold cache is survivable. Its characteristic failure is not staleness but cost: register the bump on an action that fires on every page view and you have built a cache that never hits, which the hit rate graph will tell you before any customer does.
Delete specific keys. Precise, and therefore appealing until you count. One product save can affect the single-product key, three category listing keys, a price-range facet key and a count key. Miss one and it stays wrong indefinitely, because nothing is watching that key for you. The arithmetic of how many keys a write touches is the whole difficulty, and it grows every time somebody adds a cached view over the same data.
Version the key by a hash of its inputs. Nothing is deleted. The key is derived from everything the value depends on, so a changed input produces a different key and the old entry simply ages out.
function myplugin_price_table( $product_ids, $currency ) {
// Everything this value depends on goes into the seed.
$seed = array(
$product_ids,
$currency,
get_option( 'myplugin_price_rounding' ),
// Prices live in post meta, so a meta write has to change the key.
wp_cache_get_last_changed( 'post_meta' ),
);
$key = 'price-table:' . md5( wp_json_encode( $seed ) );
$table = wp_cache_get( $key, 'myplugin' );
if ( false === $table ) {
$table = myplugin_query_prices( $product_ids, $currency );
wp_cache_set( $key, $table, 'myplugin', 3600 );
}
return $table;
}
There is no hook to register wrongly and no key to forget. The cost moves elsewhere: the seed list is now a contract with the read path. The day somebody adds a dependency on the customer's tax location, that value has to enter the seed, or the cache serves one customer's prices to another. That is a worse bug than a stale entry, and it is why I want the seed sitting next to the query rather than in a config array three files away.
Invalidation fails quietly in three specific ways
None of these throw. All three leave you looking at data that is simply wrong.
The hook fires on an action that is not the write you meant
An invalidation registered on save_post fires for autosaves and revisions, so a
draft being typed into clears the cache repeatedly. One registered on updated_post_meta
fires for every meta key on the post, including the shipping note this cache never reads. The
symptom is not staleness, it is a cache that appears to do nothing at all. Check the fire count
before the logic: if the bump runs more often than the content changes, the problem is the
action.
The entry depends on two sources and only one of them invalidates it
A shipping estimate table depends on the store's zone configuration and on the courier's rate list. If the invalidation only watches zone changes, every courier rate update is invisible until the TTL expires, and every zone change discards a table that was still accurate. Two dependencies, one hook. Find these by writing the dependency list next to the cache key and checking that each line has an invalidation path or belongs in the key's seed. A dependency with neither is a bug you have already written.
wp_cache_delete() on a key that was never set
wp_cache_delete() removes a key and reports success. If the key was never there, it
still returns successfully, because deleting something absent is not an error. A key string that
differs by one character, or one built from different arguments than the writer used, produces an
invalidation that does nothing and says nothing. This is worse under the counter pattern, because
the key contains the marker: compute the delete key after a bump and you target a key that provably
never existed. Build keys in one function that the read and the delete both call, so there is
exactly one place for the string to be wrong.
An invalidation strategy is a correctness argument, and only a test settles it
Everything above is reasoning about code paths, and reasoning does not establish that you found all of them. The only artifact that turns the claim into something checkable is a test that changes the underlying data, reads the cached value, and asserts the value changed.
public function test_a_price_change_changes_the_cached_table() {
$product = self::factory()->post->create( array( 'post_type' => 'product' ) );
update_post_meta( $product, '_price', '10' );
// First read builds, second read should hit.
$first = myplugin_price_table( array( $product ), 'EUR' );
$second = myplugin_price_table( array( $product ), 'EUR' );
$this->assertSame( $first, $second );
// The write a real editor makes, through the meta API.
update_post_meta( $product, '_price', '12' );
$after = myplugin_price_table( array( $product ), 'EUR' );
$this->assertNotSame( $first, $after );
}
Write it through the public API, not by issuing an UPDATE against the database. A direct write skips the hooks you are trying to exercise, so the test passes whether or not the invalidation exists.
This test has a ceiling worth naming. It runs inside one process, so it proves the read path derives a key from a marker the write path moves. It does not prove that two web workers behind one Redis instance agree about that marker, and it cannot reproduce the case that hurts most: a request that performed no write reading a key another request wrote. That needs a staging check with two live requests against a shared persistent cache.
What to do on Monday
- List every cached value the site reads, and beside each one write down what it is derived from. Two columns, no code yet.
- For anything that can be wrong in a way a customer would notice, keep a TTL even where you have an invalidation hook. The TTL is the ceiling on the damage.
- Use a counter bump where the group is small and rarely written, and a versioned key where the group is large or written constantly. Delete-a-key is a deliberate choice, not a default.
- Build every cache key in one function that the read and any delete both call. Never assemble a key string at the delete site.
- Write one test per cached value that mutates the source and asserts the cached output changed, and run it against a shared persistent cache in staging before you believe any hit rate graph.
Tomas Lindqvist · @tlindqvist
Hardening and operations
Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.
Continue