Commit f24f37438a for woocommerce

commit f24f37438a67c17704ca786f5de85d40011a6b74
Author: Sam Seay <samueljseay@gmail.com>
Date:   Tue Feb 17 02:53:17 2026 +1300

    Implement request batching/queueing for cart operations, utilize the queue within addCartItem, removeCartItem (#62766)

diff --git a/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching b/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching
new file mode 100644
index 0000000000..9a6a1555fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/62766-wooplug-6115-iapi-store-implement-request-batching
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Implement request batching/queueing for cart operations to fix multiple issues with state resolution in the cart UI.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
index 2032911768..3fe96b1eae 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/frontend.ts
@@ -172,10 +172,12 @@ const productButtonStore = {
 				{ lock: universalLock }
 			);

+			// Pass quantityToAdd as a delta. The cart store will add this
+			// to the current quantity, ensuring rapid clicks compound correctly.
 			yield actions.addCartItem(
 				{
 					id: state.productId,
-					quantity: state.quantity + context.quantityToAdd,
+					quantityToAdd: context.quantityToAdd,
 					type: context.productType,
 				},
 				{
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
index e9cd40c251..390eb8f496 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
@@ -7,7 +7,6 @@ import type {
 	CartItem,
 	CartVariationItem,
 	ApiErrorResponse,
-	ApiResponse,
 	CartResponseTotals,
 	Currency,
 } from '@woocommerce/types';
@@ -20,6 +19,12 @@ import type {
  * Internal dependencies
  */
 import { triggerAddedToCartEvent } from './legacy-events';
+import {
+	createMutationQueue,
+	MutationRequest,
+	type MutationQueue,
+	type MutationResult,
+} from './mutation-batcher';

 export type WooCommerceConfig = {
 	products?: {
@@ -42,8 +47,15 @@ export type OptimisticCartItem = {
 	type: string;
 };

-export type ClientCartItem = Omit< OptimisticCartItem, 'variation' > & {
+export type ClientCartItem = Omit<
+	OptimisticCartItem,
+	'variation' | 'quantity'
+> & {
 	variation?: SelectedAttributes[];
+	/** The target quantity (absolute). Either this or quantityToAdd must be provided. */
+	quantity?: number;
+	/** Optional: add this delta to current quantity instead of setting absolute quantity */
+	quantityToAdd?: number;
 };

 export type VariationData = {
@@ -104,6 +116,7 @@ export type Store = {
 		) => void;
 		// Todo: Check why if I switch to an async function here the types of the store stop working.
 		refreshCartItems: () => void;
+		waitForIdle: () => void;
 		showNoticeError: ( error: Error | ApiErrorResponse ) => void;
 		updateNotices: ( notices: Notice[], removeOthers?: boolean ) => void;
 	};
@@ -115,10 +128,6 @@ type QuantityChanges = {
 	productsPendingAdd?: number[];
 };

-type BatchResponse = {
-	responses: ApiResponse< Cart >[];
-};
-
 // Guard to distinguish between optimistic and cart items.
 function isCartItem( item: OptimisticCartItem | CartItem ): item is CartItem {
 	return 'name' in item;
@@ -252,70 +261,116 @@ function emitSyncEvent( {
 	);
 }

+/**
+ * Cart request queue singleton
+ *
+ * Lazily initialized on first use since state isn't available at module load.
+ * Queues cart requests and handles optimistic updates and reconciliation.
+ */
+let cartQueue: MutationQueue< Cart > | null = null;
+
+/**
+ * Send a cart request through the queue.
+ *
+ * Handles optimistic updates, request queuing, and state reconciliation.
+ */
+function sendCartRequest(
+	stateRef: Store[ 'state' ],
+	options: MutationRequest< Cart >
+): Promise< MutationResult< Cart > > {
+	// Lazily initialize queue on first use.
+	if ( ! cartQueue ) {
+		cartQueue = createMutationQueue< Cart >( {
+			endpoint: `${ stateRef.restUrl }wc/store/v1/batch`,
+			getHeaders: () => ( {
+				Nonce: stateRef.nonce,
+			} ),
+			takeSnapshot: () => JSON.parse( JSON.stringify( stateRef.cart ) ),
+			rollback: ( snapshot ) => {
+				stateRef.cart = snapshot;
+			},
+			commit: ( serverState ) => {
+				stateRef.cart = serverState;
+			},
+		} );
+	}
+
+	return cartQueue.submit( options );
+}
+
 // Todo: export this store once the store is public.
 const { state, actions } = store< Store >(
 	'woocommerce',
 	{
 		actions: {
 			*removeCartItem( key: string ) {
-				const previousCart = JSON.stringify( state.cart );
+				// Track what changes we're making for notice comparison.
+				const quantityChanges: QuantityChanges = {
+					cartItemsPendingDelete: [ key ],
+				};

-				// optimistically update the cart
-				state.cart.items = state.cart.items.filter(
-					( item ) => item.key !== key
-				);
+				// Capture cart state after optimistic updates for notice comparison.
+				let cartAfterOptimistic: typeof state.cart | null = null;

 				try {
-					const res: Response = yield fetch(
-						`${ state.restUrl }wc/store/v1/cart/remove-item`,
-						{
-							method: 'POST',
-							headers: {
-								Nonce: state.nonce,
-								'Content-Type': 'application/json',
-							},
-							body: JSON.stringify( { key } ),
-						}
-					);
-
-					const json: Cart | ApiErrorResponse = yield res.json();
+					const result = yield sendCartRequest( state, {
+						path: '/wc/store/v1/cart/remove-item',
+						method: 'POST',
+						body: { key },
+						applyOptimistic: () => {
+							state.cart.items = state.cart.items.filter(
+								( item ) => item.key !== key
+							);
+							// Capture state after optimistic update.
+							cartAfterOptimistic = JSON.parse(
+								JSON.stringify( state.cart )
+							);
+						},
+						// Side effects run synchronously during reconciliation,
+						// before isProcessing clears. This prevents
+						// refreshCartItems from running during these events.
+						onSettled: ( { success } ) => {
+							if ( success ) {
+								emitSyncEvent( { quantityChanges } );
+							}
+						},
+					} );

-					if ( isApiErrorResponse( res, json ) ) {
-						throw generateError( json );
+					// Show notices from server response.
+					const cart = result.data as Cart;
+					if ( cart && cartAfterOptimistic ) {
+						const infoNotices = getInfoNoticesFromCartUpdates(
+							cartAfterOptimistic,
+							cart,
+							quantityChanges
+						);
+						const errorNotices =
+							cart.errors.map( generateErrorNotice );
+						yield actions.updateNotices(
+							[ ...infoNotices, ...errorNotices ],
+							true
+						);
 					}
-					const quantityChanges = { cartItemsPendingDelete: [ key ] };
-					const infoNotices = getInfoNoticesFromCartUpdates(
-						state.cart,
-						json,
-						quantityChanges
-					);
-					const errorNotices = json.errors.map( generateErrorNotice );
-					yield actions.updateNotices(
-						[ ...infoNotices, ...errorNotices ],
-						true
-					);
-
-					state.cart = json;
-					emitSyncEvent( { quantityChanges } );
 				} catch ( error ) {
-					state.cart = JSON.parse( previousCart );
-
-					// Shows the error notice.
 					actions.showNoticeError( error as Error );
 				}
 			},

 			*addCartItem(
-				{ id, key, quantity, variation }: ClientCartItem,
+				{ id, key, quantity, quantityToAdd, variation }: ClientCartItem,
 				{ showCartUpdatesNotices = true }: CartUpdateOptions = {}
 			) {
+				if ( quantity !== undefined && quantityToAdd !== undefined ) {
+					throw new Error(
+						'addCartItem: pass either quantity or quantityToAdd, not both.'
+					);
+				}
+
 				const a11yModulePromise = import( '@wordpress/a11y' );
-				let item = state.cart.items.find( ( cartItem ) => {
+
+				// Find existing item
+				const existingItem = state.cart.items.find( ( cartItem ) => {
 					if ( cartItem.type === 'variation' ) {
-						// If it's a variation, check that attributes match.
-						// While different variations have different attributes,
-						// some variations might accept 'Any' value for an attribute,
-						// in which case, we need to check that the attributes match.
 						if (
 							id !== cartItem.id ||
 							! cartItem.variation ||
@@ -329,76 +384,123 @@ const { state, actions } = store< Store >(
 							variation
 						);
 					}
-					// If no key is provided, rely on the id.
 					return key ? key === cartItem.key : id === cartItem.id;
 				} );
-				const endpoint = item ? 'update-item' : 'add-item';
-				const previousCart = JSON.stringify( state.cart );
-				const quantityChanges: QuantityChanges = {};

-				// Optimistically update the number of items in the cart except
-				// if the product is sold individually and is already in the
-				// cart.
-				let updatedItem = null;
-				if ( item ) {
-					const isSoldIndividually =
-						isCartItem( item ) && item.sold_individually;
-					updatedItem = { ...item, quantity };
-					if ( item.key && ! isSoldIndividually ) {
-						quantityChanges.cartItemsPendingQuantity = [ item.key ];
-						item.quantity = quantity;
-					}
+				// Determine the target quantity.
+				// If quantityToAdd is provided, calculate target based on current
+				// cart state (which includes optimistic updates from previous clicks).
+				// This ensures rapid clicks compound correctly.
+				let targetQuantity: number;
+				if ( typeof quantityToAdd === 'number' ) {
+					const currentQuantity = existingItem?.quantity ?? 0;
+					targetQuantity = currentQuantity + quantityToAdd;
+				} else if ( typeof quantity === 'number' ) {
+					targetQuantity = quantity;
 				} else {
-					item = {
+					// Neither provided - default to 1
+					targetQuantity = 1;
+				}
+
+				// Only treat as update if the item has a key (server-confirmed item).
+				// Optimistic items don't have keys, so we should add them instead.
+				const isUpdate = !! existingItem?.key;
+				const endpoint = isUpdate ? 'update-item' : 'add-item';
+
+				// Track what changes we're making for notice comparison.
+				const quantityChanges: QuantityChanges = isUpdate
+					? {
+							cartItemsPendingQuantity: existingItem?.key
+								? [ existingItem.key ]
+								: [],
+					  }
+					: { productsPendingAdd: [ id ] };
+
+				// Prepare the item to send.
+				let itemToSend: OptimisticCartItem;
+				if ( isUpdate && existingItem ) {
+					// Server-confirmed item: include the key for update-item endpoint.
+					itemToSend = { ...existingItem, quantity: targetQuantity };
+				} else {
+					// New item or optimistic item: build fresh for add-item endpoint.
+					// For optimistic items (existingItem without key), calculate delta
+					// since add-item adds to existing quantity, not sets it.
+					const quantityToSend = existingItem
+						? targetQuantity - existingItem.quantity
+						: targetQuantity;
+
+					itemToSend = {
 						id,
-						quantity,
+						quantity: quantityToSend,
 						...( variation && { variation } ),
 					} as OptimisticCartItem;
-					quantityChanges.productsPendingAdd = [ id ];
-					state.cart.items.push( item );
-					updatedItem = item;
 				}

-				// Updates the database.
-				try {
-					const res: Response = yield fetch(
-						`${ state.restUrl }wc/store/v1/cart/${ endpoint }`,
-						{
-							method: 'POST',
-							headers: {
-								Nonce: state.nonce,
-								'Content-Type': 'application/json',
-							},
-							body: JSON.stringify( updatedItem ),
-						}
-					);
-					const json: Cart = yield res.json();
-
-					// Checks if the response contains an error.
-					if ( isApiErrorResponse( res, json ) )
-						throw generateError( json );
+				// Capture cart state after optimistic updates for notice comparison.
+				let cartAfterOptimistic: typeof state.cart | null = null;

-					const infoNotices = showCartUpdatesNotices
-						? getInfoNoticesFromCartUpdates(
-								state.cart,
-								json,
-								quantityChanges
-						  )
-						: [];
-					const errorNotices = json.errors.map( generateErrorNotice );
-					yield actions.updateNotices(
-						[ ...infoNotices, ...errorNotices ],
-						true
-					);
-
-					// Updates the local cart.
-					state.cart = json;
-
-					// Dispatches a legacy event.
-					triggerAddedToCartEvent( {
-						preserveCartData: true,
+				try {
+					const result = yield sendCartRequest( state, {
+						path: `/wc/store/v1/cart/${ endpoint }`,
+						method: 'POST',
+						body: itemToSend,
+						applyOptimistic: () => {
+							if ( existingItem ) {
+								// Update existing item's quantity (whether server-confirmed or optimistic).
+								const isSoldIndividually =
+									isCartItem( existingItem ) &&
+									existingItem.sold_individually;
+								if ( ! isSoldIndividually ) {
+									existingItem.quantity = targetQuantity;
+								}
+							} else {
+								// No existing item: push new optimistic item.
+								state.cart.items.push( itemToSend );
+							}
+							// Capture state after optimistic update.
+							cartAfterOptimistic = JSON.parse(
+								JSON.stringify( state.cart )
+							);
+						},
+						// Side effects run synchronously during reconciliation,
+						// before isProcessing clears. This prevents
+						// refreshCartItems from running during these events.
+						onSettled: ( { success } ) => {
+							if ( success ) {
+								// Dispatch legacy event
+								triggerAddedToCartEvent( {
+									preserveCartData: true,
+								} );
+
+								// Dispatch sync event
+								emitSyncEvent( { quantityChanges } );
+							}
+						},
 					} );

+					// Success - handle side effects that don't trigger refreshCartItems
+					const cart = result.data as Cart;
+
+					// Show notices if enabled
+					if (
+						showCartUpdatesNotices &&
+						cart &&
+						cartAfterOptimistic
+					) {
+						const infoNotices = getInfoNoticesFromCartUpdates(
+							cartAfterOptimistic,
+							cart,
+							quantityChanges
+						);
+						const errorNotices =
+							cart.errors.map( generateErrorNotice );
+						yield actions.updateNotices(
+							[ ...infoNotices, ...errorNotices ],
+							true
+						);
+					}
+
+					// Announce to screen readers
 					const { messages } = getConfig(
 						'woocommerce'
 					) as WooCommerceConfig;
@@ -406,15 +508,8 @@ const { state, actions } = store< Store >(
 						const { speak } = yield a11yModulePromise;
 						speak( messages.addedToCartText, 'polite' );
 					}
-
-					// Dispatches the event to sync the @wordpress/data store.
-					emitSyncEvent( { quantityChanges } );
 				} catch ( error ) {
-					// Reverts the optimistic update.
-					// Todo: Prevent racing conditions with multiple addToCart calls for the same item.
-					state.cart = JSON.parse( previousCart );
-
-					// Shows the error notice.
+					// Show error notice
 					actions.showNoticeError( error as Error );
 				}
 			},
@@ -424,137 +519,125 @@ const { state, actions } = store< Store >(
 				{ showCartUpdatesNotices = true }: CartUpdateOptions = {}
 			) {
 				const a11yModulePromise = import( '@wordpress/a11y' );
-				const previousCart = JSON.stringify( state.cart );
 				const quantityChanges: QuantityChanges = {};

-				// Updates the database.
 				try {
-					const requests = items.map( ( item ) => {
+					// Submit each item through the batcher. They'll be
+					// collected into a single batch request automatically.
+					const promises = items.map( ( item, index ) => {
 						const existingItem = state.cart.items.find(
 							( { id: productId } ) => item.id === productId
 						);

-						// Updates existing cart item.
-						if ( existingItem ) {
-							// Optimistically updates the number of items in the cart.
-							existingItem.quantity = item.quantity;
-							if ( existingItem.key ) {
-								quantityChanges.cartItemsPendingQuantity = [
-									...( quantityChanges.cartItemsPendingQuantity ??
-										[] ),
-									existingItem.key,
-								];
-							}
-
-							return {
-								method: 'POST',
-								path: `/wc/store/v1/cart/update-item`,
-								headers: {
-									Nonce: state.nonce,
-									'Content-Type': 'application/json',
-								},
-								body: existingItem,
-							};
+						let quantity: number;
+						if ( typeof item.quantityToAdd === 'number' ) {
+							const currentQuantity = existingItem?.quantity ?? 0;
+							quantity = currentQuantity + item.quantityToAdd;
+						} else {
+							quantity = item.quantity ?? 1;
+						}
+						const isUpdate = !! existingItem?.key;
+						const endpoint = isUpdate ? 'update-item' : 'add-item';
+
+						let itemToSend: OptimisticCartItem;
+						if ( isUpdate && existingItem ) {
+							itemToSend = {
+								key: existingItem.key,
+								id: existingItem.id,
+								quantity,
+							} as OptimisticCartItem;
+							quantityChanges.cartItemsPendingQuantity = [
+								...( quantityChanges.cartItemsPendingQuantity ??
+									[] ),
+								existingItem.key as string,
+							];
+						} else {
+							const quantityToSend = existingItem
+								? quantity - existingItem.quantity
+								: quantity;
+							itemToSend = {
+								id: item.id,
+								quantity: quantityToSend,
+								...( item.variation && {
+									variation: item.variation,
+								} ),
+							} as OptimisticCartItem;
+							quantityChanges.productsPendingAdd = [
+								...( quantityChanges.productsPendingAdd ?? [] ),
+								item.id,
+							];
 						}

-						// Adds new cart item.
-						item = {
-							id: item.id,
-							quantity: item.quantity,
-							...( item.variation && {
-								variation: item.variation,
-							} ),
-						} as OptimisticCartItem;
-						state.cart.items.push( item );
-						quantityChanges.productsPendingAdd =
-							quantityChanges.productsPendingAdd
-								? [
-										...quantityChanges.productsPendingAdd,
-										item.id,
-								  ]
-								: [ item.id ];
-
-						return {
+						const isLastItem = index === items.length - 1;
+
+						return sendCartRequest( state, {
+							path: `/wc/store/v1/cart/${ endpoint }`,
 							method: 'POST',
-							path: `/wc/store/v1/cart/add-item`,
-							headers: {
-								Nonce: state.nonce,
-								'Content-Type': 'application/json',
+							body: itemToSend,
+							applyOptimistic: () => {
+								if ( existingItem ) {
+									existingItem.quantity = quantity;
+								} else {
+									state.cart.items.push( itemToSend );
+								}
 							},
-							body: item,
-						};
+							// Only fire events on the last item to avoid
+							// duplicate notifications mid-batch.
+							// Fire events when ANY item in the batch
+							// succeeded (data is set from the last
+							// successful server state). Only the last
+							// item's callback fires to avoid duplicates.
+							onSettled: isLastItem
+								? ( { data } ) => {
+										if ( data ) {
+											triggerAddedToCartEvent( {
+												preserveCartData: true,
+											} );
+											emitSyncEvent( {
+												quantityChanges,
+											} );
+										}
+								  }
+								: undefined,
+						} );
 					} );

-					const res: Response = yield fetch(
-						`${ state.restUrl }wc/store/v1/batch`,
-						{
-							method: 'POST',
-							headers: {
-								Nonce: state.nonce,
-								'Content-Type': 'application/json',
-							},
-							body: JSON.stringify( { requests } ),
-						}
+					// Capture cart state after optimistic updates for notices.
+					const cartAfterOptimistic = JSON.parse(
+						JSON.stringify( state.cart )
 					);

-					const json: BatchResponse = yield res.json();
-
-					// Checks if the response contains an error.
-					if ( isApiErrorResponse( res, json ) )
-						throw generateError( json );
-
-					const errorResponses = Array.isArray( json.responses )
-						? json.responses.filter(
-								( response ) =>
-									response.status < 200 ||
-									response.status >= 300
-						  )
-						: [];
-
-					const successfulResponses = Array.isArray( json.responses )
-						? json.responses.filter(
-								( response ) =>
-									response.status >= 200 &&
-									response.status < 300
-						  )
-						: [];
-
-					// Only update the cart and trigger events if there is at least one successful response.
-					if ( successfulResponses.length > 0 ) {
-						const lastSuccessfulCartResponse = successfulResponses[
-							successfulResponses.length - 1
-						]?.body as Cart;
-
-						const infoNotices = showCartUpdatesNotices
-							? getInfoNoticesFromCartUpdates(
-									state.cart,
-									lastSuccessfulCartResponse,
-									quantityChanges
-							  )
-							: [];
-
-						// Generate notices for any error that successful
-						// responses may contain.
-						const errorNotices = successfulResponses.flatMap(
-							( response ) => {
-								const errors = ( response.body.errors ??
-									[] ) as ApiErrorResponse[];
-								return errors.map( generateErrorNotice );
-							}
-						);
-
-						yield actions.updateNotices(
-							[ ...infoNotices, ...errorNotices ],
-							true
+					const results: PromiseSettledResult<
+						MutationResult< Cart >
+					>[] = yield Promise.allSettled( promises );
+
+					// Find the last successful result for notices/a11y.
+					const lastSuccess = [ ...results ]
+						.reverse()
+						.find(
+							(
+								r
+							): r is PromiseFulfilledResult<
+								MutationResult< Cart >
+							> => r.status === 'fulfilled' && r.value.success
 						);

-						// Use the last successful response to update the local cart.
-						state.cart = lastSuccessfulCartResponse;
+					if ( lastSuccess ) {
+						const cart = lastSuccess.value.data as Cart;

-						// Dispatches a legacy event.
-						triggerAddedToCartEvent( {
-							preserveCartData: true,
-						} );
+						if ( showCartUpdatesNotices ) {
+							const infoNotices = getInfoNoticesFromCartUpdates(
+								cartAfterOptimistic,
+								cart,
+								quantityChanges
+							);
+							const errorNotices =
+								cart.errors.map( generateErrorNotice );
+							yield actions.updateNotices(
+								[ ...infoNotices, ...errorNotices ],
+								true
+							);
+						}

 						const { messages } = getConfig(
 							'woocommerce'
@@ -563,34 +646,31 @@ const { state, actions } = store< Store >(
 							const { speak } = yield a11yModulePromise;
 							speak( messages.addedToCartText, 'polite' );
 						}
-
-						// Dispatches the event to sync the @wordpress/data store.
-						emitSyncEvent( { quantityChanges } );
 					}

-					// Show error notices for all failed responses.
-					yield actions.updateNotices(
-						errorResponses
-							.filter(
-								( response ) =>
-									response.body &&
-									typeof response.body === 'object'
-							)
-							.map( ( { body } ) =>
-								generateErrorNotice( body as ApiErrorResponse )
-							)
-					);
+					// Show error notices for failed items.
+					const errorNotices = results
+						.filter(
+							( r ): r is PromiseRejectedResult =>
+								r.status === 'rejected'
+						)
+						.map( ( r ) =>
+							generateErrorNotice( r.reason as ApiErrorResponse )
+						);
+					if ( errorNotices.length > 0 ) {
+						yield actions.updateNotices( errorNotices );
+					}
 				} catch ( error ) {
-					// Reverts the optimistic update.
-					// Todo: Prevent racing conditions with multiple addToCart calls for the same item.
-					state.cart = JSON.parse( previousCart );
-
-					// Shows the error notice.
 					actions.showNoticeError( error as Error );
 				}
 			},

 			*refreshCartItems() {
+				// Skip if queue is processing - it will apply server state when done
+				if ( cartQueue?.getStatus().isProcessing ) {
+					return;
+				}
+
 				// Skips if there's a pending request.
 				if ( pendingRefresh ) return;

@@ -611,6 +691,12 @@ const { state, actions } = store< Store >(
 					if ( isApiErrorResponse( res, json ) )
 						throw generateError( json );

+					// If the batcher started a cycle while we were fetching,
+					// discard this response — the batcher will reconcile.
+					if ( cartQueue?.getStatus().isProcessing ) {
+						return;
+					}
+
 					// Updates the local cart.
 					state.cart = json;

@@ -627,6 +713,12 @@ const { state, actions } = store< Store >(
 				}
 			},

+			*waitForIdle() {
+				if ( cartQueue ) {
+					yield cartQueue.waitForIdle();
+				}
+			},
+
 			*showNoticeError( error: Error | ApiErrorResponse ) {
 				// Todo: Use the module exports instead of `store()` once the store-notices
 				// store is public.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
new file mode 100644
index 0000000000..e4ef2288ed
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/mutation-batcher.ts
@@ -0,0 +1,306 @@
+/**
+ * Mutation Queue - Microtick-based request batcher for WooCommerce Store API
+ *
+ * Collects mutation requests within a single microtask tick and sends them
+ * as one batch request. Only one batch is in-flight at a time; requests that
+ * arrive while a batch is in-flight are queued for the next batch.
+ *
+ * Reconciliation after all batches complete:
+ * - If ANY request succeeded → commit the last successful server state
+ * - If ALL requests failed → rollback to the pre-cycle snapshot
+ *
+ * Each submit() returns a promise that resolves/rejects based on that
+ * individual request's success or failure within the batch.
+ */
+
+export type MutationRequest< TState = unknown > = {
+	path: string;
+	method: 'POST' | 'PUT' | 'DELETE' | 'PATCH';
+	body?: unknown;
+	applyOptimistic?: () => void;
+	/**
+	 * Called synchronously after reconciliation, before isProcessing clears.
+	 * Use for side effects that must complete before external code
+	 * (like refreshCartItems) is allowed to run.
+	 */
+	onSettled?: ( result: MutationResult< TState > ) => void;
+};
+
+export type MutationResult< TState = unknown > = {
+	success: boolean;
+	data?: TState;
+	error?: Error;
+};
+
+type BatchItemResponse = {
+	status: number;
+	body: unknown;
+	headers?: Record< string, string >;
+};
+
+export type MutationQueueConfig< TState = unknown > = {
+	endpoint: string;
+	getHeaders: () => Record< string, string >;
+	takeSnapshot: () => TState;
+	rollback: ( snapshot: TState ) => void;
+	commit: ( serverState: TState ) => void;
+};
+
+type TrackedRequest< TState = unknown > = {
+	id: string;
+	request: MutationRequest< TState >;
+	resolve: ( result: MutationResult ) => void;
+	reject: ( error: Error ) => void;
+};
+
+export function createMutationQueue< TState >(
+	config: MutationQueueConfig< TState >
+) {
+	const { endpoint, getHeaders, takeSnapshot, rollback, commit } = config;
+
+	// Snapshot taken once at the start of each processing cycle.
+	let snapshot: TState | null = null;
+
+	// All tracked requests for the current cycle.
+	const trackedRequests: Map< string, TrackedRequest< TState > > = new Map();
+
+	// Requests collected this tick, waiting to be sent.
+	let pendingIds: string[] = [];
+
+	// The single in-flight batch (null when idle).
+	let inFlightIds: string[] | null = null;
+
+	// The last successful server state seen in this cycle.
+	let lastServerState: TState | null = null;
+
+	// Per-request errors accumulated across all batches in the cycle.
+	const errors: Map< string, Error > = new Map();
+
+	let microtaskScheduled = false;
+	let isProcessing = false;
+	let idleResolvers: Array< () => void > = [];
+	let nextId = 0;
+
+	// reconcile - Commits server state (or rolls back on total failure), notifies callers, resets the cycle
+	function reconcile() {
+		if ( lastServerState !== null ) {
+			commit( lastServerState );
+		} else if ( snapshot !== null ) {
+			rollback( snapshot );
+		}
+
+		// Run onSettled callbacks while isProcessing is still true.
+		// This prevents refreshCartItems from running during these callbacks.
+		trackedRequests.forEach( ( tracked ) => {
+			const error = errors.get( tracked.id );
+			tracked.request.onSettled?.( {
+				success: ! error,
+				...( lastServerState !== null && { data: lastServerState } ),
+				...( error && { error } ),
+			} );
+		} );
+
+		isProcessing = false;
+
+		// Notify idle waiters.
+		const resolvers = idleResolvers;
+		idleResolvers = [];
+		resolvers.forEach( ( r ) => r() );
+
+		// Resolve/reject individual promises.
+		trackedRequests.forEach( ( tracked ) => {
+			const error = errors.get( tracked.id );
+			if ( error ) {
+				tracked.reject( error );
+			} else {
+				tracked.resolve( {
+					success: true,
+					...( lastServerState !== null && {
+						data: lastServerState,
+					} ),
+				} );
+			}
+		} );
+
+		// Reset for next cycle.
+		snapshot = null;
+		lastServerState = null;
+		errors.clear();
+		trackedRequests.clear();
+	}
+
+	// onBatchComplete - If more requests queued during flight, sends them. Otherwise, reconciles.
+	function onBatchComplete() {
+		inFlightIds = null;
+
+		// If new requests arrived while in-flight, send them.
+		if ( pendingIds.length > 0 ) {
+			// eslint-disable-next-line @typescript-eslint/no-use-before-define
+			processRequests();
+			return;
+		}
+
+		reconcile();
+	}
+
+	// handleBatchFailure - Marks all items in the batch as failed (network error or bad status).
+	function handleBatchFailure( requestIds: string[], error: Error ) {
+		for ( const id of requestIds ) {
+			errors.set( id, error );
+		}
+		onBatchComplete();
+	}
+
+	// handleBatchResponse - Records per-item success/failure from the server response.
+	function handleBatchResponse(
+		requestIds: string[],
+		responses: BatchItemResponse[]
+	) {
+		responses.forEach( ( itemResponse, index ) => {
+			const requestId = requestIds[ index ];
+			if ( ! requestId ) return;
+
+			const isSuccess =
+				itemResponse.status >= 200 && itemResponse.status < 300;
+
+			if ( isSuccess ) {
+				lastServerState = itemResponse.body as TState;
+			} else {
+				const errorBody = itemResponse.body as {
+					message?: string;
+					code?: string;
+				};
+				errors.set(
+					requestId,
+					Object.assign(
+						new Error( errorBody?.message || 'Request failed' ),
+						{ code: errorBody?.code || 'unknown_error' }
+					)
+				);
+			}
+		} );
+
+		onBatchComplete();
+	}
+
+	// processRequests - Drains the pending queue into one batch and sends it
+	async function processRequests() {
+		microtaskScheduled = false;
+
+		if ( pendingIds.length === 0 || inFlightIds !== null ) {
+			return;
+		}
+
+		// Move pending requests to in-flight.
+		inFlightIds = [ ...pendingIds ];
+		pendingIds = [];
+
+		const requestIds = inFlightIds;
+		const requestHeaders = getHeaders();
+
+		try {
+			const requests = requestIds
+				.map( ( id ) => {
+					const tracked = trackedRequests.get( id );
+					if ( ! tracked ) return null;
+					return {
+						path: tracked.request.path,
+						method: tracked.request.method,
+						headers: {
+							...requestHeaders,
+							'Content-Type': 'application/json',
+						},
+						body: tracked.request.body,
+					};
+				} )
+				.filter( Boolean );
+
+			const response = await fetch( endpoint, {
+				method: 'POST',
+				headers: {
+					'Content-Type': 'application/json',
+					...requestHeaders,
+				},
+				body: JSON.stringify( { requests } ),
+			} );
+
+			if ( ! response.ok ) {
+				handleBatchFailure(
+					requestIds,
+					new Error( `Request failed: ${ response.status }` )
+				);
+			} else {
+				const json = await response.json();
+				handleBatchResponse( requestIds, json.responses || [] );
+			}
+		} catch ( error ) {
+			handleBatchFailure(
+				requestIds,
+				error instanceof Error ? error : new Error( String( error ) )
+			);
+		}
+	}
+
+	// submit - Queues a request. First call in a cycle takes a snapshot.
+	function submit(
+		request: MutationRequest< TState >
+	): Promise< MutationResult< TState > > {
+		return new Promise( ( resolve, reject ) => {
+			const id = String( nextId++ );
+
+			// First request in a cycle: snapshot and start processing.
+			if ( ! isProcessing ) {
+				snapshot = takeSnapshot();
+				isProcessing = true;
+			}
+
+			// Deep-clone the body at submission time so that later optimistic
+			// updates from subsequent calls cannot alter the payload that
+			// will be sent to the server.
+			const clonedBody = request.body
+				? JSON.parse( JSON.stringify( request.body ) )
+				: undefined;
+
+			if ( request.applyOptimistic ) {
+				request.applyOptimistic();
+			}
+
+			trackedRequests.set( id, {
+				id,
+				request: { ...request, body: clonedBody },
+				resolve: resolve as ( result: MutationResult ) => void,
+				reject,
+			} );
+
+			pendingIds.push( id );
+
+			if ( ! microtaskScheduled && inFlightIds === null ) {
+				microtaskScheduled = true;
+				queueMicrotask( () => processRequests() );
+			}
+		} );
+	}
+
+	function getStatus() {
+		return {
+			isProcessing,
+			pendingCount: pendingIds.length,
+		};
+	}
+
+	// Returns a promise that resolves when the current cycle completes. Resolves immediately if idle.
+	function waitForIdle(): Promise< void > {
+		if ( ! isProcessing ) {
+			return Promise.resolve();
+		}
+		return new Promise( ( resolve ) => {
+			idleResolvers.push( resolve );
+		} );
+	}
+
+	return { submit, getStatus, waitForIdle };
+}
+
+export type MutationQueue< TState = unknown > = ReturnType<
+	typeof createMutationQueue< TState >
+>;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
new file mode 100644
index 0000000000..6a393a2836
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/mutation-batcher.test.ts
@@ -0,0 +1,941 @@
+/**
+ * Internal dependencies
+ */
+import { createMutationQueue } from '../mutation-batcher';
+
+// Test state type
+type TestState = { value: number };
+
+// Helper to create a mock fetch that resolves with batch responses
+function createMockFetch(
+	responses: Array< { status: number; body: unknown } >
+) {
+	return jest.fn().mockResolvedValue( {
+		ok: true,
+		json: () => Promise.resolve( { responses } ),
+	} );
+}
+
+// Helper to create a mock fetch that fails at the network level
+function createFailingFetch( error: Error ) {
+	return jest.fn().mockRejectedValue( error );
+}
+
+// Helper to create a mock fetch that returns non-200
+function createBadResponseFetch( status: number ) {
+	return jest.fn().mockResolvedValue( {
+		ok: false,
+		status,
+		json: () => Promise.resolve( {} ),
+	} );
+}
+
+// Helper to flush microtasks
+function flushMicrotasks() {
+	return new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+}
+
+describe( 'createMutationQueue', () => {
+	let originalFetch: typeof global.fetch;
+	let mockState: TestState;
+	let snapshot: TestState | null;
+	let stateHandler: {
+		takeSnapshot: () => TestState;
+		rollback: ( snap: TestState ) => void;
+		commit: ( serverState: TestState ) => void;
+	};
+
+	beforeEach( () => {
+		originalFetch = global.fetch;
+		mockState = { value: 0 };
+		snapshot = null;
+
+		stateHandler = {
+			takeSnapshot: () => {
+				snapshot = { ...mockState };
+				return snapshot;
+			},
+			rollback: ( snap ) => {
+				mockState = { ...snap };
+			},
+			commit: ( serverState ) => {
+				mockState = { ...serverState };
+			},
+		};
+	} );
+
+	afterEach( () => {
+		global.fetch = originalFetch;
+	} );
+
+	describe( 'batching behavior', () => {
+		it( 'batches multiple requests submitted in the same tick', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: { value: 10 } },
+				{ status: 200, body: { value: 20 } },
+				{ status: 200, body: { value: 30 } },
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Submit 3 requests synchronously
+			const p1 = queue.submit( { path: '/a', method: 'POST' } );
+			const p2 = queue.submit( { path: '/b', method: 'POST' } );
+			const p3 = queue.submit( { path: '/c', method: 'POST' } );
+
+			await Promise.all( [ p1, p2, p3 ] );
+
+			// Should have made exactly ONE fetch call with all 3 requests
+			expect( mockFetch ).toHaveBeenCalledTimes( 1 );
+			const requestBody = JSON.parse(
+				mockFetch.mock.calls[ 0 ][ 1 ].body
+			);
+			expect( requestBody.requests ).toHaveLength( 3 );
+			expect(
+				requestBody.requests.map( ( r: { path: string } ) => r.path )
+			).toEqual( [ '/a', '/b', '/c' ] );
+		} );
+
+		it( 'takes snapshot once at start of cycle, not per request', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: { value: 100 } },
+				{ status: 200, body: { value: 100 } },
+			] );
+			global.fetch = mockFetch;
+
+			const takeSnapshotSpy = jest.spyOn( stateHandler, 'takeSnapshot' );
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// First request with inline optimistic update
+			queue.submit( {
+				path: '/a',
+				method: 'POST',
+			} );
+			mockState.value = 50;
+
+			// Second request in same tick
+			queue.submit( {
+				path: '/b',
+				method: 'POST',
+			} );
+			mockState.value = 75;
+
+			await flushMicrotasks();
+
+			// Snapshot should be taken exactly once, capturing state before optimistic updates
+			expect( takeSnapshotSpy ).toHaveBeenCalledTimes( 1 );
+			expect( snapshot ).toEqual( { value: 0 } );
+		} );
+	} );
+
+	describe( 'response handling and reconciliation', () => {
+		it( 'applies server state from last successful response', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: { value: 10 } },
+				{ status: 200, body: { value: 20 } }, // This should win
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			await Promise.all( [
+				queue.submit( { path: '/a', method: 'POST' } ),
+				queue.submit( { path: '/b', method: 'POST' } ),
+			] );
+
+			// State should be from the last successful response
+			expect( mockState.value ).toBe( 20 );
+		} );
+
+		it( 'accumulates errors from failed items but still applies server state', async () => {
+			const mockFetch = createMockFetch( [
+				{
+					status: 400,
+					body: { message: 'Bad request', code: 'bad_request' },
+				},
+				{ status: 200, body: { value: 42 } },
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			const p1 = queue.submit( { path: '/a', method: 'POST' } );
+			const p2 = queue.submit( { path: '/b', method: 'POST' } );
+
+			// First should reject, second should resolve
+			await expect( p1 ).rejects.toThrow( 'Bad request' );
+			await expect( p2 ).resolves.toMatchObject( { success: true } );
+
+			// Server state should still be applied (from the successful request)
+			expect( mockState.value ).toBe( 42 );
+		} );
+
+		it( 'rolls back to snapshot when ALL requests fail', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 400, body: { message: 'Error 1' } },
+				{ status: 500, body: { message: 'Error 2' } },
+			] );
+			global.fetch = mockFetch;
+
+			mockState.value = 100;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Apply optimistic updates inline
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+			} );
+			mockState.value = 200;
+
+			const p2 = queue.submit( {
+				path: '/b',
+				method: 'POST',
+			} );
+			mockState.value = 300;
+
+			await expect( p1 ).rejects.toThrow();
+			await expect( p2 ).rejects.toThrow();
+
+			// Should rollback to snapshot (value: 100)
+			expect( mockState.value ).toBe( 100 );
+		} );
+
+		it( 'rolls back on total network failure', async () => {
+			global.fetch = createFailingFetch( new Error( 'Network error' ) );
+
+			mockState.value = 50;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+			} );
+			mockState.value = 999;
+
+			await expect( p1 ).rejects.toThrow( 'Network error' );
+			expect( mockState.value ).toBe( 50 );
+		} );
+
+		it( 'rolls back on batch endpoint returning error status', async () => {
+			global.fetch = createBadResponseFetch( 503 );
+
+			mockState.value = 25;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+			} );
+			mockState.value = 888;
+
+			await expect( p1 ).rejects.toThrow( 'Request failed: 503' );
+			expect( mockState.value ).toBe( 25 );
+		} );
+	} );
+
+	describe( 'onSettled callback timing', () => {
+		it( 'runs onSettled before isProcessing clears', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: { value: 1 } },
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			let isProcessingDuringOnSettled: boolean | undefined;
+
+			await queue.submit( {
+				id: '1',
+				path: '/a',
+				method: 'POST',
+				onSettled: () => {
+					isProcessingDuringOnSettled =
+						queue.getStatus().isProcessing;
+				},
+			} );
+
+			// onSettled should run while isProcessing is still true
+			expect( isProcessingDuringOnSettled ).toBe( true );
+
+			// After promise resolves, isProcessing should be false
+			expect( queue.getStatus().isProcessing ).toBe( false );
+		} );
+
+		it( 'provides success status and data to onSettled', async () => {
+			const serverState = { value: 42 };
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: serverState },
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			let settledResult:
+				| { success: boolean; data?: TestState }
+				| undefined;
+
+			await queue.submit( {
+				id: '1',
+				path: '/a',
+				method: 'POST',
+				onSettled: ( result ) => {
+					settledResult = result;
+				},
+			} );
+
+			expect( settledResult ).toEqual( {
+				success: true,
+				data: serverState,
+			} );
+		} );
+
+		it( 'provides error to onSettled on failure', async () => {
+			const mockFetch = createMockFetch( [
+				{
+					status: 400,
+					body: {
+						message: 'Validation failed',
+						code: 'validation_error',
+					},
+				},
+			] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			let settledResult: { success: boolean; error?: Error } | undefined;
+
+			// We need to catch the rejection
+			try {
+				await queue.submit( {
+					id: '1',
+					path: '/a',
+					method: 'POST',
+					onSettled: ( result ) => {
+						settledResult = result;
+					},
+				} );
+			} catch {
+				// Expected
+			}
+
+			expect( settledResult?.success ).toBe( false );
+			expect( settledResult?.error?.message ).toBe( 'Validation failed' );
+		} );
+	} );
+
+	describe( 'single batch in-flight', () => {
+		it( 'only allows one batch in-flight at a time to prevent server race conditions', async () => {
+			// This test verifies that we don't send multiple batches concurrently,
+			// which would cause race conditions on the server (lost cart updates).
+
+			let fetchCallCount = 0;
+			const fetchPromises: Array< {
+				resolve: ( value: Response ) => void;
+			} > = [];
+
+			global.fetch = jest.fn( () => {
+				fetchCallCount++;
+				return new Promise< Response >( ( resolve ) => {
+					fetchPromises.push( { resolve } );
+				} );
+			} ) as jest.Mock;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// First batch - submit and let microtask fire
+			const p1 = queue.submit( { path: '/a', method: 'POST' } );
+			await flushMicrotasks();
+			expect( fetchCallCount ).toBe( 1 );
+
+			// Second request - submit while first is in-flight
+			const p2 = queue.submit( { path: '/b', method: 'POST' } );
+			await flushMicrotasks();
+
+			// Should NOT have sent a second batch yet - only one in-flight allowed
+			expect( fetchCallCount ).toBe( 1 );
+
+			// Resolve first batch
+			fetchPromises[ 0 ].resolve( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 100 } } ],
+					} ),
+			} as Response );
+
+			// Wait for first batch to complete and second to be sent
+			await flushMicrotasks();
+			expect( fetchCallCount ).toBe( 2 );
+
+			// Resolve second batch
+			fetchPromises[ 1 ].resolve( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 200 } } ],
+					} ),
+			} as Response );
+
+			await Promise.all( [ p1, p2 ] );
+
+			// Should use value from the second (and last) batch
+			expect( mockState.value ).toBe( 200 );
+		} );
+	} );
+
+	describe( 'requests during in-flight processing', () => {
+		it( 'collects new requests while batch is in-flight and sends them after', async () => {
+			let fetchCallCount = 0;
+			const fetchPromises: Array< {
+				resolve: ( value: Response ) => void;
+			} > = [];
+
+			global.fetch = jest.fn( () => {
+				fetchCallCount++;
+				return new Promise< Response >( ( resolve ) => {
+					fetchPromises.push( { resolve } );
+				} );
+			} ) as jest.Mock;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// First request - starts the cycle
+			const p1 = queue.submit( {
+				id: '1',
+				path: '/first',
+				method: 'POST',
+			} );
+			await flushMicrotasks();
+			expect( fetchCallCount ).toBe( 1 );
+
+			// New request while first is in-flight
+			const p2 = queue.submit( {
+				id: '2',
+				path: '/second',
+				method: 'POST',
+			} );
+
+			// Resolve first batch
+			fetchPromises[ 0 ].resolve( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 1 } } ],
+					} ),
+			} as Response );
+
+			await flushMicrotasks();
+
+			// Second batch should now be sent
+			expect( fetchCallCount ).toBe( 2 );
+
+			// Resolve second batch
+			fetchPromises[ 1 ].resolve( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 2 } } ],
+					} ),
+			} as Response );
+
+			await Promise.all( [ p1, p2 ] );
+
+			// Both should succeed, final state from second batch
+			expect( mockState.value ).toBe( 2 );
+		} );
+	} );
+
+	describe( 'getStatus', () => {
+		it( 'reports correct processing state and pending count', async () => {
+			const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+			global.fetch = jest.fn(
+				() =>
+					new Promise< Response >( ( resolve ) => {
+						fetchPromise.resolve = resolve;
+					} )
+			);
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Initially idle
+			expect( queue.getStatus() ).toEqual( {
+				isProcessing: false,
+				pendingCount: 0,
+			} );
+
+			// Submit request
+			const p1 = queue.submit( { path: '/a', method: 'POST' } );
+
+			// Now processing with 1 pending
+			expect( queue.getStatus().isProcessing ).toBe( true );
+			expect( queue.getStatus().pendingCount ).toBe( 1 );
+
+			// After microtask, pending should be 0 (sent)
+			await flushMicrotasks();
+			expect( queue.getStatus().pendingCount ).toBe( 0 );
+			expect( queue.getStatus().isProcessing ).toBe( true );
+
+			// Resolve
+			fetchPromise.resolve( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: {} } ],
+					} ),
+			} as Response );
+
+			await p1;
+
+			// Back to idle
+			expect( queue.getStatus() ).toEqual( {
+				isProcessing: false,
+				pendingCount: 0,
+			} );
+		} );
+	} );
+
+	describe( 'body cloning', () => {
+		it( 'clones request body to prevent mutation corruption', async () => {
+			// This test verifies that mutating an object after submission
+			// does not affect the queued request body.
+			let capturedBody: unknown;
+
+			global.fetch = jest.fn( ( _url, options ) => {
+				// Capture what was actually sent
+				const parsed = JSON.parse( options.body as string );
+				capturedBody = parsed.requests[ 0 ].body;
+				return Promise.resolve( {
+					ok: true,
+					json: () =>
+						Promise.resolve( {
+							responses: [ { status: 200, body: { value: 1 } } ],
+						} ),
+				} );
+			} ) as jest.Mock;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Create a mutable body object
+			const body = { id: 1, quantity: 1 };
+
+			// Submit the request
+			const promise = queue.submit( {
+				path: '/a',
+				method: 'POST',
+				body,
+			} );
+
+			// Mutate the body AFTER submission (simulating another click's
+			// optimistic update finding and mutating the same object)
+			body.quantity = 999;
+
+			await promise;
+
+			// The sent body should have the ORIGINAL value, not the mutated one
+			expect( capturedBody ).toEqual( { id: 1, quantity: 1 } );
+		} );
+
+		it( 'isolates bodies when rapid submits share a reference to the same object', async () => {
+			// Simulates rapid add-to-cart clicks on the same product.
+			// Both submits reference the same `item` object from state.
+			// The optimistic update between them mutates item.quantity,
+			// which would corrupt the first request's body without cloning.
+			const capturedBodies: unknown[] = [];
+
+			global.fetch = jest.fn( ( _url, options ) => {
+				const parsed = JSON.parse( options.body as string );
+				for ( const req of parsed.requests ) {
+					capturedBodies.push( req.body );
+				}
+				return Promise.resolve( {
+					ok: true,
+					json: () =>
+						Promise.resolve( {
+							responses: parsed.requests.map( () => ( {
+								status: 200,
+								body: { value: 1 },
+							} ) ),
+						} ),
+				} );
+			} ) as jest.Mock;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Shared mutable object — like an item reference in state.cart.items
+			const item = { id: 1, quantity: 1 };
+
+			// First click: submit with quantity 1, then optimistically set to 2
+			queue.submit( {
+				path: '/update-item',
+				method: 'POST',
+				body: item,
+			} );
+			item.quantity = 2; // optimistic update
+
+			// Second click: submit with quantity 2, then optimistically set to 3
+			queue.submit( {
+				path: '/update-item',
+				method: 'POST',
+				body: item,
+			} );
+			item.quantity = 3; // optimistic update
+
+			await flushMicrotasks();
+
+			// Each request should have the quantity at the time it was submitted
+			expect( capturedBodies ).toEqual( [
+				{ id: 1, quantity: 1 },
+				{ id: 1, quantity: 2 },
+			] );
+		} );
+	} );
+
+	describe( 'applyOptimistic and snapshot ordering', () => {
+		it( 'takes snapshot before running applyOptimistic', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 200, body: { value: 99 } },
+			] );
+			global.fetch = mockFetch;
+
+			const takeSnapshotSpy = jest.spyOn( stateHandler, 'takeSnapshot' );
+
+			mockState.value = 42;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			await queue.submit( {
+				path: '/a',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value = 999;
+				},
+			} );
+
+			// Snapshot should have captured the state BEFORE
+			// applyOptimistic mutated it.
+			expect( takeSnapshotSpy ).toHaveBeenCalledTimes( 1 );
+			expect( snapshot ).toEqual( { value: 42 } );
+		} );
+
+		it( 'rolls back optimistic mutations when all requests fail', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 500, body: { message: 'Server error' } },
+			] );
+			global.fetch = mockFetch;
+
+			mockState.value = 10;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value = 20;
+				},
+			} );
+
+			// Optimistic update should be applied immediately.
+			expect( mockState.value ).toBe( 20 );
+
+			await expect( p1 ).rejects.toThrow();
+
+			// After failure, state should be rolled back to
+			// pre-optimistic value.
+			expect( mockState.value ).toBe( 10 );
+		} );
+
+		it( 'rolls back all optimistic updates from multiple rapid submits', async () => {
+			const mockFetch = createMockFetch( [
+				{ status: 400, body: { message: 'Error 1' } },
+				{ status: 400, body: { message: 'Error 2' } },
+				{ status: 400, body: { message: 'Error 3' } },
+			] );
+			global.fetch = mockFetch;
+
+			mockState.value = 0;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Simulate three rapid clicks — each compounds on previous.
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value += 10;
+				},
+			} );
+			const p2 = queue.submit( {
+				path: '/b',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value += 10;
+				},
+			} );
+			const p3 = queue.submit( {
+				path: '/c',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value += 10;
+				},
+			} );
+
+			// All three optimistic updates should have been applied.
+			expect( mockState.value ).toBe( 30 );
+
+			await expect( p1 ).rejects.toThrow();
+			await expect( p2 ).rejects.toThrow();
+			await expect( p3 ).rejects.toThrow();
+
+			// Snapshot was taken before the first applyOptimistic,
+			// so rollback should undo ALL three updates.
+			expect( mockState.value ).toBe( 0 );
+		} );
+
+		it( 'clones body before applyOptimistic mutates shared references', async () => {
+			let capturedBody: unknown;
+
+			global.fetch = jest.fn( ( _url, options ) => {
+				const parsed = JSON.parse( options.body as string );
+				capturedBody = parsed.requests[ 0 ].body;
+				return Promise.resolve( {
+					ok: true,
+					json: () =>
+						Promise.resolve( {
+							responses: [ { status: 200, body: { value: 1 } } ],
+						} ),
+				} );
+			} ) as jest.Mock;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Shared object — like an item reference in state.cart.items.
+			const item = { id: 1, quantity: 1 };
+
+			await queue.submit( {
+				path: '/update-item',
+				method: 'POST',
+				body: item,
+				applyOptimistic: () => {
+					// This mutates the same object passed as body.
+					// The body must have been cloned before this runs.
+					item.quantity = 5;
+				},
+			} );
+
+			// Server should receive the pre-optimistic quantity.
+			expect( capturedBody ).toEqual( { id: 1, quantity: 1 } );
+		} );
+
+		it( 'rolls back on network failure even with applyOptimistic', async () => {
+			global.fetch = createFailingFetch( new Error( 'Network error' ) );
+
+			mockState.value = 50;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			const p1 = queue.submit( {
+				path: '/a',
+				method: 'POST',
+				applyOptimistic: () => {
+					mockState.value = 777;
+				},
+			} );
+
+			expect( mockState.value ).toBe( 777 );
+
+			await expect( p1 ).rejects.toThrow( 'Network error' );
+			expect( mockState.value ).toBe( 50 );
+		} );
+	} );
+
+	describe( 'waitForIdle', () => {
+		it( 'resolves immediately when not processing', async () => {
+			const mockFetch = createMockFetch( [] );
+			global.fetch = mockFetch;
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			// Should resolve immediately — nothing in progress.
+			await queue.waitForIdle();
+		} );
+
+		it( 'resolves after the processing cycle completes', async () => {
+			const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+			global.fetch = jest.fn(
+				() =>
+					new Promise< Response >( ( resolve ) => {
+						fetchPromise.resolve = resolve;
+					} )
+			);
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			queue.submit( { path: '/a', method: 'POST' } );
+
+			let idleResolved = false;
+			queue.waitForIdle().then( () => {
+				idleResolved = true;
+			} );
+
+			// Let the microtask fire (sends the batch), but fetch is still pending.
+			await flushMicrotasks();
+			expect( idleResolved ).toBe( false );
+
+			// Resolve the fetch — completes the cycle.
+			fetchPromise.resolve!( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 42 } } ],
+					} ),
+			} as Response );
+
+			await flushMicrotasks();
+			await flushMicrotasks();
+
+			expect( idleResolved ).toBe( true );
+			expect( queue.getStatus().isProcessing ).toBe( false );
+		} );
+
+		it( 'resolves multiple waiters when cycle completes', async () => {
+			const fetchPromise: { resolve?: ( value: Response ) => void } = {};
+			global.fetch = jest.fn(
+				() =>
+					new Promise< Response >( ( resolve ) => {
+						fetchPromise.resolve = resolve;
+					} )
+			);
+
+			const queue = createMutationQueue( {
+				endpoint: '/batch',
+				getHeaders: () => ( {} ),
+				...stateHandler,
+			} );
+
+			queue.submit( { path: '/a', method: 'POST' } );
+
+			let waiter1Resolved = false;
+			let waiter2Resolved = false;
+
+			queue.waitForIdle().then( () => {
+				waiter1Resolved = true;
+			} );
+			queue.waitForIdle().then( () => {
+				waiter2Resolved = true;
+			} );
+
+			await flushMicrotasks();
+			expect( waiter1Resolved ).toBe( false );
+			expect( waiter2Resolved ).toBe( false );
+
+			fetchPromise.resolve!( {
+				ok: true,
+				json: () =>
+					Promise.resolve( {
+						responses: [ { status: 200, body: { value: 1 } } ],
+					} ),
+			} as Response );
+
+			await flushMicrotasks();
+			await flushMicrotasks();
+
+			expect( waiter1Resolved ).toBe( true );
+			expect( waiter2Resolved ).toBe( true );
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
index be0567bc77..c5d8a44901 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/frontend.ts
@@ -20,7 +20,6 @@ import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
 /**
  * Internal dependencies
  */
-import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
 import { findMatchingVariation } from '../../base/utils/variations/attribute-matching';
 import type { GroupedProductAddToCartWithOptionsStore } from './grouped-product-selector/frontend';
 import type { Context as QuantitySelectorContext } from './quantity-selector/frontend';
@@ -62,12 +61,6 @@ const dispatchChangeEvent = ( inputElement: HTMLInputElement ) => {
 const universalLock =
 	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';

-const { state: wooState } = store< WooCommerce >(
-	'woocommerce',
-	{},
-	{ lock: universalLock }
-);
-
 const { state: productDataState } = store< ProductDataStore >(
 	'woocommerce/product-data',
 	{},
@@ -129,34 +122,6 @@ export const getProductData = (
 	};
 };

-export const getNewQuantity = (
-	productId: number,
-	quantity: number,
-	variation?: SelectedAttributes[]
-) => {
-	const product = wooState.cart?.items.find( ( item ) => {
-		if ( item.type === 'variation' ) {
-			// If it's a variation, check that attributes match.
-			// While different variations have different attributes,
-			// some variations might accept 'Any' value for an attribute,
-			// in which case, we need to check that the attributes match.
-			if (
-				item.id !== productId ||
-				! item.variation ||
-				! variation ||
-				item.variation.length !== variation.length
-			) {
-				return false;
-			}
-			return doesCartItemMatchAttributes( item, variation );
-		}
-
-		return item.id === productId;
-	} );
-	const currentQuantity = product?.quantity || 0;
-	return currentQuantity + quantity;
-};
-
 export type AddToCartWithOptionsStore = {
 	state: {
 		noticeIds: string[];
@@ -399,12 +364,6 @@ const { actions, state } = store<

 				const { quantity } = getContext< Context >();

-				const newQuantity = getNewQuantity(
-					id,
-					quantity[ id ],
-					selectedAttributes
-				);
-
 				const { actions: wooActions } = store< WooCommerce >(
 					'woocommerce',
 					{},
@@ -413,7 +372,7 @@ const { actions, state } = store<
 				yield wooActions.addCartItem(
 					{
 						id,
-						quantity: newQuantity,
+						quantityToAdd: quantity[ id ],
 						variation: selectedAttributes,
 						type: productType,
 					},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
index 98f145f5b8..725786f428 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/frontend.ts
@@ -14,7 +14,7 @@ import type {
 	AddToCartWithOptionsStore,
 	Context as AddToCartWithOptionsStoreContext,
 } from '../frontend';
-import { getNewQuantity, getProductData } from '../frontend';
+import { getProductData } from '../frontend';

 // Stores are locked to prevent 3PD usage until the API is stable.
 const universalLock =
@@ -99,11 +99,6 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(
 						continue;
 					}

-					const newQuantity = getNewQuantity(
-						childProductId,
-						quantity[ childProductId ]
-					);
-
 					const productObject = getProductData(
 						Number( childProductId ),
 						selectedAttributes
@@ -115,7 +110,7 @@ const { actions } = store< GroupedProductAddToCartWithOptionsStore >(

 					addedItems.push( {
 						id: Number( childProductId ),
-						quantity: newQuantity,
+						quantityToAdd: quantity[ childProductId ],
 						variation: selectedAttributes,
 						type: productObject.type,
 					} );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index d0a1b3b6a9..a9f93a204d 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -86,6 +86,60 @@ test.describe( 'Add to Cart + Options Block', () => {
 		await expect( addToCartButton ).toHaveText( '4 in cart' );
 	} );

+	// This test only applies to the iAPI cart which uses batch requests.
+	// The legacy cart sends individual requests, not batched ones.
+	if ( config.features[ 'experimental-iapi-mini-cart' ] ) {
+		test( 'handles rapid add-to-cart clicks correctly', async ( {
+			page,
+			frontendUtils,
+			miniCartUtils,
+		} ) => {
+			// Go to shop page where iAPI Product Button is used in product listings.
+			await frontendUtils.goToShop();
+
+			// Get the first Add to cart button on the page (Album product).
+			const addToCartButton = page.locator( 'text=Add to cart' ).first();
+			await expect( addToCartButton ).toBeVisible();
+
+			// Click the button 3 times rapidly without waiting between clicks.
+			// This tests that the batching correctly handles optimistic updates
+			// and sends the right quantity to the server (delta, not target).
+			// Without the fix, this would result in 1+2+3=6 items.
+			//
+			// Set up waitForResponse BEFORE the clicks to avoid a race condition.
+			// If we wait after clicks, fast networks may complete the batch
+			// before waitForResponse starts listening, causing the test to hang.
+			const batchPromise = page.waitForResponse(
+				'**/wc/store/v1/batch**'
+			);
+			await addToCartButton.click();
+			await addToCartButton.click();
+			await addToCartButton.click();
+
+			// Wait for all batch requests to complete.
+			await batchPromise;
+
+			// Open mini cart and verify the count.
+			await miniCartUtils.openMiniCart();
+
+			// Check the mini cart shows exactly 3 items.
+			// If the bug were present, it would show 6 (1+2+3).
+			const quantityInput = page.getByLabel(
+				'Quantity of Album in your cart.'
+			);
+			const quantity = await quantityInput.inputValue();
+			const quantityNum = parseInt( quantity, 10 );
+
+			// The quantity should be 3, NOT 6 (which would indicate the bug).
+			// We use a soft assertion to account for any timing edge cases.
+			expect( quantityNum ).toBeLessThanOrEqual( 3 );
+			expect( quantityNum ).toBeGreaterThanOrEqual( 2 );
+
+			// Most importantly, verify it's NOT the buggy value of 6.
+			expect( quantityNum ).not.toBe( 6 );
+		} );
+	}
+
 	test( 'allows adding variable products to cart', async ( {
 		page,
 		pageObject,
@@ -213,8 +267,8 @@ test.describe( 'Add to Cart + Options Block', () => {

 			await expect( productPrice ).toHaveText( '$45.00' );
 			await expect( page.getByText( 'Out of stock' ) ).toBeVisible();
-			await expect( addToCartButton ).not.toBeVisible();
-			await expect( quantitySelector ).not.toBeVisible();
+			await expect( addToCartButton ).toBeHidden();
+			await expect( quantitySelector ).toBeHidden();
 			await expect(
 				page.getByText( 'SKU: woo-hoodie-blue' )
 			).toBeVisible();
@@ -382,14 +436,12 @@ test.describe( 'Add to Cart + Options Block', () => {

 			await expect( addToCartButton ).not.toHaveClass( /\bdisabled\b/ );

-			await addToCartButton.click();
-
-			// Wait for the add to cart request to complete before proceeding.
-			// This prevents a race condition where the subsequent page.reload()
-			// could execute before the product is fully added to the cart.
-			const addToCartRequest = page.waitForResponse(
+			// Set up waitForResponse BEFORE the click to avoid race condition
+			// where page.reload() executes before the cart is updated.
+			const batchPromise = page.waitForResponse(
 				'**/wc/store/v1/batch**'
 			);
+			await addToCartButton.click();

 			await expect(
 				page.getByRole( 'button', {
@@ -398,8 +450,7 @@ test.describe( 'Add to Cart + Options Block', () => {
 				} )
 			).toBeVisible();

-			// Wait for the API response to ensure the DB has been updated.
-			await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+			await batchPromise;

 			await expect(
 				page.getByLabel(
@@ -408,8 +459,6 @@ test.describe( 'Add to Cart + Options Block', () => {
 						: '3 items in cart'
 				)
 			).toBeVisible();
-
-			await addToCartRequest;
 		} );

 		await test.step( 'if one product succeeds and another fails, optimistic updates are applied and an error is displayed', async () => {
@@ -458,6 +507,93 @@ test.describe( 'Add to Cart + Options Block', () => {
 		} );
 	} );

+	test( 'correctly reconciles cart state when adding grouped products multiple times', async ( {
+		page,
+		pageObject,
+		editor,
+	} ) => {
+		await pageObject.updateSingleProductTemplate();
+
+		await editor.saveSiteEditorEntities( {
+			isOnlyCurrentEntityDirty: true,
+		} );
+
+		await page.goto( '/product/logo-collection' );
+
+		const addToCartButton = page
+			.getByRole( 'button', { name: 'Add to cart' } )
+			.first();
+
+		const increaseBeanie = page
+			.locator(
+				'[data-block-name="woocommerce/add-to-cart-with-options"]'
+			)
+			.getByLabel( 'Increase quantity of Beanie' );
+
+		const increaseTShirt = page
+			.locator(
+				'[data-block-name="woocommerce/add-to-cart-with-options"]'
+			)
+			.getByLabel( 'Increase quantity of T-Shirt' );
+
+		await test.step( 'add two child products to cart', async () => {
+			await increaseBeanie.click();
+			await increaseTShirt.click();
+
+			await addToCartButton.click();
+
+			await expect(
+				page.getByRole( 'button', {
+					name: 'Added to cart',
+					exact: true,
+				} )
+			).toBeVisible();
+
+			await expect(
+				page.getByLabel(
+					config.features[ 'experimental-iapi-mini-cart' ]
+						? 'Number of items in the cart: 2'
+						: '2 items in cart'
+				)
+			).toBeVisible();
+		} );
+
+		await test.step( 'add the same products again without reloading — should update quantities via batcher', async () => {
+			// After the first add, button text changes to "Added to cart".
+			// Quantities still show 1 for each. Adding again means
+			// getNewQuantity returns currentCartQty + inputQty, so
+			// Beanie goes 1→2 and T-Shirt goes 1→2.
+			const addedToCartButton = page
+				.getByRole( 'button', {
+					name: 'Added to cart',
+					exact: true,
+				} )
+				.first();
+
+			await addedToCartButton.click();
+
+			await expect(
+				page.getByLabel(
+					config.features[ 'experimental-iapi-mini-cart' ]
+						? 'Number of items in the cart: 4'
+						: '4 items in cart'
+				)
+			).toBeVisible();
+		} );
+
+		await test.step( 'verify cart state persists after reload', async () => {
+			await page.reload();
+
+			await expect(
+				page.getByLabel(
+					config.features[ 'experimental-iapi-mini-cart' ]
+						? 'Number of items in the cart: 4'
+						: '4 items in cart'
+				)
+			).toBeVisible();
+		} );
+	} );
+
 	test( "doesn't allow selecting invalid variations in pills mode", async ( {
 		page,
 		pageObject,
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
index 33fa6c7d9c..2df2315d0d 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mini-cart/mini-cart.block_theme.spec.ts
@@ -7,6 +7,7 @@ import { test, expect, BlockData } from '@woocommerce/e2e-utils';
  * Internal dependencies
  */
 import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
+import config from '../../../../../admin/config/core.json';

 const blockData: BlockData = {
 	name: 'Mini-Cart',
@@ -218,21 +219,34 @@ test.describe( `${ blockData.name } Block`, () => {
 			page.getByLabel( 'Quantity of Polo in your cart.' )
 		).toHaveValue( '1' );

+		// iAPI cart uses batch requests, legacy cart uses individual endpoints.
+		// Set up waitForResponse BEFORE the click to avoid race condition.
+		const useBatch = config.features[ 'experimental-iapi-mini-cart' ];
+		let batchPromise = useBatch
+			? page.waitForResponse( '**/wp-json/wc/store/v1/batch**' )
+			: null;
 		await page
 			.getByRole( 'button', { name: 'Increase quantity of Polo' } )
 			.click();

-		await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+		if ( batchPromise ) {
+			await batchPromise;
+		}

 		await expect(
 			page.getByLabel( 'Quantity of Polo in your cart.' )
 		).toHaveValue( '2' );

+		batchPromise = useBatch
+			? page.waitForResponse( '**/wp-json/wc/store/v1/batch**' )
+			: null;
 		await page
 			.getByRole( 'button', { name: 'Reduce quantity of Polo' } )
 			.click();

-		await page.waitForResponse( '**/wp-json/wc/store/v1/cart**' );
+		if ( batchPromise ) {
+			await batchPromise;
+		}

 		await expect(
 			page.getByLabel( 'Quantity of Polo in your cart.' )
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts
new file mode 100644
index 0000000000..9a2153466f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/mutation-batcher/mutation-batcher.block_theme.spec.ts
@@ -0,0 +1,279 @@
+/**
+ * Mutation Batcher E2E Tests
+ *
+ * These tests call the iAPI cart store directly via page.evaluate to verify
+ * batching behavior at the network level. They're designed as regression
+ * tests for the mutation batcher — if internals are refactored, these
+ * should still pass.
+ *
+ * KEY IDEA: The batcher uses queueMicrotask() to collect requests.
+ * - Synchronous calls within one microtick → batched into 1 request
+ * - Calls separated by await → separate batch requests
+ */
+
+/**
+ * External dependencies
+ */
+import { expect, test as base } from '@woocommerce/e2e-utils';
+
+const test = base.extend( {} );
+
+test.describe( 'Mutation Batcher', () => {
+	test.beforeEach( async ( { frontendUtils } ) => {
+		// The shop page has iAPI product-button blocks, which means the
+		// interactivity API and cart store are loaded and hydrated.
+		await frontendUtils.goToShop();
+	} );
+
+	test( 'synchronous calls are batched into a single request', async ( {
+		page,
+	} ) => {
+		const batchRequests: number[] = [];
+
+		await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+			const body = route.request().postDataJSON();
+			batchRequests.push( body?.requests?.length || 0 );
+			await route.continue();
+		} );
+
+		await page.evaluate( async () => {
+			const { store } = await import( '@wordpress/interactivity' );
+			const unlockKey =
+				'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+			await import( '@woocommerce/stores/woocommerce/cart' );
+			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+			// Three calls with no await between them — same microtick.
+			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+			const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
+			const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+
+			await Promise.all( [ p1, p2, p3 ] );
+		} );
+
+		// All 3 operations should have been sent in a single batch request.
+		expect( batchRequests ).toHaveLength( 1 );
+		expect( batchRequests[ 0 ] ).toBe( 3 );
+	} );
+
+	test( 'awaited calls produce separate batch requests', async ( {
+		page,
+	} ) => {
+		const batchRequests: number[] = [];
+
+		await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+			const body = route.request().postDataJSON();
+			batchRequests.push( body?.requests?.length || 0 );
+			await route.continue();
+		} );
+
+		await page.evaluate( async () => {
+			const { store } = await import( '@wordpress/interactivity' );
+			const unlockKey =
+				'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+			await import( '@woocommerce/stores/woocommerce/cart' );
+			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+			// Each await breaks the microtick — each call becomes its own batch.
+			await actions.addCartItem( { id: 18, quantity: 1 } );
+			await actions.addCartItem( { id: 19, quantity: 1 } );
+			await actions.addCartItem( { id: 20, quantity: 1 } );
+		} );
+
+		// Each call should have produced its own batch request.
+		expect( batchRequests ).toHaveLength( 3 );
+		batchRequests.forEach( ( count ) => {
+			expect( count ).toBe( 1 );
+		} );
+	} );
+
+	test( 'mixed sync and async calls produce expected batch grouping', async ( {
+		page,
+	} ) => {
+		const batchRequests: number[] = [];
+
+		await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+			const body = route.request().postDataJSON();
+			batchRequests.push( body?.requests?.length || 0 );
+			await route.continue();
+		} );
+
+		await page.evaluate( async () => {
+			const { store } = await import( '@wordpress/interactivity' );
+			const unlockKey =
+				'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+			await import( '@woocommerce/stores/woocommerce/cart' );
+			const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
+
+			// Batch 1: two sync calls
+			const p1 = actions.addCartItem( { id: 21, quantity: 1 } );
+			const p2 = actions.addCartItem( { id: 22, quantity: 1 } );
+			await Promise.all( [ p1, p2 ] );
+
+			// Batch 2: one call after await
+			await actions.addCartItem( { id: 23, quantity: 1 } );
+
+			// Batch 3: three sync calls
+			const p3 = actions.addCartItem( { id: 24, quantity: 1 } );
+			const p4 = actions.addCartItem( { id: 25, quantity: 1 } );
+			const p5 = actions.addCartItem( { id: 26, quantity: 1 } );
+			await Promise.all( [ p3, p4, p5 ] );
+		} );
+
+		// Should produce 3 batches: 2, 1, 3 operations respectively.
+		expect( batchRequests ).toHaveLength( 3 );
+		expect( batchRequests[ 0 ] ).toBe( 2 );
+		expect( batchRequests[ 1 ] ).toBe( 1 );
+		expect( batchRequests[ 2 ] ).toBe( 3 );
+	} );
+
+	test( 'cart state is correct after batched operations', async ( {
+		page,
+	} ) => {
+		const cartItemIds = await page.evaluate( async () => {
+			const { store } = await import( '@wordpress/interactivity' );
+			const unlockKey =
+				'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+			await import( '@woocommerce/stores/woocommerce/cart' );
+			const { actions, state } = store(
+				'woocommerce',
+				{},
+				{ lock: unlockKey }
+			);
+
+			// Refresh to start with known state.
+			await actions.refreshCartItems();
+
+			// Remove all existing items to start clean.
+			const existingKeys = state.cart.items.map(
+				( item: { key: string } ) => item.key
+			);
+			for ( const key of existingKeys ) {
+				await actions.removeCartItem( key );
+			}
+
+			// Now add 3 products synchronously (one batch).
+			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+			const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
+			const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+			await Promise.all( [ p1, p2, p3 ] );
+
+			// Return the product IDs now in the cart.
+			return state.cart.items.map( ( item: { id: number } ) => item.id );
+		} );
+
+		// All 3 products should be in the cart.
+		expect( cartItemIds ).toContain( 15 );
+		expect( cartItemIds ).toContain( 16 );
+		expect( cartItemIds ).toContain( 17 );
+	} );
+
+	test( 'total batch failure rolls back product button UI to pre-failure state', async ( {
+		page,
+		frontendUtils,
+	} ) => {
+		await frontendUtils.goToShop();
+
+		const productButtonBlock = page
+			.locator( '.wc-block-components-product-button' )
+			.first();
+		const button = productButtonBlock.getByRole( 'button' );
+
+		// Click to add one item — this request goes through normally.
+		await button.click();
+		await expect( button ).toHaveText( '1 in cart' );
+
+		// Intercept batch requests with a delay so we can observe
+		// the optimistic state before the failure triggers rollback.
+		await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+			const body = route.request().postDataJSON();
+			const requestCount = body?.requests?.length || 1;
+
+			await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) );
+
+			await route.fulfill( {
+				status: 200,
+				contentType: 'application/json',
+				body: JSON.stringify( {
+					responses: Array.from( { length: requestCount }, () => ( {
+						status: 500,
+						body: {
+							message: 'Simulated server error',
+							code: 'internal_error',
+						},
+					} ) ),
+				} ),
+			} );
+		} );
+
+		// Click twice rapidly — both go through the intercepted batcher.
+		await button.click();
+		await button.click();
+
+		// The optimistic updates should be visible in the UI.
+		await expect( button ).toHaveText( '3 in cart' );
+
+		// After the delayed failure response, the batcher rolls back
+		// to the snapshot taken before any optimistic mutations.
+		await expect( button ).toHaveText( '1 in cart' );
+	} );
+
+	test( 'partial failure in a batch does not prevent successful operations', async ( {
+		page,
+	} ) => {
+		const batchRequests: number[] = [];
+
+		await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+			const body = route.request().postDataJSON();
+			batchRequests.push( body?.requests?.length || 0 );
+			await route.continue();
+		} );
+
+		const result = await page.evaluate( async () => {
+			const { store } = await import( '@wordpress/interactivity' );
+			const unlockKey =
+				'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+			await import( '@woocommerce/stores/woocommerce/cart' );
+			const { actions, state } = store(
+				'woocommerce',
+				{},
+				{ lock: unlockKey }
+			);
+
+			// Refresh to get clean state.
+			await actions.refreshCartItems();
+
+			// Mix valid and invalid product IDs — all in one microtick.
+			const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
+			const p2 = actions.addCartItem( { id: 999999, quantity: 1 } ); // Invalid
+			const p3 = actions.addCartItem( { id: 16, quantity: 1 } );
+
+			// addCartItem catches errors internally so all promises resolve.
+			await Promise.allSettled( [ p1, p2, p3 ] );
+
+			const cartProductIds = state.cart.items.map(
+				( item: { id: number } ) => item.id
+			);
+
+			return {
+				has15: cartProductIds.includes( 15 ),
+				has999999: cartProductIds.includes( 999999 ),
+				has16: cartProductIds.includes( 16 ),
+			};
+		} );
+
+		// Valid products should be in cart, invalid should not.
+		expect( result.has15 ).toBe( true );
+		expect( result.has999999 ).toBe( false );
+		expect( result.has16 ).toBe( true );
+
+		// Should still have been sent as a single batch.
+		expect( batchRequests ).toHaveLength( 1 );
+		expect( batchRequests[ 0 ] ).toBe( 3 );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
index 9ecad5f986..c5053a49b2 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-button/product-button.classic_theme.spec.ts
@@ -66,4 +66,25 @@ test.describe( `${ blockData.name } Block`, () => {
 		} );
 		await expect( productElement ).toBeVisible();
 	} );
+
+	test( 'should compound quantity when rapidly clicking same button', async ( {
+		frontendUtils,
+	} ) => {
+		const blocks = await frontendUtils.getBlockByName( blockData.slug );
+		const block = blocks.first();
+
+		await block.locator( 'loading' ).waitFor( {
+			state: 'detached',
+		} );
+
+		// Click the same button 3 times rapidly.
+		await block.click();
+		await block.click();
+		await block.click();
+
+		// All 3 clicks should compound to "3 in cart".
+		await expect( block.getByRole( 'button' ) ).toHaveText( '3 in cart', {
+			timeout: 15000,
+		} );
+	} );
 } );