Short answer
Storefront API performance in Hydrogen comes down to caching each sub-request by how volatile its data is, and shaping queries so that volatile and stable data are never fetched together. Be aware of a recent change: in Hydrogen 2026-10, a query is only cached if you pass a strategy such as Cache.long(). Classic Hydrogen (2026-04 and earlier) cached Storefront API queries by default. Upgrading without adding strategies quietly turns sub-request caching off.
A Hydrogen page typically makes several Storefront API calls on the server: the product, its recommendations, the menu, perhaps a CMS block. Each is a sub-request, and each can be cached independently in the runtime’s cache store (the Cache API on Oxygen or Cloudflare Workers). Getting that right does more for time to first byte than almost anything else in a headless storefront. The higher-level question of whether to go headless at all is covered in Shopify Hydrogen and Custom Storefronts; this article assumes you have, and want it fast.
Two versions of Hydrogen, two caching APIs
Hydrogen 2026-10 is a library you add to your own React Router app rather than a framework that owns the server. Its caching API changed with it. Shopify now labels the older documentation as legacy, and much of what ranks for “Hydrogen caching” describes the classic API.
| Classic Hydrogen (2026-04 and earlier) | Hydrogen 2026-10 | |
|---|---|---|
| Query call | context.storefront.query() |
storefrontClient.graphql() |
| Default when no strategy is given | Cached: public, max-age=1, stale-while-revalidate=86399 |
Not cached |
| Long-lived data | CacheLong(): 1 hour fresh, 23 hours stale |
Cache.long(): same durations |
| Volatile data | CacheShort(): 1 second fresh, 9 seconds stale |
Cache.short(): same durations |
| Opt out | CacheNone() |
Cache.none() |
| Custom | CacheCustom({maxAge, staleWhileRevalidate, ...}) |
Cache({maxAge, staleWhileRevalidate, staleIfError}) or Cache.short({maxAge: 60}) |
| Third-party APIs | createWithCache() then withCache.fetch / withCache.run |
createRunWithCache() and createFetchWithCache() |
Both versions share important rules. Mutations are never cached. Responses containing GraphQL errors are not cached. The cache key is derived from the query and its variables. Customer Account API data is never cached. Sources: Shopify’s current caching guide and the legacy caching guide.
Match the strategy to how the data changes
| Data | Strategy | Reason |
|---|---|---|
| Menus, pages, blog content, shop policies | Cache.long() |
Changes on publish, rarely |
| Product and collection content (title, description, images) | Cache.long() or a custom middle value |
Edited occasionally; brief staleness is harmless |
| Price and availability | Cache.short() |
Changes with sales and stock; stale prices cause disputes |
| Search results, filtered collections | Cache.short() with a modest maxAge |
Many variable combinations, low hit rate per key |
| Cart | Never cached | Per buyer, and mutated constantly |
| Customer data | Never cached; set private or no-store on the page |
Shared caches would leak it |
Stale-while-revalidate is what makes long strategies safe. A stale entry is served immediately while Hydrogen refreshes it in the background, and with waitUntil that refresh can finish after the response has been sent. The buyer never waits on the refetch.
Wiring it up in Hydrogen 2026-10
Pass the cache store and waitUntil when you create the client, then pass a strategy on every query you want cached:
// Server entry (Oxygen / Workers). Check signatures against your installed version.
import {createStorefrontClient, createShopifyRequestContext, Cache} from '@shopify/hydrogen';
const cache = await caches.open('hydrogen');
const storefront = createStorefrontClient({
type: 'private',
requestContext: createShopifyRequestContext({request, i18n, buyerIp}),
config: {
storeDomain: env.PUBLIC_STORE_DOMAIN,
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
cache,
waitUntil: executionContext.waitUntil.bind(executionContext),
},
});
// In a loader:
const {data} = await storefront.graphql(PRODUCT_CONTENT_QUERY, {
variables: {handle},
cache: Cache.long(),
});
Shopify’s guide is explicit about two things: pass the cache store directly (do not wrap it or swap in an in-process LRU), and never pass a strategy to a personalised query. The client does not support a private cache mode, so personalised reads belong on an uncached, request-scoped client.
If you are still on classic Hydrogen, the equivalent is the cache option on storefront.query:
// Classic Hydrogen (2026-04 and earlier)
const {product} = await context.storefront.query(PRODUCT_CONTENT_QUERY, {
variables: {handle},
cache: context.storefront.CacheLong(),
});
When you migrate, search the codebase for every query that relied on the old default. Those are the ones that will silently go uncached.
Fragment queries: split stable from volatile
A GraphQL fragment does not create its own cache entry. Caching is per query plus variables, so a fragment helps in two indirect but significant ways.
First, fragments let you split one large query into a stable query and a volatile query without duplicating field lists. A product page that fetches content and live price in one query has to cache the whole thing with the shortest TTL either part needs. Split it, and the heavy content query gets Cache.long() while a small price query gets Cache.short():
const PRODUCT_MEDIA = `#graphql
fragment ProductMedia on Product {
featuredImage { url altText width height }
images(first: 8) { nodes { url altText width height } }
}
`;
export const PRODUCT_CONTENT_QUERY = `#graphql
query ProductContent($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
id title descriptionHtml vendor
...ProductMedia
}
}
${PRODUCT_MEDIA}
`;
export const PRODUCT_PRICE_QUERY = `#graphql
query ProductPrice($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) {
variants(first: 100) {
nodes { id availableForSale price { amount currencyCode } }
}
}
}
`;
// In the loader: run both in parallel with different strategies.
const [content, price] = await Promise.all([
storefront.graphql(PRODUCT_CONTENT_QUERY, {variables, cache: Cache.long()}),
storefront.graphql(PRODUCT_PRICE_QUERY, {variables, cache: Cache.short()}),
]);
Second, fragments keep query text identical across routes. If the collection grid and the search grid both use the same ProductCard fragment and the same variables shape, they are easier to keep lean, and you avoid near-duplicate queries that differ by a field or two and each need their own cache entries.
Two cautions. Market context changes the cache key, because @inContext values are variables: a store selling to many countries has proportionally more keys and a lower hit rate per key. And bound every connection with first:. An unbounded connection is both slow and expensive in query complexity.
Caching third-party data alongside Shopify
Reviews, CMS blocks and loyalty data are sub-requests too. In 2026-10, createRunWithCache wraps any async work under a key and strategy:
import {createRunWithCache, Cache} from '@shopify/hydrogen';
const runWithCache = createRunWithCache({cache, waitUntil});
const {data: reviews} = await runWithCache(
{key: ['reviews', handle], strategy: Cache.short({maxAge: 60})},
async () => {
const res = await fetch(`https://reviews.example.com/products/${handle}`);
// Only cache successful responses.
return {data: await res.json(), shouldCache: res.ok};
},
);
The returned data must be JSON-serialisable. For customer-specific third-party data, include something private to that customer in the key, or do not cache it.
Full-page caching on Oxygen
Sub-request caching still runs your loader and renders the page. Oxygen can also cache the whole HTML response. According to Shopify’s Oxygen documentation (written for classic Hydrogen; confirm for your version), a response is cacheable only if it is a GET, returns 2xx or 3xx, carries a public Oxygen-Cache-Control header with a non-zero max-age or s-maxage, and sets a Vary header. A Set-Cookie header makes it uncacheable. The Oxygen-Full-Page-Cache response header reports Hit, Miss, Stale or Uncacheable, which is the quickest way to debug.
The constraint to design around: you cannot purge Oxygen’s full-page cache for the current deployment. Content waits for expiry or a new deployment. Keep full-page TTLs short on anything merchants edit, and rely on sub-request caching for the heavy lifting.
Storefront API rate limits, briefly
The Storefront API reference describes a different model from the Admin API’s cost-based bucket:
- Requests from real buyers have no fixed requests-per-minute limit. Bots and crawlers are limited, most strictly when requests are unsigned.
- Server-side requests made on a buyer’s behalf should send the
Shopify-Storefront-Buyer-IPheader. Without it, Shopify cannot tell your buyers apart and treats your server as one client. - Tokenless access has a query complexity limit of 1,000; exceeding it returns
MAX_COMPLEXITY_EXCEEDED. Private tokens stay on the server, public tokens are for browsers and apps. - Checkout creation is throttled per minute and returns a throttled error when exceeded.
Catalogue writes are a different problem entirely: they go through the Admin API, and for large catalogues through bulk operations and productSet. Caching is what keeps you clear of bot-related throttling during crawls and traffic spikes, because cached sub-requests never reach the API. The Admin API side, where cost budgets really do bite, is covered in Shopify App Development: OAuth, Webhooks and Rate Limits.
A working checklist
- On Hydrogen 2026-10, give every cacheable query an explicit strategy. Nothing is cached by default.
- Split content and price/availability into separate queries with shared fragments, cached long and short respectively.
- Run independent sub-requests in parallel with
Promise.all, not one after another. - Never cache cart, customer or other personalised queries; set
privateorno-storeon those pages. - Send
Shopify-Storefront-Buyer-IPon buyer-driven server requests. - Bound every connection with
first:and request only fields the page renders. - Use full-page caching only on routes that are identical for every visitor, with short TTLs, since there is no purge.
If your storefront is Next.js rather than Hydrogen’s React Router setup, Shopify’s guidance is to use the framework’s own data cache ("use cache", cacheLife, cacheTag) instead of Hydrogen’s sub-request cache. How those layers interact is explained in Next.js Caching Explained.
Questions people actually ask
- Does Hydrogen cache Storefront API queries by default?
- In classic Hydrogen (2026-04 and earlier), yes: one second fresh with a long stale-while-revalidate window. In Hydrogen 2026-10, no: a query is cached only when you pass a strategy, and a client with a cache store but no strategies caches nothing.
- What is the difference between CacheShort and CacheLong?
CacheShort(orCache.short()) is fresh for 1 second and may be served stale for 9 more.CacheLong(Cache.long()) is fresh for 1 hour and may be served stale for 23 more. Use short for price and availability, long for content.- Does the Storefront API have a rate limit?
- Not a fixed per-minute limit for genuine buyer traffic. Bots are limited, tokenless access has a 1,000 complexity cap, and checkout creation is throttled. Send the buyer IP header from your server so Shopify can distinguish buyers.
Is your Hydrogen storefront slower than it should be?
I can audit your sub-request caching, query shapes and full-page cache headers, and plan a safe upgrade to Hydrogen 2026-10 that does not lose caching along the way.