Shopify Hydrogen Caching: Sub-Requests and Full Pages

Shopify Hydrogen caching happens at two levels. Each Storefront API sub-request takes a cache option (CacheShort, CacheLong, CacheNone or CacheCustom), and third-party calls go through withCache. Oxygen will also cache whole HTML pages, but only when you send an Oxygen-Cache-Control header plus a Vary header, and no Set-Cookie.

The cookie condition is the one that fails without telling you. The details below come from @shopify/hydrogen 2026.4.5, the current stable release as of September 2026, which targets Storefront API 2026-04.

What does Hydrogen cache if you set nothing?

More than you might expect. A storefront.query() call with no cache option is cached with max-age=1, stale-while-revalidate=86399. The value is fresh for one second, and after that Hydrogen will serve the stale copy for up to a day while it refetches in the background.

On a busy product page that behaves like no caching at all, because the background refresh runs almost constantly. On a page nobody has opened since yesterday, the first visitor gets yesterday’s data and the refresh happens behind them. So a merchant who fixes a price in the admin and then opens a quiet product page can see the old price once. That is the default working as designed, and it is worth explaining to the merchant before launch.

Mutations are different. storefront.mutate() has no cache option in its type signature, so cart and customer writes never touch the sub-request cache.

Which cache strategy should each query use?

The four helpers are thin wrappers that produce a Cache-Control value. Read from the 2026.4.5 source:

  • CacheShort() is public, max-age=1, stale-while-revalidate=9, ten seconds in total.
  • CacheLong() is public, max-age=3600, stale-while-revalidate=82800, fresh for an hour and usable for a day.
  • CacheNone() is no-store.
  • CacheCustom() takes mode, maxAge, staleWhileRevalidate, sMaxAge and staleIfError and passes them straight through.

CacheShort and CacheLong also accept overrides. Pass mode: 'no-store' to either and you get 'mode' must be either 'public' or 'private' thrown at runtime; use CacheNone() for that instead.

We split queries by who edits the data and how quickly a stale answer costs money. Menus, shop policies, metaobject-driven content and collection descriptions change when a person edits them, so CacheLong() is fine. Product data that carries price or availability gets the default or CacheShort(). Anything tied to a signed-in buyer gets CacheNone().

// app/routes/products.$handle.tsx
import type {LoaderFunctionArgs} from 'react-router';

export async function loader({params, context}: LoaderFunctionArgs) {
  const {storefront} = context;

  const [{product}, {menu}] = await Promise.all([
    storefront.query(PRODUCT_QUERY, {
      variables: {handle: params.handle!},
      cache: storefront.CacheShort(),
      displayName: 'product',
    }),
    storefront.query(FOOTER_MENU_QUERY, {
      variables: {handle: 'footer'},
      cache: storefront.CacheLong(),
      displayName: 'footer-menu',
    }),
  ]);

  if (!product) throw new Response(null, {status: 404});
  return {product, menu};
}

displayName labels the call in the dev server’s Subrequest Profiler, which shows each sub-request’s cache status (HIT, MISS, STALE or PUT). Open it before changing any strategy, because it shows which calls actually miss.

How do you cache a third-party API in Hydrogen?

With createWithCache, which is not part of createHydrogenContext. You create it yourself and pass it in as additional context. In the skeleton template that means app/lib/context.ts:

import {createHydrogenContext, createWithCache, type WithCache} from '@shopify/hydrogen';

declare global {
  interface HydrogenAdditionalContext {
    withCache: WithCache;
  }
}

// inside createHydrogenRouterContext(), after cache and waitUntil exist
const withCache = createWithCache({cache, waitUntil, request});

const hydrogenContext = createHydrogenContext(
  {env, request, cache, waitUntil, session, i18n: {language: 'EN', country: 'US'}},
  {withCache},
);

request is required. The utility gives you two methods. withCache.fetch(url, init, options) wraps a single fetch and resolves to {data, response}. withCache.run(options, fn) caches whatever your function returns, which suits an SDK that does its own HTTP.

const REVIEWS_API = 'https://reviews.example.com/v1/summary';

type ReviewSummary = {rating: number; count: number};

export async function loader({params, context}: LoaderFunctionArgs) {
  const {storefront, withCache} = context;

  const {data: reviews} = await withCache.fetch<ReviewSummary>(
    `${REVIEWS_API}/${params.handle}`,
    {headers: {Accept: 'application/json'}},
    {
      cacheKey: ['reviews-summary', params.handle],
      cacheStrategy: storefront.CacheCustom({
        mode: 'public',
        maxAge: 60,
        staleWhileRevalidate: 600,
      }),
      shouldCacheResponse: (body, response) =>
        response.ok && typeof body?.rating === 'number',
    },
  );

  return {reviews};
}

