The cache that showed someone else's prices: segmenting FPC with X-Magento-Vary
A Magento production war story: how to segment the full page cache by customer group with HTTP headers, without extra backend logic and without killing the hit rate.
The report arrived the way the worst ones do: impossible to reproduce. A wholesale customer of an enterprise e-commerce site would sometimes see end-consumer prices. Sometimes the other way around. Same browser, same URL, different results depending on the time of day. Nobody on the team could trigger it on demand, and the pricing code was fine: every customer group had the correct catalog in the backend.
The bug wasn’t in the backend. It lived in the layer almost nobody looks at when debugging prices: the full page cache. This is the story of how we found it, the native Magento mechanism that solves it, and the opposite trap that is incredibly easy to fall into while fixing it. I can’t share project details for confidentiality reasons, but the problem and the solution are one hundred percent generalizable.
Where the HTML the user sees actually lives
In a Magento store with serious traffic, the product page the browser receives is almost never rendered by PHP at that moment. The backend rendered it once, minutes or hours ago, and since then the full page cache (FPC) has been serving it through Varnish or a CDN like Fastly. That’s what makes the platform viable: the backend only works on the misses.
This works perfectly as long as the page is the same for everyone. And that was exactly our problem: the page was not the same for everyone. Prices depended on the customer group, but the URL was identical. The CDN kept a single copy per URL — whichever user happened to come through first after each expiration — and served it to everybody. That’s why the bug was intermittent: it depended on who had warmed the cache.
The mechanism Magento already ships: X-Magento-Vary
The fix wasn’t writing more defensive pricing logic. It was telling the cache what the backend already knew.
Magento maintains an HTTP context (Magento\Framework\App\Http\Context): a set of key-value pairs describing everything the HTML depends on besides the URL — whether there’s a logged-in session, the customer group, the currency, the store. That context is serialized, hashed, and the result travels to the browser as the X-Magento-Vary cookie.
The piece that closes the loop sits on the CDN side: Magento’s default VCL includes that cookie’s value in the cache key. The key stops being just the URL and becomes URL + segment:
The effect is exactly what’s needed: the same URL can have one cached copy per segment, each copy shared by thousands of users in the same group. The backend still never sees most of the traffic, but content can no longer leak across segments.
Don’t ask the backend to defend itself from the cache. Give the cache the information so no defense is needed.
First fix: putting the segment into the context
When the variation is one of the native ones, this works out of the box. Our case had a dimension of its own — a pricing scheme that didn’t map one-to-one onto the standard groups — so it had to be registered in the context. The pattern is a plugin (or an early observer in the request) that declares the value before the HTML is generated:
<?php
// Acme/PriceSegment/Plugin/AddSegmentToHttpContext.php
declare(strict_types=1);
namespace Acme\PriceSegment\Plugin;
use Acme\PriceSegment\Model\SegmentResolver;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\Http\Context as HttpContext;
class AddSegmentToHttpContext
{
public function __construct(
private readonly HttpContext $httpContext,
private readonly SegmentResolver $segmentResolver,
) {
}
public function beforeExecute(ActionInterface $subject): void
{
$this->httpContext->setValue(
'acme_price_segment',
$this->segmentResolver->resolve(), // "retail" | "wholesale" | ...
SegmentResolver::DEFAULT_SEGMENT // what an anonymous visitor sees
);
}
}
Two details of that call are worth more than the rest of the file:
- The third argument is the default value, and it has to match exactly what an anonymous visitor sees. If the resolved value matches the default, Magento doesn’t vary the cookie; declare the default wrong and you split the cache of all anonymous traffic — usually 80–90% of the total — in two, gaining nothing.
- The value must come from a small, enumerable set. The full trap is covered below.
With the segment inside the context, the cookie changes, the cache key changes, and every group gets its own copy. Without touching a single line of pricing logic.
Second round: the AJAX nobody looked at
Weeks later, a variant of the same symptom: crossed prices, but only when scrolling through the product listing. Full pages were already segmented; the bug came back through another door.
The listing used infinite scroll from a third-party module: from page two onwards, products arrived via an AJAX request that returned HTML. That response was cacheable — correctly so, it’s heavy, repetitive content — but the endpoint didn’t declare what it depended on. The CDN stored the first response and served it to every segment.
The fix was conceptually one line: the response has to declare its variation with the standard HTTP header, so the CDN folds the cookie into the key for that endpoint too:
$response->setHeader('Vary', 'Cookie', true);
The lesson matters more to me than the specific fix: Magento’s context covers the pages that go through its FPC, but every cacheable endpoint that produces user-dependent content is responsible for declaring its own variation. Third-party modules that add cacheable AJAX are exactly where this gets forgotten, because they work perfectly in local and staging — where there’s no CDN in front.
The opposite trap: segmenting the cache to death
So far it sounds like the answer to everything is “add more things to the context”. That’s the opposite trap, and it’s worse than the original bug because it doesn’t break content: it breaks performance, silently.
Every dimension that enters the context multiplies the copies the CDN must keep per URL. Five customer groups times three currencies is fifteen variants of every page; the hit rate gets split among them, the rarely visited variants expire before being reused, and the backend starts receiving traffic it never saw before. The extreme case is putting something with per-user cardinality into the context — a customer ID, a token — and turning the FPC into a private per-person cache: a hit rate near zero, at the infrastructure cost of a full cache.
The rules I kept:
- Segment by small, enumerable values: the set should fit in one hand, not grow with your users.
- Before adding a dimension, multiply: current variants × new values. If the number makes you uncomfortable, the dimension is badly chosen.
- What is genuinely per-user — the minicart, the name in the header, totals — doesn’t get segmented: it gets privatized, with client-side customer-data sections or private blocks, outside the cached HTML.
- After deploying, watch the CDN hit rate for days, not minutes: fragmentation shows up when the cold variants expire.
Checklist: per-segment content behind a CDN
- List every dimension the HTML depends on besides the URL: group, currency, store, special catalogs.
- Check which ones already travel in
X-Magento-Varyand register your own in the HTTP context, with the exact default of an anonymous visitor. - Audit cacheable AJAX endpoints — especially those from third-party modules — and make them declare
Vary. - Compute the total cardinality before adding dimensions: variants = product of the possible values.
- Move the genuinely personal parts to customer-data/private blocks; don’t segment them.
- Test with two sessions from different segments against the environment with the CDN in front, not against local.
- Watch the CDN hit rate after every context change for at least a week.
Closing
The full fix for that intermittent bug added no business logic: it added information to the cache key. That’s the thesis I take from this story and several like it: when content depends on the user, the first question isn’t “how do I compute this more defensively?” but “does the cache know what this depends on?”. HTTP already has the vocabulary — cache keys, Vary, context cookies — and using it well replaces entire layers of defensive application logic.
This is the first in a series of Magento production war stories. Next up: what to do when one shipping method per order isn’t enough — real multi-shipping on top of MSI, with time-window stock reservations.
If you’re fighting a cache that serves crossed content — or a hit rate that collapsed without explanation — that’s exactly the kind of problem I work on. You can reach out.