All Posts
Headless Commerce July 31, 2026

Edge Caching Personalized Storefronts in Headless Commerce: Cache Keys, Vary, and Hole Punching (2026)

The speed argument for headless commerce rests on one thing: the storefront is static enough that a CDN can serve it from the edge, close to the user, without a round trip to an origin. Then the business asks for personalization, a logged in greeting, cart aware pricing, a member only price, a segment specific banner, and that single requirement threatens to collapse the entire model, because a page that differs per user is a page the edge cannot share across users. The naive reaction is to mark the page uncacheable and send every request to origin, which throws away the reason you went headless in the first place. The correct reaction is to stop treating the page as one cacheable unit and start separating what is shared by everyone from the small fragments that actually differ per user. That separation is the whole discipline of caching a personalized storefront, and it comes down to three mechanisms that most teams half implement and then wonder why their hit rate is poor or their users see each other's data. The cache key decides what counts as the same page. The Vary header decides which request dimensions split the cache. And hole punching decides how a mostly shared page carries a per user fragment without becoming per user itself. Get these three right and a personalized storefront still serves the bulk of its bytes from the edge. Get them wrong and you either leak one customer's cart to another or cache nothing at all.

Edge Caching Personalized Storefronts in Headless Commerce: Cache Keys, Vary, and Hole Punching (2026)

Headless commerce is fast for one reason: a CDN serves the storefront from the edge instead of rendering it at an origin on every request. Personalization is the feature that quietly breaks that, because a page that differs per user cannot be shared across users, and the lazy fix, marking it uncacheable, throws away the entire performance case for going headless. The real fix is to stop caching the page as a single unit and separate the shared shell from the per user fragment. Three mechanisms do that work, and this is how they fit together.

The Problem: Personalization Makes a Shared Page Private

A CDN caches a response and serves it to many users. That works when the response is the same for all of them. The moment a page includes a logged in name, a cart count, a member price, or a segment specific banner, the response is no longer the same for everyone, and a single cached copy would serve one user's data to another. So the page becomes, in caching terms, private.

The mistake is to conclude the whole page is private. In almost every storefront, the vast majority of the page is identical for all users: the product imagery, the description, the layout, the navigation, the footer, the base price for a non member. The personalized part is small, a header widget, a price badge, a banner. Treating the entire page as private to protect that small part is like refusing to cache a magazine because one address label is glued to the cover. The discipline is to find the boundary and cache each side of it correctly.

Page region Varies per user? Correct treatment
Product media, description, layout No Cache at edge, long TTL
Base price, non member No Cache at edge
Navigation, footer, static chrome No Cache at edge
Cart count, logged in greeting Yes Hole punch, fetch client side or at edge
Member or segment price Yes Hole punch or key on segment
Personalized recommendations Yes Fetch async, never block the cached shell

Cache Keys: What Counts as the Same Page

The cache key is the identity of a cached object. By default a CDN keys on the URL, so two requests to the same path are the same object. Personalization goes wrong at the key level in two opposite ways.

The first failure is a key that is too broad. If you key only on the URL and then inject a member price into the response, every user gets whichever price was cached first. That is a data leak, not a performance bug. The second failure is a key that is too narrow. If you fold the session ID or a per user token into the cache key, every user gets a distinct object, your hit rate collapses to near zero, and you are effectively rendering at origin for everyone while paying for a CDN that never shares anything.

The right key includes exactly the dimensions that legitimately change the shared shell and nothing that identifies an individual. If price depends on a customer segment with a small number of tiers, key on the segment, not the user. Ten segments means at most ten cached variants of a page, all still shared across the many users in each segment. That is the core move: cache on the class of user, never on the user.

# Too broad: leaks personalized data across users
cache_key = url

# Too narrow: hit rate collapses, CDN shares nothing
cache_key = url + session_id

# Right: share across everyone in a segment class
cache_key = url + customer_segment   # e.g. anonymous | member | wholesale

Vary: Splitting the Cache on Request Dimensions

