Invalidation you can prove.
A price changes on Shopify, and I can check which cached pages were purged, when, and whether the fresh price is live. A change gate that fails open, an index from content to the exact pages it appears on, a targeted purge, and a three-state audit that writes a verdict back onto every run.
LayerKick serves merchants’ pages from cache, which means I’ve signed up for the oldest hard question in the field: when the real thing changes, does the cache follow?
This week the price path became a closed loop. Shopify signals that something changed, exactly the right cached pages get deleted, and a durable run log records a verdict I can go back and read later. This entry walks the loop end to end.
The word I keep coming back to is “prove.” It’s cheap to purge a cache and assume it worked. What I wanted was to be able to point at a specific price change and show, after the fact, which objects were deleted and whether fresh ones replaced them. That’s a different and harder goal, and most of the work below is in service of it.
Only real price changes get through
Shopify signals a change for basically any edit to a product: inventory ticks, tag changes, a metafield nobody looks at. If we invalidated the cache on all of it, we’d be churning the cache constantly for changes that don’t affect a single rendered page.
So the first thing in the loop is a gate that looks only at what matters. It builds a fingerprint from the price-bearing fields of the change, nothing else, and compares it against the last one we stored in a database, where each store’s last-known state lives. If they match, nothing that changes a rendered price actually changed, so we skip, and the run log records the skip as “no price change.”
There’s a subtlety in what counts as price-bearing. Collection cards show a price range, “from $20,” and that range shifts when you add or remove a variant even if every remaining price is untouched. So the gate has to be sensitive to the set of variants changing, not just to the price numbers, or it would miss that case.
Two deliberate choices sit under this gate, and both are the same lesson learned twice. First, the signal comes from the change data Shopify sends, never from the rendered HTML. Our content fingerprint (the one from the hot-path entry) flips on inventory counters, countdown timers, and social-proof widgets; it’s measuring noise on purpose, and a gate built on a noisy signal fires on noise. The structured change data is the clean signal.
Second, the gate fails open. If we can’t confirm the last-known state for any reason, we invalidate anyway rather than risk skipping a real change. A needless purge costs a little cache warmth. A wrong price on a page can confuse a customer. Those aren’t close.
Finding every page a price touches
A changed price doesn’t live on one page, and finding all the pages it does live on is half the job. A product’s price shows up on its own page, on every collection that features it, and on the homepage. So the moment a price changes, we trace it out to that whole set: the product page, the homepage, and the live set of collections it belongs to, re-checked right then rather than read from a copy that might be stale, because a product could have been added to a collection since we last looked. Miss one of those surfaces and you’ve left a wrong price sitting on a page nobody thought to check.
That’s the map of what to purge. There’s a second layer under it, and it’s the part that makes
invalidation genuinely tricky: a path is not an object. An invalidation deletes objects, not paths,
and one path fans out to many cached objects. Locale, currency, product variant, price-test arm,
theme arm, and pagination each mint a distinct object under a distinct key, which is the whole point
of the treatment-aware keying from the last entry. So /products/handle might be dozens of real
objects in the store.
We handle that with an index that maps each path to the exact objects that actually exist under it. When an invalidation fires, we look up the affected paths and delete precisely those objects, the real ones a store has minted. No wildcard deletes, and no guessing at which combinations a store uses.
Purging without melting anything
The failure scenario I designed against is a merchant re-pricing 500 products in one collection at once. That’s a burst of change events, and a naive system would try to do 500 purges at once and either fall over or hammer the edge.
So the consumer never deletes inline. It resolves each event’s affected URLs and enqueues them to a durable invalidation queue, and that queue is rate-controlled and collapses duplicate work, so a burst that all lands on one shared collection page becomes a single purge instead of 500 separate ones. That throttle is what keeps a big re-pricing from turning into a self-inflicted traffic spike.
Per URL, the actual work is small: delete the cached copy of the page, the one that has the old price baked into it, and rebuild the pageset, which is the membership set the edge consults before it’s willing to claim a page. The next real visitor triggers a fresh ingest and gets the new price.
Worst case, a page serves stale for a view or two per colo while the rebuilt pageset propagates. That’s the same order of staleness you already accept from a browser’s back button, and it self-corrects within a visit or two. The result, which of the targeted URLs were actually in the pageset, is written back to the run’s step trace asynchronously, so the trace fills in over time like a pipeline completing rather than blocking the purge.
The other two legs: reconcile and verify
Change signals aren’t guaranteed. Shopify’s own docs say so and recommend reconciliation for exactly this reason: sometimes a notification just doesn’t arrive. A system that only reacts to the live signal silently drops those changes and never knows.
So on a regular sweep, a cron (a scheduled job) asks Shopify for products updated since our last pass, and enqueues each one as a synthetic event carrying the same price fingerprint the live path would compute. The gate then dedupes everything that was already handled, so this isn’t double work. A missed change gets caught one sweep late instead of never. And crucially, there’s no second invalidation code path to keep correct: reconciliation is just a second door into the exact same loop.
Then the loop closes on proof, which is the part I care about most. Every non-skipped run lands in a durable run log with a step trace (received, gate, resolve, invalidate, verify) rendered inline in the dashboard. On top of that, an audit re-checks the live cache state for recent runs, asking one narrow question per hash: did a write land after the event, or is the pre-event object gone?
Three verdicts come out of that question. PASS means a fresh object replaced the stale one. PENDING means the object is absent (a cold page correctly waiting for its own next view) or stale but still inside a short grace window. FAIL is the one state that means an invalidation ran and didn’t take. We compare the cached copy’s write time to event time, deliberately not content hashes, for the same noise reason as the gate: hashing the rendered HTML would flip on countdown timers and tell us a page changed when it didn’t.
The verdict gets written back onto the run row. Any FAIL fails the run, fresh writes with zero FAILs pass it, and no signal yet reads as still settling. My whole debugging philosophy is evidence before code, and this is that same rule applied to the cache itself, standing on a cron.
The audit is honest about its scope. It samples invalidations that fired. A clean report means the invalidations that fired did their job, not that every page on the site is currently fresh. Over-reading green dashboards is how stale caches survive.
I’d rather be clear about that than oversell it. The loop proves that the changes it saw were handled correctly. It doesn’t claim omniscience about every page on every store, and a verify step that pretended otherwise would be worse than useless.
The whole loop is drilled live by a 13-step smoke test that perturbs a real product’s price on a staging store and follows it through gate, queue, cache delete, and verdict. It passed 13/13 before today’s cut shipped: 5 PRs, 6 migrations.
What’s next in the log
Products are the first object type through the loop. Collections and articles inherit the same three legs next, since only the change signal and the URL mapping differ. And merging this same week: the piece that lets you start an A/B test without deploying anything at all. That’s a coming entry.
LayerKick layers onto your existing Shopify theme and serves it from Cloudflare's edge. If anything goes wrong, traffic passes through to Shopify like we were never there. The fastest way to understand it is to watch it run on your own storefront, and the waitlist is the way in.