Horizon's Add-to-Cart Race Handling: Clicking Add While a Variant Switch Is Still Pending
This article is based on Horizon 4.2.0, with source pinned to 5acd1b6 (verified 2026-10-07). Conclusions are limited to that commit; this is a static source analysis and nothing was tested in a store.
The problem
When a shopper switches variants, the variant picker first requests new section HTML, and only after it returns does it update the hidden input[name="id"], price, inventory and button state. If the shopper picks M and immediately clicks "Add to cart" while the request is still in flight, a plain form submit still carries the previous variant in input[name="id"]: they think they added M, but the cart holds S. If the previous variant is sold out or at its purchase limit, the add fails outright.
assets/product-form.js handles this window explicitly, with the decision logic extracted into a pure function in assets/variant-resolution.js.
Overall flow
User selects a variant
└─ variant-picker dispatches ProductSelectEvent (carrying the section request's promise)
└─ product-form: generation++, variantChangeInProgress = true, remember pendingVariantChange
User clicks add to cart (handleSubmit)
├─ No variant request in flight → submit normally
└─ Variant request in flight → push a "snapshot" onto the queue; the button still plays its add animation
Variant request settles (success or failure)
└─ If still the latest generation → clear the in-progress flag → drain the queue
└─ Resolve each snapshot's variant → normalize quantity → one batched POST
Key design points
1. The event carries a promise
fetchUpdatedSection in variant-picker.js dispatches ProductSelectEvent before starting the request, with the request result's promise attached to the event:
const deferredEventPromise = ProductSelectEvent.createPromise();
this.dispatchEvent(new ProductSelectEvent({ ..., promise: deferredEventPromise.promise }));
fetch(requestUrl, { signal }) /* resolves on success, rejects on failure or abort */
Listeners don't have to wait for the request to know "the variant is changing"; they can enter the in-flight state immediately and await event.promise for the result later. ProductSelectEvent is imported from @shopify/events, which the importmap points at cdn.shopify.com/storefront/standard-events.js: Shopify's standard events module, not a theme-defined event.
2. A generation counter keeps old requests from clearing new state
A user might switch S → M → L in a row, and each switch aborts the previous request. When a cancelled request reaches its finally, it must not clear the in-flight flag, because L's request hasn't returned yet:
const generation = ++this.#variantChangeGeneration;
this.#variantChangeInProgress = true;
this.#pendingVariantChange = event.promise;
try { /* await event.promise, update button, price, etc. */ }
finally {
if (generation === this.#variantChangeGeneration) {
this.#variantChangeInProgress = false;
await this.#drainAddToCartQueue();
}
}
Only the latest generation may end the in-flight state and drain the queue. It's the classic pattern for "cancellable async operation + shared flag."
3. The queue stores snapshots, not variant IDs
At click time the variant isn't settled, so each queue entry records the clues needed to resolve it:
{
quantity, // quantity at click time
generation, // generation at click time
intendedVariantId, // data-variant-id on the selected option (present for single-option products)
pendingVariantChange, // the request promise in flight at click time
variantResolutionUrl, // picker.buildRequestUrl(selected option), a URL that can re-resolve this selection
}
The key is pendingVariantChange: the snapshot is bound to the request in flight when the user clicked, not to the picker's latest state at drain time. If the user clicks add and then switches variants again, that queued add still corresponds to the selection at click time.
4. Variant resolution priority is a pure function
export function resolveVariantId({ resolvedVariantId, intendedVariantId, hiddenInputValue, available }) {
if (available === false) return null;
return normalize(resolvedVariantId) // ① variant resolved by the snapshot's own request
?? normalize(intendedVariantId) // ② data-variant-id on the selected option, never lags
?? normalize(hiddenInputValue) // ③ hidden input, trustworthy only after the request completes
?? null; // none → abandon this add
}
This is illustrative: the source checks each value with its own if, not a ?? chain, and normalizeVariantId also treats whitespace-only strings as missing.
A few things to note:
- Returning
nullmeans abandon. Better to add nothing than to submit a stale, empty or maxed-out variant ID; - The hidden input is only read for the latest generation. For an older snapshot
nullis passed instead, because the input then belongs to a different selection; availability likewise only falls back to "is the button disabled" for the latest generation; - What if the snapshot's request was cancelled? If the user clicked add and then switched variants again, the snapshot's promise rejects because of the abort. It then requests once more using
variantResolutionUrland reads the variant ID and availability from the JSON insidevariant-pickerin the returned HTML; if that request fails, the selection is treated as unavailable.
The source comment calls this the "#2756 fix" (without saying whether that's an issue or a PR number) and explains that extracting a pure function makes it unit-testable without a DOM or component harness.
5. Quantity is renormalized for the new variant
Different variants can have different minimums, increments and maximums (B2B quantity rules). The snapshot's quantity was entered against the old variant, so at drain time it finds the quantity-selector-component quantity input in the HTML that belongs to the snapshot (the original request or the re-resolution), reads min, max, step and data-cart-quantity (quantity already in the cart), then normalizes:
// effective max = max(max - quantity already in cart, min)
// round down to the step, then clamp to [min, effective max]
if ((quantity - min) % step !== 0) normalized = min + Math.floor((quantity - min) / step) * step;
return Math.max(min, Math.min(effectiveMax ?? Infinity, normalized));
6. Merged into one request
All resolved queue entries are submitted in one POST to Theme.routes.cart_add_url (an items array in the JSON body), with the sections parameter bringing back the HTML of the sections that own each cart-items-component on the page. Even if the user clicked add three times while the request was in flight, there's one add request and one cart refresh; if every entry resolves to null, no request is sent.
7. The user doesn't notice the queue
On enqueue, the button still calls animateAddToCart(). From the user's point of view the click gets immediate feedback, and the wait behind it is hidden.
Things worth questioning
product-form.jsis 1122 lines. Add to cart, the queue, post-switch UI updates, B2B quantities and error messages all live in one class, which makes it expensive to read. Extracting variant resolution into a pure function is a good start; queue management deserves its own module too.- Animating on enqueue can mislead when the add is finally abandoned. If resolution ends in
null(the selected variant is unavailable), the animation has already played but nothing was added. The comment on#drainAddToCartQueueexplains that an unavailable selection disables the button in#onProductSelect, so no further UI change is needed. But in the window where the button was enabled at click time and unavailability only shows up at drain time, the user sees an add-to-cart success animation. - Re-resolution costs another request. When the snapshot's request was cancelled by a later switch, the section HTML has to be fetched again. It's a correctness-first trade-off, but on a poor network it stretches the add-to-cart time further.
- Queue entries are resolved serially.
#drainAddToCartQueueawaits each entry in a loop, so when several snapshots need re-resolution, the requests go out one after another.
Summary
The hard part of this problem isn't technical; it's realizing it exists. It's nearly impossible to reproduce on a fast network, only hits users on slow networks who act quickly, and shows up as "the wrong item was added," which is hard to attribute to theme code.
Horizon's solution boils down to four rules that apply to any "asynchronously updated form + submit" scenario:
- Broadcast as soon as the state change begins, with a promise for the result;
- Guard shared flags with a generation counter;
- On submit, save an "intent snapshot," not the current values;
- Extract the decision logic into a pure function and test it on its own.
Horizon's LICENSE.md forbids distributing themes derived from its code; this article only analyzes the source, so implement any borrowed ideas yourself.