Shopify Cart Race Conditions — Fix Double-Click Add to Cart With a Promise Queue
A customer clicks Add to Cart, the network request takes 300ms, and they click again before it completes. You now have two concurrent POST requests to /cart/add.js that can result in duplicate line items, incorrect quantities, or a cart state that is inconsistent with what the customer sees. This is a race condition, and it affects every Shopify cart implementation that does not serialize mutations. A Promise queue pattern solves this completely with a small amount of code.
Why concurrent cart requests cause problems
Shopify's cart API is not atomic across concurrent requests. If two cart/add.js requests arrive simultaneously or overlapping, both are processed against the cart state at the time each request is received — but neither waits for the other to complete before processing. The result can be: duplicate line items added, the second request overwriting the first's quantity, the cart drawer updating with stale data from the first response while the second modifies the cart further. All of these produce a cart that does not reflect what the customer intended.
The Promise queue pattern
A Promise queue serializes all cart operations by chaining each new operation onto the tail of the previous one. If no operation is in progress, the operation starts immediately. If one is in progress, the new operation waits for it to complete before starting. Implementation: class CartQueue { constructor() { this.queue = Promise.resolve(); } add(operation) { this.queue = this.queue.then(() => operation()); return this.queue; } }. All cart mutations go through cartQueue.add(() => fetch('/cart/add.js', {...})) — they execute one at a time, in order.
Disabling the button during the operation
The Promise queue prevents data corruption, but visual feedback prevents confusion. Disable the add-to-cart button for the duration of the entire queue: async function addToCart(variantId, button) { button.disabled = true; button.setAttribute('aria-disabled', 'true'); try { await cartQueue.add(() => cartRequest(variantId)); await updateCartSections(); } finally { button.disabled = false; button.removeAttribute('aria-disabled'); } }. Using finally ensures the button is re-enabled even if the request fails.
Applying the queue to all cart operations
The queue must cover all cart mutations — not just add, but also change (quantity update) and remove. Using separate queues for add and change creates a new race condition between them. Use a single queue instance shared across all cart operations: const cartQueue = new CartQueue(); All cart mutations call cartQueue.add(). This guarantees that no matter what sequence of add/change/remove operations a user triggers, they always execute in order.
The fix: before and after
// CODE_COMPARISON
Frequently asked questions
- Is the Promise queue pattern overkill for most Shopify stores?
No. Any store with an add-to-cart button that is accessible while a cart request is pending can experience race conditions. This includes stores with fast customers, slow network connections (mobile users), and stores that test with network throttling. The Promise queue adds negligible complexity (10 lines of code) and eliminates an entire class of hard-to-debug cart bugs.
- Does disabling the button prevent all race conditions on its own?
Disabling the button prevents rapid re-clicks on the same button but does not prevent race conditions between different cart operations — for example, a user changing quantity in the cart drawer while an add-to-cart request is in progress from another section. The Promise queue is necessary to handle all scenarios.
- What happens to queued operations if one fails?
In the implementation above, the catch block logs the error but continues the queue — subsequent operations proceed regardless of whether a previous one failed. This is usually the correct behavior for cart operations: if adding one item fails, the queue continues and later operations are not blocked. Optionally, you can propagate errors and empty the queue on failure, which is appropriate if the operations are dependent on each other.
// SCAN_YOUR_CODE
Does your theme have this bug?
Paste your code. Syphio automatically detects and fixes this error and hundreds of others — in seconds.
Check my JavaScript →