Permalink

Comment #11

Nora PatelCommerce developer
replying to #10
There's a related trap in how product data itself gets shaped for faceted navigation. If collection loaders pull full variant arrays just to compute which filter values exist, every facet change drags a payload the page never renders, and stale facet counts linger in cache alongside the products. I've had better luck fetching filter metadata through a separate query keyed by collection handle and a short TTL, then letting product results stay lean and long-lived. Search analytics from Shopify's Search & Discovery app also feed back into which facets are worth surfacing, so the query design and the merchandising config drift apart fast unless someone owns both.
Around this reply

More discussion

View full thread
Marcus ReedEdge developer
replying to #9
Leo, that variant extends further into anything the loader derives from time rather than identity. We served a promotional banner from a loader that checked whether a sale window had passed, and because the response carried a long edge TTL, shoppers kept seeing expired pricing well after the cutoff. The fix was keeping the decision out of the cached HTML entirely: render a neutral shell, then have a tiny client fetch resolve the promotion state against a short-lived endpoint. The same pattern handles delivery estimates and stock messaging, since those depend on clocks and warehouse state that no cache key can capture. Worth noting that cache-hit ratio and time-to-first-byte observability need to be tracked separately from loader latency, otherwise a stale-content incident looks like a slow origin.
Maya ChenFrontend developer
replying to #11
Variant data has the same shape problem but in reverse. Pulling every option combination into the collection loader inflates the payload and, worse, bakes availability into cached HTML that goes stale the moment one SKU sells out. What has worked better for me is a dedicated selection query that runs on demand once a shopper actually picks a combination, keyed by product handle plus selected options and served with a short TTL, so the collection page never carries variant arrays it won't render. That shifts the tradeoff to a small client round trip at selection time, which is usually invisible, and keeps the bulk of the page cacheable with boring keys. The annoyance is mapping Shopify's option position semantics onto a flat key you can actually cache.
Leo MartinWeb developer
replying to #8
That custom header approach has a lurking variant that bit me on a recent build. Anything the loader computes from request context, not just identity, changes the response: serving an estimated delivery window on collection cards derived from `localization.country` or a warehouse lookup means two visitors hitting the same
Leo MartinWeb developer
replying to #12
The debugging side of this deserves its own thought, because cached HTML breaks the usual assumption that what your logs show is what a visitor saw. Accidentally embedding a render timestamp in a server-rendered component is the classic version: the HTML gets cached with that moment frozen, and it quietly confirms the wrong build is still live
AnonymousCommunity member
replying to #7
The cache key problem shows up in another place too: personalization. If a loader reads a customer token to decide what to render, the response can no longer be shared, and one logged-in visitor can quietly poison a cached route for everyone else if the vary logic is loose. The practical split I keep landing on is keeping loaders anonymous by default and hydrating anything customer-specific from a client component after paint, or from a separate endpoint with its own no-store policy. That costs a round trip, but it means the server-rendered HTML stays cacheable and the cache key stays boring. Skeptical about how well that holds once shops want logged-in pricing on collection pages.
Sophie EvansUI developer
replying to #13
Cached HTML also freezes accessibility semantics in ways that are easy to miss. If a loader formats prices or dates for `localization.country` server-side, a German shopper can end up with a cached English date string, and screen readers announce it with the wrong language because the `lang` attribute on that fragment was rendered alongside it. The same thing happens with `dir` on RTL markets. What works better is emitting a neutral, machine-readable value with an explicit `lang` and letting a tiny client formatter handle the locale-specific
Maya ChenFrontend developer
replying to #6
Route-level cache policies sound right, but React Router loaders and edge caching can fight each other in practice. I hit this querying `storefront.query` with a `@inContext` directive for localization: Oxygen caches per request URL, so `/products/x?country=DE` and `/products/x?country=US` produce separate entries even when the underlying data barely differs. Two mitigations worth testing are restructuring so currency lives in a cookie or subdomain instead of the query string, and returning a `Cache-Control` header from the loader via `headers()` rather than relying on platform defaults. Cloudflare Workers give more granular control here through `caches.default`, though you own the invalidation logic. Either way, decide your cache key dimensions before writing queries, not after.
Nora PatelCommerce developer
replying to #14
Sophie's point about baked-in locale formatting connects to something bigger: cached HTML freezes the *entire* accessible name of a control, not just its text. If your variant picker renders `aria-label="Size Medium, out of stock"` server-side from a Storefront API availability read, that label ships inside the cached response even though another visitor hits the same product with Medium in stock. The shopper sees a selectable swatch, but a screen reader announces it as unavailable until hydration corrects it. I've started passing raw availability into the markup and composing the label in the component after the selection query resolves, keeping the cached shell purely structural. It costs a flicker in announced state, which is arguably worse, so I'm genuinely unsure which failure mode to prefer here.
Join the discussion

Leave a comment

Name and message are required. Website is optional. Public website links use rel="ugc nofollow" to reduce comment spam.