The Vary response header is the standardized way to tell a cache that a response depends on a specific request header, so the cache stores a separate variant per value of that header. Vary on Accept-Language and you get one cached copy per language. Vary on a currency or country header and you get one per market. This is the clean mechanism for the dimensions that are shared by a class of users rather than by an individual.

Vary has a sharp edge that punishes carelessness. Varying on a header with high cardinality fragments the cache the same way a per user key does. The classic disaster is varying on User-Agent, which has thousands of distinct values, so you get thousands of near identical cached copies and almost no hits. Vary only on low cardinality, normalized dimensions: language, currency, country, a coarse device class of mobile versus desktop if you truly serve different markup. Never vary on Cookie directly, because a cookie header carries per user tokens and will shatter the cache. If you need a segment out of a cookie, extract it at the edge and normalize it into a small header with a handful of possible values before it reaches the cache key.

Vary on Cardinality Verdict
Accept-Language Low Safe, one variant per language
Currency or country header Low Safe, one per market
Normalized segment header Low Safe, the personalization workhorse
Device class (coarse) Low Safe if markup truly differs
User-Agent (raw) Very high Never, shatters the cache
Cookie (raw) Per user Never, one variant per user

Hole Punching: A Shared Page That Carries a Private Fragment

Keys and Vary handle personalization that maps to a class. Hole punching handles the personalization that is genuinely per individual, the logged in name and the live cart count, without making the whole page private.

The idea is to cache the shell as public and leave a hole where the private fragment goes, then fill the hole separately. There are two ways to fill it. The older server side approach is edge side includes, where the CDN assembles a cached shell and stitches in a fragment fetched from a fast, small, uncacheable endpoint, so the composition happens at the edge and the browser receives a complete page. The more common modern approach in a headless React or Vue storefront is to render the shell as a cached static or server rendered page and hydrate the personalized fragment on the client with a single call to a lightweight personalization endpoint after the page paints.

The client side version is usually the right default for a headless build. The user gets the cached shell instantly from the edge, and the cart count or greeting pops in a beat later from a small JSON call. The rule that keeps this honest is that the personalized fetch must never block the cached shell. If your header cannot render until the cart call returns, you have reintroduced the origin round trip you were trying to avoid, just moved into the header. Render the shell, show a neutral placeholder in the hole, and fill it asynchronously.

<!-- Cached, public shell served from the edge -->
<header>
  <nav><!-- shared, cacheable --></nav>
  <!-- The hole: filled client side, never blocks the shell -->
  <div id="account-widget" data-placeholder>Account</div>
  <div id="cart-count" data-placeholder>Cart</div>
</header>
<script>
  // One small uncacheable call fills every hole after paint
  fetch('/api/session/personalization', { credentials: 'include' })
    .then(r => r.json())
    .then(fillHoles);   // greeting, cart count, member badges
</script>

Putting the Three Together

A well cached personalized storefront layers all three. The product page shell is cached at the edge with a long TTL and keyed on URL plus a normalized segment and market. Vary splits it cleanly by language and currency. The truly per user pieces, the greeting and the live cart, are holes filled by one small client side call that never blocks the paint. The result is that ninety percent or more of the bytes ship from the edge and only a tiny JSON payload touches an uncacheable origin path per user.

Invalidation is the companion problem, because a long TTL on the shell means a price or content change has to purge the right variants, and that is its own decision covered in the cache invalidation writeup. Pair the shell caching here with stale while revalidate so an expired shell serves instantly from the edge while the CDN refreshes it in the background, which keeps a content update from ever causing a slow request. The platform you run this on shapes the exact primitives available; the trade offs between the major edge hosts are in the Vercel versus Cloudflare Pages comparison, and the framework layer that renders the shell is covered in the Hydrogen versus Next.js writeup.

When This Applies To Your Stack