In 2026.4.5 the types make shouldCacheResponse and shouldCacheResult required. Keep them honest: plenty of CMS and reviews APIs return 200 with an error in the body, and without the check you cache that error for as long as your strategy allows.

If you leave out cacheKey on withCache.fetch, Hydrogen keys on the URL and the init object. That works for GET. For a GraphQL POST, set the key yourself from the endpoint and the query text, as the third-party caching guide does, and add anything that changes the answer, such as locale.

How does Shopify Hydrogen caching work for whole pages?

Sub-request caching still runs your loaders and renders React on every request. Oxygen’s full-page cache skips both, and it’s strict. A response is cached only if it answers a GET, has a 2xx or 3xx status, carries a public Oxygen-Cache-Control header with a non-zero max-age or s-maxage, and has a Vary header that isn’t *. Any Set-Cookie header makes it uncacheable.

Note the header name. Oxygen reads Oxygen-Cache-Control, not Cache-Control, so you can give browsers one policy and the edge another.

The Set-Cookie rule is where pages quietly miss. The skeleton’s server.ts commits the session cookie whenever session.isPending is true. createRequestHandler has collectTrackingInformation on by default, and in 2026.4.5 it calls storefront.setCollectedSubrequestHeaders(), which appends any Set-Cookie headers collected from Storefront API sub-requests to your response. Either one makes Oxygen skip the page, whatever your cache header says.

So set the header late, after those have run, and check for the cookie:

// server.ts, after handleRequest and the session commit
const CACHEABLE = /^\/(pages|policies|blogs)\//;

const response = await handleRequest(request);

if (hydrogenContext.session.isPending) {
  response.headers.set('Set-Cookie', await hydrogenContext.session.commit());
}

const url = new URL(request.url);
if (
  request.method === 'GET' &&
  response.status === 200 &&
  !response.headers.has('Set-Cookie') &&
  CACHEABLE.test(url.pathname)
) {
  response.headers.set(
    'Oxygen-Cache-Control',
    'public, max-age=300, stale-while-revalidate=600',
  );
  response.headers.set('Vary', 'Accept-Encoding, Accept-Language');
}

Accept-Encoding, Accept-Language is the Vary value Shopify suggests as a default. If you localise by cookie rather than by URL path, you can’t vary on it safely: Oxygen matches whole header values and can’t match on a single cookie. Use subpath locales (/en-gb/...) if you want full-page caching across markets.

Two operational facts to plan around. The cache is scoped to a deployment, so a new deploy starts cold. And there is no manual purge: you wait for expiry or you deploy. That’s the reason to keep max-age in minutes, not hours, on anything a merchant edits. The stale-while-revalidate directive behaves here as it does on any CDN.

What should never be cached?

Shopify’s caching overview says Customer Account API data is never cached, because it is personal to each buyer. The Storefront API is your responsibility. A query that passes a customer access token, a B2B buyer context or anything else that makes the answer personal needs CacheNone(), and the page needs no-store or private in its headers.

Oxygen’s own docs spell out the failure: a page rendered with a serialised cart session and then cached can serve that cart to other shoppers. The cart drawer, account pages and anything reading context.cart.get() on the server stay out of the full-page cache.

What changes in the developer preview?

As of September 2026, Hydrogen’s 8 July developer preview adds opt-in edge caching for catalogue data through client.graphql(QUERY, {cache: Cache.long()}), alongside createFetchWithCache() and createRunWithCache(). It refuses mutations and private cache mode and returns a Cache-Status header. It is a preview, so production code on 2026.4.x keeps using the API above.

What we would set on a new storefront

Keep the default on product queries, and use CacheLong() for menus, policies and content. Wrap every third-party call in withCache with a real shouldCacheResponse, and only turn on full-page caching for content routes that never read the session. We would not put Oxygen-Cache-Control on product or collection pages until the Subrequest Profiler shows sub-request caching isn’t enough. A stale sub-request corrects itself on the next background refresh, while a wrongly cached page stays live until it expires or you deploy. If you are still weighing the stack itself, our Hydrogen vs Next.js comparison covers what Oxygen gives you and what you give up.

Whoooop builds and tunes Hydrogen storefronts for merchants who have outgrown a theme. That includes caching audits like the one above, starting from a Subrequest Profiler trace of the slowest routes. Our Shopify development page covers the rest of that work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch