The Shopify Cart Bug That Only Happens on Slow Connections
Items vanish from the cart, quantities are wrong, and it never reproduces on your machine. Throttle your network and it appears every time.
Disclosure: I spent four years building Shopify themes at Debutify, finishing as CDO. This pattern came up repeatedly in merchant support.
The report was always some version of the same thing. "Customers say items disappear from the cart." Occasionally with a screenshot showing a cart total that did not match the items listed.
It never reproduced. Not on my machine, not on staging, not on any device in the office.
The thing that made it reproduce every single time was throttling the network to Slow 3G in DevTools.
The symptom
Add two items quickly. The cart drawer shows one. Refresh the page and both are there. Or: change a quantity, the drawer updates, refresh, and the old quantity is back.
The data was never lost. The display was wrong, and it was wrong in a way that made customers think their action had failed, so they did it again, and now they had four of something.
Why throttling made it reproducible
The Shopify AJAX cart API is a set of independent HTTP endpoints:
POST /cart/add.js add an item
POST /cart/change.js change quantity
POST /cart/update.js bulk update
GET /cart.js read current state
Each is a separate request. There is no transaction spanning them, and there is no ordering guarantee between concurrent requests from the same browser.
The theme's typical flow is:
async function addToCart(variantId, qty) {
await fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: variantId, quantity: qty }),
});
const cart = await fetch("/cart.js").then(r => r.json());
renderDrawer(cart);
}
Add, then read, then render. Correct in isolation.
Now two clicks 200ms apart on a slow connection:
t=0 add A sent
t=200 add B sent
t=900 add A completes
t=950 read 1 sent (sees A only, B still in flight)
t=1100 add B completes
t=1150 read 2 sent
t=1800 read 2 completes (A + B) -> render
t=1900 read 1 completes (A only) -> render <-- overwrites
Read 1 was sent first and finished last. Its response is stale, and it renders after the correct one. The drawer now shows one item while the server has two.
On a fast connection each cycle completes before the next click, so the interleaving never happens. That is the entire reason it does not reproduce in the office.
Three fixes, in increasing quality
1. Ignore stale responses
The minimum viable fix. Track a request sequence number and discard responses that arrive out of order.
let latestRequest = 0;
async function refreshCart() {
const seq = ++latestRequest;
const cart = await fetch("/cart.js").then(r => r.json());
if (seq !== latestRequest) return; // a newer read is in flight
renderDrawer(cart);
}
Same technique as the abort pattern in React data fetching, and the same underlying problem: a response that is no longer relevant when it arrives.
This stops the wrong render. It does not stop the requests racing, so the cart contents can still end up wrong if the mutations themselves interleave badly.
2. Serialise the mutations
Better. Queue cart operations so only one is in flight at a time.
class CartQueue {
#chain = Promise.resolve();
run(fn) {
const result = this.#chain.then(fn, fn);
this.#chain = result.catch(() => {}); // keep the chain alive on failure
return result;
}
}
const cartQueue = new CartQueue();
function addToCart(variantId, qty) {
return cartQueue.run(async () => {
const res = await fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: variantId, quantity: qty }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
});
}
Now operations happen in the order the user performed them. The catch on the chain matters: without it, one failure breaks the queue permanently and every subsequent operation hangs.
3. Use the response you already have
The best fix, and the one most themes miss.
/cart/add.js already returns the updated cart if you ask for it, and /cart/change.js returns the full cart object by default. The separate read is unnecessary.
async function addToCart(variantId, qty) {
const res = await fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
items: [{ id: variantId, quantity: qty }],
sections: "cart-drawer,cart-icon-bubble",
}),
});
const data = await res.json();
document.querySelector("#cart-drawer").innerHTML = data.sections["cart-drawer"];
document.querySelector("#cart-icon-bubble").innerHTML = data.sections["cart-icon-bubble"];
}
The sections parameter is the important part. It asks Shopify to render those theme sections server side and return the HTML alongside the cart JSON. One request instead of two, no window for a race, and the markup is rendered by the same Liquid that renders it on a full page load, so it cannot drift out of sync with your templates.
This is the Section Rendering API and it is the correct approach for cart updates in a modern theme.
Handling the errors properly
While you are in here, the error paths in most cart implementations are worth fixing too.
const res = await fetch("/cart/add.js", { ... });
if (!res.ok) {
const err = await res.json();
// 422 with description: "You can only add 3 of this item to your cart"
showError(err.description ?? "Could not add to cart");
return;
}
Shopify returns 422 with a useful description when a variant is out of stock or an inventory limit is hit. Themes routinely swallow this and show nothing, so the customer clicks add to cart, nothing happens, and they leave.
Silently ignoring an error the API took the trouble to describe is the swallowed exception pattern in its most expensive form, because here it costs an order.
Reproducing it deliberately
Do not wait for reports. Test the race on purpose.
DevTools throttling. Network tab, Slow 3G. Then click add to cart three times fast. This alone finds most of it.
Add artificial latency during development so the window is always wide:
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function debugFetch(...args) {
await sleep(800 + Math.random() * 700);
return fetch(...args);
}
Swap fetch for debugFetch behind a flag. Variable latency is important, because a fixed delay serialises things by accident and hides the bug.
Test on a real phone on mobile data, not on wifi. Merchant traffic is majority mobile and mobile latency is genuinely different.
The general shape
This bug is not really about Shopify. It is the standard client side race: two async operations, shared state, and a render that assumes ordering.
The three fixes map onto the three general solutions. Discard stale responses, serialise the operations, or eliminate the second round trip so there is nothing to race.
The third is almost always the best available answer wherever the API supports it, because it removes the concurrency rather than managing it. When an API can return the new state along with the mutation, taking it means one less thing that can interleave.
And the diagnostic lesson generalises too: anything that gets worse under network throttling is an ordering problem. That is a fast way to classify a whole category of bug that otherwise looks like random flakiness, in the same way that a test that fails more under CPU load is a timing problem.