Reach for this architecture when you run a headless storefront with real traffic and any personalization at all, which is nearly all of them. The failure it prevents is the one where a team ships personalization, marks the page private to be safe, and watches their edge hit rate and their page speed fall off together. Draw the boundary once: shared shell cached and keyed on segment, low cardinality dimensions on Vary, per user fragments hole punched and filled asynchronously. That single discipline preserves the performance case that justified going headless while still showing each user their own cart, price, and name.

Skip the complexity only if your storefront is genuinely one price, one market, one language, and anonymous, in which case the whole page is public and you can cache it flat. The moment a member price or a logged in state enters the picture, the boundary work is not optional, it is the difference between a fast personalized store and a slow one that leaks data.

If you are building or migrating a headless storefront and personalization is dragging your edge hit rate down, that is the headless commerce and platform architecture work we do at Contra Collective. The performance is not lost to personalization; it is lost to caching the whole page as private when only a fragment ever needed to be.

FAQ

Why not just mark personalized pages uncacheable? Because that sends every request to origin and discards the reason to go headless. Almost all of a personalized page is identical for all users; only a small fragment differs. Caching the shared shell and isolating the fragment keeps the page fast while still personalizing it.

What is the difference between a cache key and Vary? The cache key is the identity of the cached object, usually the URL plus normalized dimensions like segment. Vary tells the cache to store separate variants based on a request header such as language or currency. Both split the cache; use them only on low cardinality dimensions that map to a class of users, never to an individual.

What is hole punching? Caching a page shell as public with a placeholder where per user content goes, then filling that placeholder separately, either at the edge with an include or on the client with a small fetch. It lets a mostly shared page carry a private fragment without becoming private itself.

Why is varying on Cookie or User-Agent a mistake? Both are high cardinality. A raw cookie carries a per user token and a raw User-Agent has thousands of values, so varying on either creates a distinct cached variant per user or per browser string and collapses your hit rate. Normalize the one dimension you need into a small header first.

How does this interact with cache invalidation? A long lived cached shell needs a purge strategy when content or price changes, and it should be paired with stale while revalidate so an expired shell still serves instantly while it refreshes in the background. Invalidation strategy is a separate decision from the keying and hole punching covered here.

[ 02 ] — Keep Reading

More from the lab.

Oct 5, 2026 Headless Commerce

Adobe's 275 Billion Dollar 2026 Holiday Forecast: Is Your Commerce Stack Ready for Peak Season

Adobe's 2026 holiday forecast puts US online sales at 275.1 billion dollars between November 1 and December 31, up 6.7 percent year over year. The bigger number in the report is not the top line: AI referred traffic to retail sites is projected to grow 130 percent, with spikes to 159 percent on Thanksgiving alone. Most teams are reading this as a marketing story. It is an infrastructure story.

Oct 5, 2026 Headless Commerce

Shopify Lets Meta's Muse Check Out, Amazon Blocks It: The Platform Split Over AI Shopping Agents

On September 22, Shopify and Meta announced that Muse, Meta's AI shopping agent, can search Shopify merchant catalogs and complete purchases through Shop Pay. Within days, Amazon blocked the same agent from its marketplace, citing unauthorized agent access, identity concealment, and credential collection. Two platforms, the same AI agent, and two opposite bets on what agentic commerce should look like.

Oct 3, 2026 Headless Commerce

Mastercard Agent Pay, Visa TAP, and ACP: The Agentic Payments Protocol Map for Merchants (October 2026)

On October 1, 2026, Mastercard added a probability score and a set of trust and intelligence signals to Agent Pay, its framework for telling issuers whether a transaction was actually initiated by an AI agent, built in partnership with agent identity provider Skyfire and with Cloudflare on the data side. The same day, trade press mapped out eight separate protocols now live in agentic commerce: MCP, A2A, ACP, UCP, AP2, TAP, Agent Pay, and MPP, backed by Anthropic, Google, OpenAI, Stripe, Shopify, Visa, and Mastercard respectively or in combination. None of them has lost. They are stacking, not competing, which is both good news and a real integration problem for anyone who is not Shopify.

Ready when you are

Want to discuss this topic?

Start a Conversation