Shopify Inventory Says In Stock and Checkout Says Otherwise
The storefront cached availability, the customer added to cart, and checkout disagreed. Here is where the state diverges and what to do about it.
Disclosure: I spent four years building Shopify themes at Debutify, finishing as CDO.
The short answer
Storefront inventory display and checkout inventory enforcement are different systems with different freshness guarantees.
The product page can be cached for minutes. Checkout is authoritative and checks at the moment of purchase. The gap between them is where overselling and confusing errors live.
Four common causes:
- Page caching serving stale availability
inventory_policyset tocontinue, which allows overselling by design- Multi location inventory where the total is not what is sellable
- A third party inventory app writing asynchronously
Check the actual state first:
https://yourstore.com/products/handle.js
That returns live product JSON including per variant available. Compare it against what the page rendered.
Where the numbers come from
Several layers, and they update at different rates.
variant.available in Liquid is a boolean computed at page render time. If the page is cached, this is as old as the cache.
variant.inventory_quantity is only exposed in Liquid when inventory tracking is on and the theme has permission. It is also render time.
/products/handle.js hits Shopify's storefront API path and is much fresher, though still subject to CDN caching in some configurations.
Checkout queries the authoritative inventory service at the moment the order is created. This is the only number that is definitionally correct.
So a customer can load a cached page showing in stock, add to cart, and be rejected at checkout because the last unit sold ninety seconds ago. That is not a bug in the strict sense. It is a consequence of caching, and it is a bad experience regardless.
Cause 1: cached pages
Shopify caches aggressively at the CDN. A popular product page during a flash sale can serve the same rendered HTML to thousands of visitors for the duration of the cache window.
Diagnose by comparing:
# what the page rendered
curl -s https://yourstore.com/products/example | grep -o 'data-available="[^"]*"'
# what is actually true
curl -s https://yourstore.com/products/example.js | jq '.variants[] | {id, title, available, inventory_quantity}'
If they disagree, caching is your answer.
The fix is not to disable caching, which would be worse. It is to fetch availability client side after load for anything where accuracy matters:
async function refreshAvailability(handle) {
const res = await fetch(`/products/${handle}.js`, {
headers: { Accept: "application/json" },
});
const product = await res.json();
for (const variant of product.variants) {
const el = document.querySelector(`[data-variant-id="${variant.id}"]`);
if (!el) continue;
el.dataset.available = String(variant.available);
el.disabled = !variant.available;
}
}
document.addEventListener("DOMContentLoaded", () => refreshAvailability(HANDLE));
The page renders fast from cache, then corrects itself. For a low stock item, doing this on page focus as well catches the customer who left a tab open.
Cause 2: inventory_policy continue
{ "inventory_management": "shopify", "inventory_policy": "continue" }
continue means "allow purchase when quantity is zero or negative". It exists for preorders and made to order products, and it gets set accidentally during bulk imports remarkably often.
Check across the catalogue:
curl -s "https://yourstore.myshopify.com/admin/api/2026-01/products.json?limit=250" \
-H "X-Shopify-Access-Token: $TOKEN" \
| jq -r '.products[].variants[] | select(.inventory_policy=="continue") | "\(.sku) \(.inventory_quantity)"'
If a variant shows continue with a negative quantity, you have been overselling and nobody noticed until fulfilment could not ship.
This is worth auditing periodically rather than investigating reactively. A negative inventory quantity is a fact that should generate an alert, and it usually generates nothing.
Cause 3: multi location inventory
The total across locations is not what a given customer can buy.
If you have three locations and one is a warehouse not enabled for online orders, the admin total includes it and the storefront's availability does not. A merchant looking at admin sees 40 in stock and a customer sees sold out, and both are correct.
Check per location:
{
productVariant(id: "gid://shopify/ProductVariant/123") {
inventoryItem {
inventoryLevels(first: 10) {
edges { node {
location { name }
quantities(names: ["available", "committed", "on_hand"]) { name quantity }
}}
}
}
}
}
The distinction between on_hand, available, and committed matters. committed is stock allocated to unfulfilled orders. A product with 10 on hand and 10 committed has 0 available, and the merchant looking at "10 in stock" in one view is looking at the wrong number.
Most confused inventory conversations I have had came down to two people looking at different quantity names.
Cause 4: an app writing asynchronously
An ERP sync, a warehouse integration, or a multichannel inventory tool writing to Shopify on a schedule.
Two failure modes. Lag, where the app updates every fifteen minutes and Shopify is stale in between. And conflict, where the app overwrites a decrement Shopify made for an order it has not seen yet, effectively resurrecting sold stock.
That second one causes real overselling and it is hard to see, because each system's logs look correct in isolation.
Check the inventory adjustment history:
{
inventoryItem(id: "gid://shopify/InventoryItem/123") {
inventoryLevel(locationId: "gid://shopify/Location/456") {
quantities(names: ["available"]) { quantity }
}
}
}
And in admin, the inventory history for a variant shows each adjustment with its source. A pattern of the app setting an absolute value shortly after Shopify decremented for an order is the signature.
The fix is for the integration to use delta adjustments rather than absolute sets where possible, and to reconcile on a schedule rather than continuously overwrite. inventoryAdjustQuantities with a delta is safe under concurrency in a way that setting an absolute value is not, for the same reason an atomic upsert beats read-then-write.
Handling the error properly at the front end
When checkout or the cart API rejects, Shopify tells you why. Most themes throw that away.
const res = await fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [{ id: variantId, quantity }] }),
});
if (!res.ok) {
const err = await res.json();
// 422 with description like "You can only add 3 of this item to your cart"
showMessage(err.description ?? "Could not add to cart");
await refreshAvailability(HANDLE); // correct the page while we are here
return;
}
The description field is written for customers and is genuinely useful. Swallowing it and showing nothing means the customer clicks add to cart, sees no response, and leaves. That is the swallowed error pattern in its most directly expensive form.
Refreshing availability after a rejection is worth doing too, because if the item is now sold out the page should say so rather than letting them try again.
Prevention
Show low stock explicitly. "Only 2 left" sets expectations and reduces the surprise when the third customer is rejected. It also converts, which makes it easier to justify.
Refresh availability client side on high traffic product pages and on tab focus.
Alert on negative inventory. A daily check for variants below zero catches both the continue misconfiguration and integration conflicts.
Audit inventory_policy after every bulk import. This is the single most common way overselling gets switched on accidentally.
Reconcile against the source of truth. If an ERP owns inventory, a scheduled comparison of Shopify's numbers against the ERP's, alerting on drift beyond a tolerance, catches sync problems before customers do. Same principle as reconciling analytics against an external source: internal consistency is not verification.