I ran into the same problem several times while building an e-commerce cart. A user could press "add" faster than the server could respond. The interface felt slow if it waited for every request, but updating from each response caused the quantity to jump backward when requests finished out of order.

Three separate concerns were mixed together:

  1. The interface needed to respond before the network request finished.
  2. The server needed to process mutations in the order the user made them.
  3. A failed mutation needed a defined rollback.

Debouncing reduced the number of requests, but it also delayed the interface and changed the meaning of repeated clicks. Optimizing the endpoint helped, but it could not remove network latency. I needed the client to show the expected result immediately while sending mutations to the server one at a time.

Update the interface first

The cart already had enough information to show the result of an action. When a user adds a product, the client knows its name, price, image, and requested quantity. It does not need to wait for the server before rendering the expected cart.

The client can apply that change optimistically, then confirm it with the server. If the request fails, it can remove the optimistic operation or replace the cart with a valid server response.

An optimistic update makes the interface fast. It does not solve request ordering.

Process mutations in order

Imagine four "add" requests starting together. The server might finish them in the order 1, 3, 2, 4. If each response replaces the cart in the client, the displayed quantity can move from 1 to 3, back to 2, then to 4.

A queue prevents that. Each task starts only after the previous task settles, so the server sees the mutations in the order the user made them.

%%{init: {'theme':'neutral'}}%%
flowchart TB
A[User action] --> B[Optimistic update] --> C[Add task to queue]
C --> D[Run next task]
D --> E[Success]
D --> F[Error]
E --> G[Confirm server state] --> D
F --> H[Revert failed operation and clear queue]

The queue solves ordering. The cart reducer still owns the optimistic state, confirmation, and rollback.

A small queue hook

The queue does not need to store its tasks in React state. The UI only needs to know whether work is running. Refs are a better fit for the task list and processing lock because changing them should not cause a render.

import * as React from "react";

type QueueTask = () => Promise<void>;

type UseQueueOptions = {
    onError: (error: unknown) => void;
};

export function useQueue({ onError }: UseQueueOptions) {
    const tasksRef = React.useRef<QueueTask[]>([]);
    const isRunningRef = React.useRef(false);
    const [isProcessing, setIsProcessing] = React.useState(false);

    const processQueue = React.useCallback(async () => {
        if (isRunningRef.current) return;

        isRunningRef.current = true;
        setIsProcessing(true);

        try {
            while (tasksRef.current.length > 0) {
                const task = tasksRef.current.shift();
                if (task) await task();
            }
        } catch (error) {
            tasksRef.current = [];
            onError(error);
        } finally {
            isRunningRef.current = false;
            setIsProcessing(false);
        }
    }, [onError]);

    const addTask = React.useCallback(
        (task: QueueTask) => {
            tasksRef.current.push(task);
            void processQueue();
        },
        [processQueue],
    );

    const clearQueue = React.useCallback(() => {
        tasksRef.current = [];
    }, []);

    return {
        addTask,
        clearQueue,
        isProcessing,
    };
}

tasksRef preserves insertion order. isRunningRef prevents two processors from draining the same queue. The loop awaits each task before starting the next one. If a task throws, the hook clears tasks that have not started and reports the error.

The hook cannot cancel a request that is already running. If that matters, each task should accept an AbortSignal, and clearQueue should abort the active controller as well as remove pending tasks.

Connect the queue to optimistic state

Each cart mutation needs an identifier. The reducer uses it to distinguish the optimistic operation from the server response that confirms or rejects it.

const onQueueError = React.useCallback((error: unknown) => {
    dispatch({ type: "cart/revertAllPending" });
    toast.error(getErrorMessage(error));
}, [dispatch, toast]);

const { addTask, isProcessing } = useQueue({
    onError: onQueueError,
});

function addCartItem(product: Product) {
    const operationId = crypto.randomUUID();

    dispatch({
        type: "cart/addOptimistic",
        operationId,
        product,
    });

    addTask(async () => {
        const response = await cartApi.addItem(product.id);

        if (!response.ok) {
            dispatch({
                type: "cart/revertOptimistic",
                operationId,
            });

            throw new Error(response.errorMessage);
        }

        dispatch({
            type: "cart/confirmOptimistic",
            operationId,
            cart: response.cart,
        });
    });
}

The reducer must not blindly replace the visible cart with response.cart. A later optimistic action may already be on screen while an earlier request is being confirmed. The reducer should store the last confirmed cart and replay any still-pending operations on top of it. If the queue drops pending tasks after an error, revertAllPending removes their optimistic changes too.

That distinction matters. Serializing requests prevents the server responses from arriving out of order. It does not automatically make optimistic state correct.

Decide what an error means

Clearing the queue after an error was the right policy for this cart because later mutations depended on the earlier state. If adding an item failed, sending a queued quantity change for that item made no sense.

That policy is not universal. Independent tasks can continue after one fails. Other interfaces may retry the failed task, pause the queue, or ask the user what to do. The hook should match the dependency between the operations rather than treating "flush on error" as a requirement of every queue.

For this cart, the final flow is predictable:

  1. Apply the user's action locally.
  2. Add its server mutation to the queue.
  3. Process mutations in insertion order.
  4. Confirm the optimistic operation or revert it.
  5. Stop pending dependent work after an error.

The useful part is not the hook by itself. It is separating immediate interface feedback, ordered server work, and recovery into three explicit responsibilities.