HEADLESS · STOREFRONT API · TECHNICAL · 28 OCTOBER 2025 · 8 MIN READ
Storefront API caching: what can go stale and what cannot
Cache the catalogue aggressively, cache price and stock deliberately, and never cache the person. The third one is the mistake that ends up in a postmortem.
Split every query into three buckets and treat them differently. Catalogue and content — titles, descriptions, images, collection membership, metafields — can be cached for a long time and served stale while it revalidates, because a description that is an hour old harms nobody. Price and inventory are their own category: cacheable, but with a TTL you choose against the commercial cost of being wrong, and invalidated on the webhook rather than left to expire. Anything tied to a person — cart, customer, addresses, order history — must never touch a shared cache at all. Hydrogen makes this explicit with CacheLong, CacheShort and CacheNone, and the reason it does is that the failure is not a slow page, it is one buyer seeing another buyer's cart.
IN SHORT
- Hydrogen's documented strategies are `CacheShort()` at `public, max-age=1, stale-while-revalidate=9`, `CacheLong()` at `public, max-age=3600, stale-while-revalidate=82800`, `CacheNone()` at `no-store`, and `CacheCustom()`.
- Hydrogen documents the default for sub-requests as `public, max-age=1, stale-while-revalidate=86399` when no cache option is given — so an uncached-looking query is already being cached.
- There are two independent layers: sub-request caching of API responses, and full-page caching of the rendered HTML. Both have to be right or personalised data leaks.
- Shopify documents that Customer Account API data is never cached, but Storefront API `customer` queries return personalised data you must protect yourself with `CacheNone()` and the right response headers.
- Use `no-store` for anything that must not be stored anywhere, and `private, max-age=<seconds>` when the browser may cache but shared caches may not.
- Shopify states that Storefront API requests from real buyers are not subject to a fixed request-per-minute limit, so cache for speed and cost, not to dodge throttling.
- With a private access token, Shopify documents the `Shopify-Storefront-Buyer-IP` header as necessary to differentiate buyers, and warns that without it requests can be throttled.
Two layers, and why both have to be right
The first thing to get straight is that there are two caches, they are configured separately, and getting one right does not save you from the other.
Sub-request caching is the response to an individual Storefront API query — the product query, the collection query, the menu query. In Hydrogen this is the cache option on the query, and the documented default when you omit it is public, max-age=1, stale-while-revalidate=86399: revalidate after a second, serve stale for up to a day. That default matters more than it looks, because a developer who has never passed a cache option has not opted out of caching. They have opted into a day of stale-while-revalidate without noticing.
Full-page caching is the rendered HTML, stored by Oxygen or whatever shared cache sits in front of your app, controlled by the Cache-Control header on the loader response. This is the layer that leaks. A page can be assembled entirely from correctly-uncached sub-requests and still be stored by a CDN with one customer's name in it, because the HTML carried a cacheable header.
So the rule is: decide per query, then decide again for the page. A route that renders anything personal needs both CacheNone() on the personal queries and a response header that keeps the HTML out of shared caches.
Bucket one: the catalogue, which can be old
Product titles, descriptions, images, collection membership, navigation, metafield content, and anything coming out of a CMS: cache these hard. Hydrogen's CacheLong() is documented as public, max-age=3600, stale-while-revalidate=82800 — an hour fresh, then served stale for the next twenty-three while it refreshes behind the request.
Stale-while-revalidate is the mechanism that makes headless commerce feel fast, and it is worth understanding rather than copying. Within the max-age window the cached response is served outright. After it, the cached response is still served immediately, and the refresh happens in the background so the next visitor gets the new one. Nobody waits for Shopify. The cost is that one visitor sees content up to stale-while-revalidate seconds old, and for a product description that is not a cost at all.
The instinct to lower these numbers "to be safe" is the most common way a headless storefront ends up slower than the theme it replaced. A merchandiser edits a description twice a month. Caching it for sixty seconds to protect against that is paying for freshness nobody will ever observe.
Bucket two: price and stock, where the decision is commercial
This is the interesting bucket, because the right TTL is not a technical question. It is: what does it cost you to show a wrong number for this long?
For price, being stale means showing a price you no longer charge. The customer adds to cart, and the cart — which you fetched live — shows the real one. Whether that is a shrug or a complaint depends on the direction and the size of the gap, and on whether you run flash pricing. A store that changes prices in a quarterly review can cache price with the catalogue. A store running hourly promotions cannot, and should be invalidating on the product-update webhook rather than picking a shorter TTL.
For inventory, the honest position is that a cached stock number is always wrong in the way that matters most: it is stale precisely when demand is high, because that is when it changes fastest. Caching stock for ten minutes on an ordinary day is fine and useless; doing it during a drop turns your product page into an oversell generator. Which points at the better answer for most stores — do not display a precise count at all. "In stock" and "low stock" are cacheable states that tolerate being a few minutes old. "Only 3 left" does not, and it buys you urgency in exchange for a support queue.
Wherever the number genuinely has to be exact — a live availability check, a stock gate before checkout — fetch it at that moment, uncached, on that one query. One live call at the point of decision costs far less than short TTLs on every page view, and it is the only version that is actually correct.
- Catalogue and content: long TTL with a generous stale-while-revalidate window.
- Price: cache with the catalogue if prices are stable; invalidate on webhook if they are not.
- Stock state (in / low / out): short TTL, and prefer a state over a number.
- Exact stock count at the point of purchase: uncached, fetched at that moment, or not shown.
- Cart, customer, addresses, orders: never cached, at either layer.
Bucket three: the person, which is never cached
Shopify documents that Customer Account API data is never cached by Hydrogen. That is a useful floor, and it is not the whole risk. Storefront API queries that return personalised data — the customer object, cart state — are yours to protect: CacheNone() at the sub-request level, and a Cache-Control header on the page that stops a shared cache storing the HTML.
The documented headers are the ones to learn. no-store means nobody stores this, anywhere. private, max-age=<seconds> means the buyer's own browser may keep it but shared caches may not — which is the right answer for a logged-in page that is safe to reuse on a back button and catastrophic to reuse across users.
The specific bug worth guarding against is boring and severe. Somebody adds a small personalised element — a name in the header, a loyalty balance, a cart count rendered server-side — to a route that was previously anonymous and cached at the page level. Nothing errors. The page renders correctly for the person who warmed the cache, and then serves them to everyone else. It will not appear in tests, because tests do not usually run two different users through the same shared cache. It appears in a support ticket.
The cheap defence is a rule rather than vigilance: personalised data renders on the client, or its route is never page-cached. Pick one per route and write it down next to the route.
Invalidation, and why TTL is not enough on its own
A TTL answers "how long may this be wrong". It does not answer "make this right now", and every store eventually needs the second one — a mispriced product, a corrected description, a sale that starts at nine.
The mechanism is webhooks. Subscribe to product and inventory updates, and let the handler purge or re-warm the affected keys rather than waiting for expiry. This lets you set genuinely long TTLs, which is the point: long TTL plus event-driven invalidation gives you both speed and correctness, whereas short TTL gives you neither properly.
Two things to get right. Webhook delivery is not guaranteed to be instant or ordered, so treat invalidation as a fast path and the TTL as the backstop — never delete the TTL because the webhook exists. And make the purge granular: purging everything on every product update turns a busy merchandising afternoon into a cold cache and a stampede on Shopify.
Do not cache to avoid rate limits
A common justification for aggressive caching is fear of throttling, and on the Storefront API that fear is mostly misplaced. Shopify states that requests from real buyers are not subject to a fixed request-per-minute limit; the limits documented for the Storefront API concern bots and crawlers, and a per-minute throttle on checkout creation. Public access tokens are described as scaling with the number of buyers based on their IP address.
That last detail has a caching consequence people miss. If you query from a server with a private access token, every request appears to come from your server rather than from a buyer. Shopify documents the Shopify-Storefront-Buyer-IP header for exactly this, and warns that without it Shopify cannot differentiate requests from different buyers, which can result in throttled requests. If a headless storefront starts throttling under load, check that header before you touch a TTL.
So cache for the two reasons that hold: latency, because a cached response is tens of milliseconds and a round trip to Shopify is not, and resilience, because a stale-while-revalidate cache keeps serving pages through an upstream wobble. Both are good reasons. Rate limits are not the one.
Questions this raises
What are Hydrogen's default cache settings?
Shopify documents the sub-request default, when no cache option is passed, as `public, max-age=1, stale-while-revalidate=86399` — one second fresh, then served stale for up to a day while it refreshes. The named strategies are `CacheShort()` at `max-age=1, stale-while-revalidate=9`, `CacheLong()` at `max-age=3600, stale-while-revalidate=82800`, `CacheNone()` at `no-store`, and `CacheCustom()`.
Should I cache inventory from the Storefront API?
Cache the state, not the number. "In stock" and "low stock" tolerate being a few minutes old; "only 3 left" does not, and it is stale exactly when demand is highest. Where an exact figure genuinely matters — a live availability check before checkout — fetch it uncached at that moment rather than shortening the TTL on every page view.
How do I stop personalised data leaking between customers?
Fix both layers. Use `CacheNone()` on the queries that return customer or cart data, and set a response header that keeps the HTML out of shared caches — `no-store` to prevent storage anywhere, or `private, max-age=<seconds>` to allow the buyer's browser but not a shared cache. The reliable rule is that a route either renders personalised data on the client or is never page-cached.
Is caching necessary to avoid Storefront API rate limits?
No. Shopify states that Storefront API requests from real buyers are not subject to a fixed request-per-minute limit; the documented limits cover bots and crawlers plus a per-minute throttle on checkout creation. Cache for latency and resilience instead. If you are throttling with a private token, check you are sending `Shopify-Storefront-Buyer-IP` — Shopify warns that without it requests from different buyers cannot be told apart.
How do I update a cached page immediately?
Subscribe to product and inventory webhooks and purge or re-warm the affected keys from the handler. Keep the TTL as a backstop, because webhook delivery is neither instant nor ordered, and keep the purge narrow — purging everything on every product update gives you a cold cache and a stampede on Shopify during a busy merchandising afternoon.
Is a short TTL the safe default?
It is the expensive one. Short TTLs everywhere cost latency on every request and still leave you unable to correct a mistake on demand. Long TTLs with event-driven invalidation give you both, and they are the reason a well-built headless storefront is fast. The exception is anything personal, where the answer is not a short TTL but no cache at all.
NEXT STEP
Free store audit
A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.
