The Section Rendering API Returned Yesterday's Cart Count

Share
The Section Rendering API Returned Yesterday's Cart Count. Abstract shopify illustration in orange and dark grey on debugly.dev

A merchant reported that the cart count in the header was wrong. Not stale by a few seconds. Wrong by a whole session. One customer added three items and the header said eleven. Then it said two. It settled on the correct number only after a hard reload.

Eleven was another customer's cart.

The theme was using the Section Rendering API to refresh the header after an add to cart, which is the correct technique. The problem was that the rendered fragment was being cached without regard for who was asking.

This was on a Shopify Online Store 2.0 theme with a custom header section, tested against Chrome 133 and Safari 17.

What the Section Rendering API actually is

/ with a sections parameter returns the rendered HTML for specific sections only:

const res = await fetch(`${window.location.pathname}?sections=cart-drawer,header`);
const data = await res.json();
document.querySelector("#header").innerHTML = data["header"];

It is enormously useful. You get server rendered Liquid output for one fragment without a full page load, which is exactly what you want after an add to cart, a variant change or a discount code apply. It is faster than a client side re render and it keeps the source of truth in Liquid rather than duplicating your templating in JavaScript.

It is also a GET request to a URL. And GET requests to URLs get cached.

Why the fragment goes stale

Three caching layers sit between your fetch and the Liquid render, and none of them know what a cart is.

Shopify's storefront cache. Shopify caches storefront responses at the edge. Section rendering responses participate in that caching. The cache key is derived from the URL, which includes the sections parameter but does not include anything about the customer.

The theme's own cache headers. If your section sets Cache-Control with a long max age, or if a reverse proxy in front of the store does, the fragment is cached with an explicit lifetime.

The browser. A GET with cacheable headers will be served from the memory or disk cache on a repeat request. In practice this is the layer that produces the most confusing bug reports, because it makes the wrong answer appear and disappear based on navigation history.

The result is that a header fragment rendered for customer A gets served to customer B. Cart count, customer name, wishlist state, tier pricing, loyalty balance. Anything customer specific in a rendered section is exposed.

The fix

Mark the request as non cacheable

The first thing to do is make the intent explicit in the fetch:

const res = await fetch(`${window.location.pathname}?sections=header`, {
  cache: "no-store",
  headers: { "Cache-Control": "no-cache" },
});

cache: "no-store" prevents the browser from writing or reading a cache entry for that request. That alone resolves the majority of reports, because the browser layer is where the same user sees their own stale state.

It does not fix cross customer leakage at the edge.

Keep customer state out of the rendered fragment

This is the real architectural fix, and it is the one I would insist on in review.

Split the section into a shell and the volatile part. Render the static structure server side through the Section Rendering API. Render the cart count, the customer name and anything else identity dependent client side from a source that is already scoped to the session.

For the cart specifically, Shopify gives you the right endpoint:

const cart = await fetch("/cart.js", { cache: "no-store" }).then(r => r.json());
document.querySelector("[data-cart-count]").textContent = cart.item_count;
document.querySelector("[data-cart-count]").hidden = cart.item_count === 0;

/cart.js is scoped to the current session and is not shared across customers. Combining a cacheable shell with an uncacheable session scoped number gives you the performance of section rendering without the leak.

Bust the cache when you must render customer state

Sometimes the fragment genuinely has to be server rendered with customer context, usually because the logic lives in Liquid and depends on metafields or customer tags. In that case, make the URL unique per session:

const url = `${window.location.pathname}?sections=header&_t=${Date.now()}`;

A cache buster is ugly and it defeats the caching you were relying on, but it is correct. If you find yourself adding one to every section render, that is a signal the architecture is wrong and you should be moving the volatile part client side instead.

The test that catches this

Log in as two different customers in two different browser profiles. Add a distinctive quantity to one cart. Then in the other profile, trigger the section render and inspect the response.

If the count from profile A appears in profile B, you have the bug. This takes about two minutes and it is the only reliable way to check, because the bug does not reproduce for a single user in a single session unless the cache happens to be cold.

I would add it to the QA checklist for any theme that uses the Section Rendering API, alongside the checks in the Shopify theme debugging toolkit.

The wider pattern

This is not a Shopify specific failure. It is the same defect as the cache key that was missing a tenant ID, wearing a different costume. Any time you cache a response that was generated with request specific context, the cache key has to include every dimension of that context, or the response has to stop containing context dependent data.

Section rendering makes it easy to accidentally cache something you should not, because the API hands you a URL and URLs feel cacheable. They are. That is the trap.

The rule I now apply: if a rendered fragment would be wrong for a different logged in user, it must not be fetched through a cacheable URL. Either scope the cache key to the session, or move the volatile piece somewhere that is already scoped.

If you are also seeing performance problems in the same theme, the interaction between fragment fetching and render blocking resources is covered in the theme JavaScript that blocked rendering, and the hero image case in your hero image is lazy loaded.