Published 30 Dec 2020 · Updated 21 Oct 2026

A cart badge should reflect the server’s cart after an add request completes. Sending the write and read in parallel can render stale state; adding one to a returned count also breaks when a shopper selects a quantity greater than one.

Make the request order explicit

This example is for a Shopify theme with a standard product form. Add data-ajax-cart to that form, keep its existing variant and quantity fields, and include this live region inside it:

<p data-cart-status role="status" aria-live="polite"></p>

Mark each cart count with data-item-count. Load the following JavaScript once, after Shopify’s theme globals are available. Use it only on forms that do not already have a theme cart handler, to avoid duplicate add requests.

const root = window.Shopify.routes.root;
let cartBusy = false;

document.addEventListener('submit', async (event) => {
    const form = event.target;
    if (!form.matches('form[data-ajax-cart]')) return;
    event.preventDefault();
    if (cartBusy) return;

    const status = form.querySelector('[data-cart-status]');
    if (!status) return; // Include the live region shown above.
    cartBusy = true;
    const buttons = Array.from(form.querySelectorAll('[type="submit"]'));
    const disabledBefore = buttons.map(button => button.disabled);
    const data = new FormData(form);
    buttons.forEach(button => { button.disabled = true; });
    let added = false;
    status.textContent = 'Adding item…';
    try {
        const response = await fetch(root + 'cart/add.js', {
            method: 'POST',
            headers: { Accept: 'application/json' },
            body: data
        });
        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.description || 'Unable to add this item.');
        }
        added = true;
        const cartResponse = await fetch(root + 'cart.js', {
            headers: { Accept: 'application/json' },
            cache: 'no-store'
        });
        if (!cartResponse.ok) throw new Error('Unable to refresh the cart.');
        const cart = await cartResponse.json();
        document.querySelectorAll('[data-item-count]').forEach(element => {
            element.textContent = cart.item_count;
        });
        status.textContent = 'Item added to cart.';
    } catch (error) {
        status.textContent = added
            ? 'Item added, but the count could not refresh. Open your cart before trying again.'
            : 'The request could not be completed. Check your cart before retrying. ' + error.message;
    } finally {
        cartBusy = false;
        buttons.forEach((button, index) => { button.disabled = disabledBefore[index]; });
    }
});

Why this sequence matters

Await the add request before reading the cart. Render the returned item_count directly. A shared in-flight guard prevents overlapping submissions through this handler, and submit buttons return to their original disabled state when the sequence ends.

A failed read after a successful write needs a different message: the item is already in the cart, so automatically repeating the write could add it twice. A network failure during the write also leaves the outcome uncertain; ask the shopper to check the cart before retrying.

Verification checklist

  • Add quantities of one and several; compare the badge with the cart page.
  • Submit twice quickly; verify this handler sends one add request.
  • Simulate an unavailable variant and confirm the error is announced.
  • Fail the cart read after a successful add; confirm no automatic second write occurs.
  • Test a localized storefront and keyboard submission.

This example updates the count and status only. A theme cart drawer needs its own section refresh or documented integration. Coordinate other cart writers in the theme separately.

References: Shopify Ajax API and Cart API reference.