LayerKick LayerKick Journal
How It Works Pricing Journal 63ms TTFB Join Waitlist →
Waitlist →
Engineering / Entry №17 / ★ Flagship

A price test walks into a full-page cache.

Price tests render different prices at the same URL, which is a hard problem for a cache that serves one body to everyone. We isolate test arms with additive cache-key dimensions. Rolling it out invalidated zero existing keys.

Craig Ruks, Founder · July 3, 2026 · 6 min read · AI-drafted, founder-edited
5
Key dimensions beyond path+query
2,201
Tests green on the key path

A store running a price test has two truths for the same URL. Visitor A sees the product at one price, visitor B at another, and both are correct, because that’s the whole point of the test. A full-page cache has exactly one job: serve the same body to everyone who asks for the same page.

Those two facts don’t fit together until you change what “the same page” means. This entry is about the cache-key design that reconciles them. It shipped over the last two days, for Shopify’s own server-side price experiments and for the theme-level split tests that third-party testing tools run.

The two goals here aren’t equal, and that set the order I built them in. Serving a shopper the wrong arm’s page is the kind of correctness problem you make structurally impossible up front, not one you patch later, so that came first. Making it fast came second.

The key is a list of dimensions

Every cached page has an identity: a fixed-length name derived from the request, keyed by a secret so nobody can forge one from outside. What matters here isn’t the hash function. It’s what goes into it.

A page’s cache identity accounts for every characteristic that makes a given visitor’s view of the page unique, and it updates as those characteristics change. Among others, market and currency are in there, because a shopper in Germany and a shopper in the US need different versions of the same URL. Last week added two more: the arm of a server-side price experiment, and a theme id for split tests that render a non-live theme. Each characteristic that distinguishes one visitor’s page from another’s is folded into the identity, so no visitor can be handed a version that isn’t theirs.

Same URL, different arm, different key, different cached body. And that’s the shift worth pausing on: arm isolation stops being a policy the code has to remember to enforce and becomes a property of the address space itself. There is no key under which two arms could collide, so there’s no code path where the cache could accidentally hand you the wrong one. The correctness lives in the shape of the key, not in a check somewhere that could be forgotten.

I want to be careful about what I’m claiming, because the interesting hard problem, figuring out which arm a given visitor belongs to, isn’t what this post is about. That detection lives upstream and involves the specific third-party tools a store runs. What this post is about is the layer underneath: once you know the arm, how do you store and serve it so each visitor gets the arm that’s theirs. That’s the cache-key design, and it’s the same regardless of how the arm was determined.

The dimensions live outside the URL, in structure the server adds, not in the address bar. So a crafted URL can’t impersonate a dimension: a query parameter a shopper types is just content, part of the path, and isn’t read as an arm. There’s no address a visitor can type that enrolls them in an arm they weren’t assigned.

Additive by construction

Here’s the property that made this shippable in two days instead of two weeks: dimensions are appended only when they’re present. A page with no treatment and no theme produces exactly the key it did before, as if those dimensions weren’t in the code at all.

That sounds like a small implementation detail. It’s the whole reason rolling this out invalidated zero existing keys. Shipping the new dimension wasn’t a migration. There was no cache flush, no warm-up cliff where every store re-fetches its pages cold from origin, no window where the site got slow while the cache refilled.

Every existing key stayed valid. The new dimension only starts minting keys when a store actually runs a test. And the canonical key, the one with no arm on it, doubles as the key for a visitor who isn’t enrolled in any experiment, so stores that don’t run tests don’t notice the feature exists. As the comment above that function puts it, adding a dimension is a no-op until it’s actually populated.

The alternative would have been to bake all the dimensions into the key format up front and migrate every existing key to the new shape. That’s a flush, and a flush on a live storefront cache means a latency spike for real shoppers while everything re-warms. Additive keying sidesteps the whole event.

One keying path, guarded by tests

A key derived in two places is really two keys that happen to agree today. The write side (ingest, which renders and stores pages), the read side (the shim, which serves them), and the warmer (which pre-fills the cache) all have to compute the identical hash from the identical inputs, and keep doing so as the code changes, or the cache quietly stops working.

So they don’t each have their own copy. All three hash through one shared descriptor: the same request attributes go in, one hash comes out. One definition, three callers.

Sharing the code isn’t quite enough on its own, because someone could still change one caller’s inputs and not the other’s. So the agreement is pinned by tests. For every dimension there’s a parity test asserting the shim’s read key equals ingest’s write key, byte for byte, for that arm. The full run across the shared config, the shim, and the origin worker is 2,201 tests (633 + 133 + 1,435), and the parity suite fails the build the moment the two sides drift.

The serve side also stays skeptical about its inputs, which is a separate line of defense. The arm arrives as a first-party value the shim validates against a strict format before it’s allowed anywhere near the key. Behind that sit independent gates, so even a fabricated arm value can’t mint a servable key for a page that wasn’t ingested under that arm. Faking the input doesn’t get you a page; it gets you a miss.

Fail toward the origin

The last piece is what happens when we genuinely don’t know the arm yet. A first-time visitor on a page that’s under an active price test has no arm we can trust, and guessing means possibly showing the wrong price, which is the outcome the whole design is built to avoid.

So we don’t serve from cache. The request passes through to Shopify like normal, and it’s self-healing: once the visitor’s arm is established, their next request hits that arm’s own key and they’re back on the fast path.

The ingest path got the same reject-by-default instinct. Because test pages can be ingested from real browsing sessions, the content gates were hardened last week to refuse anything carrying customer data or an active cart, so we don’t accidentally cache one shopper’s personalized page and serve it to the next. Every pattern the gate matches on is value-bearing, chosen so it keys off real customer data and not the ordinary markup of a theme.

The worst case anywhere in this design is a passthrough, meaning a slightly slower correct page. A wrong price simply isn’t an outcome the key space can express, and that was the goal.

What’s next in the log

Caching was the hard half of running experiments at the edge. Proving the cache stays honest is the other half. The next entry covers the invalidation loop: how a price change on Shopify provably purges exactly the pages it should have, with a verdict written onto every run.

If you're curious
See it on your own store.

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.

Join the waitlist → $200/mo beta · billing starts two weeks after signup
← Previous entry
Selling as a technical founder.
Next entry →
Invalidation you can prove.