Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 11 of 15IntermediateAug 3, 2026

Shopping Cart

Design a cart that works logged out and logged in, updates optimistically with rollback, merges on login, stays consistent across tabs, and never trusts a client-computed price.

frontend-system-designpractice-problemcommerce

The Problem

Design a shopping cart: add items, change quantities, remove items, see a running total, and check out. It must work for a visitor who is not signed in, and it must still be there when they sign in on another device.

The interesting parts are not the list of items. They are the four different places a cart can live - local memory, local storage, a server session, an account record - and the reconciliation between them; the optimistic update path where every interaction must feel instant but any of them can be rejected; and the fact that money is involved, which makes "the client computed the total" a security statement rather than a performance one.

Requirements

Functional

  • Add, remove and change quantity, with instant visual feedback.
  • Cart badge and totals accurate everywhere they appear.
  • Works signed out; survives a page reload.
  • Merges into the account cart on sign-in without losing items.
  • Consistent across multiple open tabs.
  • Stock and price changes surfaced before checkout, not after payment.

Non-functional

  • Cart interactions feel instantaneous; no spinner on a quantity change.
  • A rejected mutation reverts visibly and explains itself.
  • Server owns pricing, tax, shipping and availability.
  • Concurrent writes from two tabs or two devices do not silently erase each other.

Where the Cart Lives

Four candidate homes, and a production cart uses several at once.

Diagram
100%
flowchart TB subgraph CLIENT["Client"] MEM[("in-memory store<br/>renders the UI, optimistic")] LS[("localStorage<br/>survives reload, anonymous")] BC["BroadcastChannel<br/>fan-out to other tabs"] end subgraph SYNC["Sync layer"] Q["operation queue<br/>(increment, setQuantity, remove)"] RETRY["retry with backoff<br/>+ idempotency key per op"] end subgraph SERVER["Server - authoritative"] SESS[("anonymous cart<br/>keyed by session cookie")] ACCT[("account cart<br/>keyed by userId")] PRICE["pricing engine<br/>line prices, discounts,<br/>tax, shipping"] STOCK["stock + availability"] end MEM <--> LS MEM --> BC --> MEM MEM -->|"operations, not snapshots"| Q --> RETRY --> SESS RETRY --> ACCT SESS -->|"authoritative totals"| MEM ACCT -->|"authoritative totals"| MEM PRICE --> SESS PRICE --> ACCT STOCK --> PRICE LOGIN["sign-in"] -->|"merge(anonymous, account)<br/>server-side, idempotent"| ACCT SESS -.->|"consumed by merge,<br/>then cleared"| LOGIN style CLIENT fill:#1e3a5f,stroke:#3b82f6 style SYNC fill:#3f2d1e,stroke:#f59e0b style SERVER fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

The division of ownership is the whole design:

The client owns intent - which product variants, and how many. That is all.

The server owns everything derived - line prices, discounts, tax, shipping, availability, and the total. Not because the client is untrustworthy in some abstract sense, but because those values depend on data the client does not have and cannot be allowed to assert.

Anonymous carts need a home that survives a reload. localStorage alone is the cheapest option and requires no backend. A server-side session cart keyed by a cookie is better if you want abandoned-cart recovery or cross-tab consistency for free, at the cost of a session store. Many products use both: local for instant render, session for durability.

/** What the client stores. Note what is absent: prices. */
type CartLine = {
  /** Stable key for a specific purchasable thing - product plus variant. */
  variantId: string;
  quantity: number;
  /** Cached for display only, replaced by the server's value on every sync. */
  display: {
    title: string;
    imageUrl: string;
    unitPriceMinor: number;
    currency: string;
  };
  /** Set by the server when it cannot fulfil the requested quantity. */
  availability?: { available: number; message: string };
};

type CartState = {
  lines: CartLine[];
  /** Server-computed. Null until the first successful sync. */
  totals: CartTotals | null;
  /** Operations applied locally but not yet acknowledged. */
  pending: PendingOperation[];
  /** Optimistic concurrency token from the server. */
  version: number;
  syncState: 'idle' | 'syncing' | 'failed';
};

The display object earns its name: it exists so the cart can render without a round trip, and it is replaced by the server's values on every sync. Treating a cached unit price as the price is how a stale figure reaches a checkout screen. This is the server-state-is-cached-not-owned position from State Management Architecture, applied where being wrong costs money.

Optimistic Updates and Rollback

A quantity stepper that waits for a round trip feels broken. Apply locally, send, reconcile.

type CartOperation =
  | { kind: 'increment'; variantId: string; by: number }
  | { kind: 'setQuantity'; variantId: string; to: number }
  | { kind: 'remove'; variantId: string };

/**
 * Operations, not snapshots. Two tabs sending "increment by 1" compose;
 * two tabs sending their whole cart means the second silently wins.
 */
function applyLocally(state: CartState, operation: CartOperation): CartState {
  switch (operation.kind) {
    case 'increment': {
      const line = state.lines.find((item) => item.variantId === operation.variantId);
      if (!line) return state;
      return replaceLine(state, {
        ...line,
        quantity: Math.max(0, line.quantity + operation.by),
      });
    }
    case 'setQuantity':
      return replaceLine(state, {
        ...state.lines.find((item) => item.variantId === operation.variantId)!,
        quantity: operation.to,
      });
    case 'remove':
      return {
        ...state,
        lines: state.lines.filter((item) => item.variantId !== operation.variantId),
      };
  }
}

/** The inverse of an operation, for rollback. Not a snapshot. */
function invert(operation: CartOperation, before: CartLine | undefined): CartOperation | null {
  switch (operation.kind) {
    case 'increment':
      return { kind: 'increment', variantId: operation.variantId, by: -operation.by };
    case 'setQuantity':
      // Needs the prior quantity, which is why it is captured when queued.
      return before
        ? { kind: 'setQuantity', variantId: operation.variantId, to: before.quantity }
        : { kind: 'remove', variantId: operation.variantId };
    case 'remove':
      return before
        ? { kind: 'setQuantity', variantId: operation.variantId, to: before.quantity }
        : null;
  }
}
Diagram
100%
sequenceDiagram participant U as User participant UI as Cart UI participant S as Cart store participant API as Cart API participant STK as Stock service U->>UI: taps "Add to cart" (quantity 2) UI->>S: dispatch increment(+2) S->>S: apply locally, push to pending<br/>capture inverse: increment(-2) S->>UI: re-render instantly - badge 2, optimistic subtotal S->>API: POST /cart/operations<br/>{op, idempotencyKey, version} API->>STK: reserve 2 of variant STK-->>API: only 1 available API-->>S: 409 {reason: 'insufficient_stock',<br/>available: 1, cart: authoritativeCart} Note over S: Do NOT restore a snapshot - a price<br/>refresh may have landed meanwhile. S->>S: apply inverse increment(-2) S->>S: then replace lines + totals from<br/>the authoritative cart in the response S->>UI: quantity reverts, availability set UI->>U: "Only 1 left - quantity adjusted"<br/>inline on the line, not a toast alone U->>UI: accepts 1 UI->>S: dispatch setQuantity(1) S->>API: POST /cart/operations API-->>S: 200 {cart, totals, version: 8} S->>S: clear pending, adopt server totals S->>UI: confirmed state
visualized byIOCombats

Four properties make this correct rather than merely fast.

Roll back with the inverse, not a snapshot. Between the optimistic apply and the rejection, other things may have landed - a price refresh, another tab's edit, a stock update. Restoring a snapshot discards them.

The server's response carries the authoritative cart, and the client adopts it wholesale after rolling back. That converges any drift the inverse did not catch, which is why the two steps are complementary rather than redundant.

Rejections are explained in place. An item that silently appears then vanishes reads as a bug. The message belongs on the line ("Only 1 left") as well as, or instead of, a toast.

Each operation carries an idempotency key, so a retry after a lost response does not apply the change twice. This is the same lost-acknowledgement problem as in File Upload System, and the same fix.

async function dispatchOperation(store: CartStore, operation: CartOperation) {
  const before = store.getLine(operationVariantId(operation));
  const inverse = invert(operation, before);
  const key = crypto.randomUUID();

  store.applyOptimistic(operation, key);

  try {
    const result = await api.applyCartOperation({
      operation,
      idempotencyKey: key,
      // Optimistic concurrency: the server rejects a write built on stale state.
      version: store.getVersion(),
    });

    store.acknowledge(key, result.cart, result.totals, result.version);
  } catch (error) {
    if (isVersionConflict(error)) {
      // Someone else changed the cart. Adopt the server's state and re-apply
      // our intent on top of it rather than fighting over a snapshot.
      const fresh = await api.getCart();
      store.adopt(fresh);
      store.applyOptimistic(operation, key);
      return dispatchOperation(store, operation);
    }

    if (inverse) store.applyOptimistic(inverse, `${key}-rollback`);
    if (isStockError(error)) store.setAvailability(error.variantId, error.available);
    store.reportFailure(key, messageFor(error));
  }
}

Two refinements worth mentioning. A rapid tap on a quantity stepper should coalesce - five taps become one setQuantity after a short debounce, rather than five requests that arrive out of order. And operations for the same line must be serialised, or a remove and an increment can cross and leave the server in a state the user did not ask for.

Price Calculation

The split is not a performance optimisation; it is a trust boundary.

/**
 * Client-side, for instant feedback only. Deliberately does not attempt tax,
 * shipping or discounts - a wrong number there is worse than no number.
 */
function optimisticSubtotal(lines: CartLine[]): Money {
  const amount = lines.reduce(
    (sum, line) => sum + line.display.unitPriceMinor * line.quantity,
    0,
  );

  return { amountMinor: amount, currency: lines[0]?.display.currency ?? 'INR' };
}
/** What the server returns and what the UI must display for anything binding. */
type CartTotals = {
  subtotalMinor: number;
  discountMinor: number;
  taxMinor: number;
  shippingMinor: number;
  totalMinor: number;
  currency: string;
  /** Lines whose price changed since they entered the cart. */
  priceChanges: { variantId: string; wasMinor: number; nowMinor: number }[];
  computedAt: number;
};

Four rules:

  1. Money is integers in the smallest currency unit. Floating point on money produces the classic 0.1 + 0.2 error, and in a cart that surfaces as a total that is one paisa out and a reconciliation nobody can explain. Store minor units; format at the edge.
  2. The client estimates the subtotal only. Discounts, tax and shipping depend on rules, jurisdictions and rates the client does not have. A wrong estimate is worse than an honest "calculated at checkout".
  3. The server's totals replace the estimate on every sync, and the checkout step displays only server-computed figures.
  4. Price changes are surfaced explicitly. Prices move between adding and paying. priceChanges exists so the UI can say "the price of this item changed from ₹1,299 to ₹1,499" and require acknowledgement, rather than silently charging a number the user never saw. Silently charging more is both a trust failure and, in many jurisdictions, a legal one.

Currency and formatting are their own hazard - symbol placement, decimal separator, grouping, and the fact that some currencies have no minor unit at all. Intl.NumberFormat with an explicit currency is the answer, and the broader reasoning is in Internationalization Architecture.

Merge on Login

An anonymous visitor has three items. They sign in, and their account already has two from last week. What happens next needs a stated policy, because every naive answer loses data.

type MergeStrategy = 'union-max' | 'union-sum' | 'prefer-local' | 'prefer-account';

/**
 * Server-side. The client cannot do this - only the server can validate stock
 * and pricing for the merged result.
 */
export async function mergeCarts(
  userId: string,
  sessionId: string,
  strategy: MergeStrategy = 'union-max',
) {
  // Idempotent: the login callback can fire more than once, and a merge that
  // runs twice must not double quantities.
  const alreadyMerged = await db.cartMerge.findUnique({
    where: { userId_sessionId: { userId, sessionId } },
  });
  if (alreadyMerged) return { data: await getAccountCart(userId) };

  const [local, account] = await Promise.all([
    getSessionCart(sessionId),
    getAccountCart(userId),
  ]);

  const byVariant = new Map<string, number>();
  for (const line of account.lines) byVariant.set(line.variantId, line.quantity);

  for (const line of local.lines) {
    const existing = byVariant.get(line.variantId) ?? 0;

    // union-max, not union-sum: two visits to the same product should not
    // become a quantity the user never intended.
    const merged =
      strategy === 'union-sum'
        ? existing + line.quantity
        : Math.max(existing, line.quantity);

    byVariant.set(line.variantId, merged);
  }

  // Clamp to stock, and record what had to be trimmed so the user is told.
  const { lines, adjustments } = await clampToAvailableStock(byVariant);

  await db.$transaction([
    db.cart.update({ where: { userId }, data: { lines } }),
    db.cartMerge.create({ data: { userId, sessionId } }),
    db.sessionCart.delete({ where: { id: sessionId } }),
  ]);

  return { data: { cart: await getAccountCart(userId), adjustments } };
}

Four properties:

  • Union, not replacement. prefer-account discards what the user just chose - the most recent and most intentional data. prefer-local discards what they added on another device. Both lose items and both generate support tickets.
  • union-max over union-sum as the default. Someone who added one of an item anonymously and had one in their account wants one, not two. Summing is defensible for consumables and is a product decision, but it should be a deliberate one.
  • Idempotent. Auth callbacks fire more than once - a duplicated OAuth redirect, a retried request. A non-idempotent merge doubles quantities.
  • Adjustments are reported. If stock forced a quantity down, or an item is now unavailable, say so. Silently trimming a cart during login is the kind of bug users notice at the payment screen.

The merge belongs on the server because it needs stock and pricing. It also needs to run before the client's first post-login cart read, or the UI briefly shows the pre-merge cart and then flips - and users interpret a cart that changes on its own as items being lost.

Session handling around this is worth a note: the anonymous cart is keyed by a session cookie, and that cookie must be HttpOnly, Secure and SameSite=Lax so it cannot be read by script or attached to a cross-site request. On login, the session should be rotated and the old session cart consumed, since a session identifier that survives a privilege change is a session-fixation vector. The reasoning is in Security Architecture.

Multi-Tab Consistency

Two tabs, two copies of the cart. Adding in one leaves the other stale - and if both then write their full cart, the second erases the first.

const CART_CHANNEL = 'cart';

type CartBroadcast =
  | { kind: 'operation'; operation: CartOperation; originId: string }
  | { kind: 'adopt'; cart: SerializedCart; version: number; originId: string };

class CartSync {
  private channel = new BroadcastChannel(CART_CHANNEL);
  private tabId = crypto.randomUUID();

  constructor(private store: CartStore) {
    this.channel.onmessage = (event) => {
      const message = event.data as CartBroadcast;
      if (message.originId === this.tabId) return; // our own echo

      if (message.kind === 'operation') {
        // Apply locally only. The originating tab owns the server write, so
        // this tab must not send its own or the change is applied twice.
        this.store.applyLocalOnly(message.operation);
      } else if (message.version > this.store.getVersion()) {
        this.store.adopt(message.cart, message.version);
      }
    };

    // A tab that was frozen or offline may have missed broadcasts entirely.
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') void this.store.refetch();
    });
  }

  publishOperation(operation: CartOperation) {
    this.channel.postMessage({ kind: 'operation', operation, originId: this.tabId });
  }

  publishAdopt(cart: SerializedCart, version: number) {
    this.channel.postMessage({ kind: 'adopt', cart, version, originId: this.tabId });
  }
}

Three mechanisms working together:

Broadcast the operation, not the resulting cart, so the other tabs apply the same intent and stay convergent without a network request. Broadcasting the whole cart works too but loses ordering information when two tabs act at once.

Only the originating tab writes to the server. Otherwise the same increment is sent twice and the quantity doubles.

Version-based optimistic concurrency on the server catches everything the client-side coordination misses - including a second device, which no BroadcastChannel can reach. A write with a stale version is rejected, the client adopts the server's state and re-applies its intent.

Reconciling on visibilitychange covers a tab that was frozen by the browser and missed the broadcasts entirely. The same leader-election and fan-out machinery, in a more elaborate form, is described in Notification System.

Stock and Staleness at Checkout

A cart is a set of intentions that decay. Between adding and paying, prices change, stock runs out and promotions expire. The design decision is when to discover this.

Three checkpoints, and all three are needed:

  1. On add. A soft check so the user is told immediately if something is unavailable.
  2. On cart view and on checkout entry. Revalidate everything - prices, stock, promotions - and surface differences before the payment step. This is the one that prevents the worst outcome.
  3. At payment authorisation. The final atomic check, server-side, inside the same transaction that reserves stock. Nothing before this is a guarantee.
type CheckoutValidation = {
  ok: boolean;
  blocking: { variantId: string; reason: 'out_of_stock' | 'unavailable' }[];
  requiresAcknowledgement: {
    variantId: string;
    kind: 'price_increased' | 'quantity_reduced' | 'promotion_expired';
    detail: string;
  }[];
};

Separating blocking problems from those requiring acknowledgement is what makes the checkout experience tolerable. An out-of-stock item must be resolved before proceeding. A price increase needs to be seen and accepted, not silently applied and not treated as a hard stop.

Stock reservation is a genuine product tradeoff worth naming: reserving on add gives a certain checkout but lets abandoned carts hold inventory hostage; reserving only at payment maximises availability but means a user can reach the payment screen and fail. Most products reserve at payment with a short hold, and accept the occasional late failure.

Common Interview Follow-Up Questions

"The user adds an item, goes offline, and keeps browsing. What should happen?" The local cart keeps working, because it is the rendering source and does not need the network. Operations queue in durable storage rather than memory, so a reload while offline does not lose them, and the UI marks the cart as not yet synced rather than pretending everything is confirmed. On reconnect the queue replays in order with idempotency keys, and the server's response reconciles any conflict - stock that ran out while offline surfaces then. What must not happen is allowing checkout offline: payment requires the server, and queuing a purchase to be attempted later is a promise you cannot keep. The queue-and-replay machinery is in Offline and PWA Architecture.

"Cart badge shows 3 but the cart page shows 2 items. How does that happen?" Two copies of derived state. Almost always the badge count is stored rather than derived - incremented on add somewhere that does not go through the same reducer as the cart list, so any path that changes lines without touching the counter causes permanent drift. The fix is that the badge is a selector over the same lines the page renders, computed rather than stored. The second most likely cause is two independent caches for the same server resource, where the badge reads a header count and the page reads the cart endpoint, and one is refreshed while the other is not. Both are the single-owner failure described in State Management Architecture.

"How do you handle a cart with 200 line items?" Rare but real for wholesale. The list needs windowing, so the cart page inherits the techniques in Virtualized List. The operation model matters more: sending the whole cart on every change becomes a large payload, and 200 individual requests when the user clicks "update all" is worse - so operations should batch into a single request with an array of changes and one idempotency key. Optimistic subtotal recomputation over 200 lines on every keystroke is also enough work to be felt, so it should be memoised and computed from a diff rather than a full reduce.

"What do you instrument?" The funnel first - add-to-cart rate, cart view to checkout entry, checkout entry to payment, and abandonment at each step - because that is what the business asks about. Then the engineering signals that explain it: rejection rate by reason, with out-of-stock and price-change separated, since a spike in either shows up as abandonment with no obvious cause; rollback frequency, which indicates either stock problems or a client that is out of step; version-conflict rate, which reveals multi-tab or multi-device usage patterns; and merge adjustments on login, which is where silent item loss hides. All of it is client-reported for the interaction half, since the server cannot see a rejection the user reacted to - the argument in Observability.

"How do you test this?" The reducer is pure and gets the most coverage: apply-then-invert returns to the original state for every operation type, quantity clamping at zero, and adopting a server cart while operations are pending. The merge function needs a table of cases - overlapping items, stock-clamped items, a duplicate merge call asserting no doubling. Multi-tab sync needs two real browser contexts, asserting convergence and that only one server write occurred. The single highest-value test is the rejection path, because it is the one that never happens in development: mock a 409 with an authoritative cart and assert the inverse was applied, the totals came from the server, and a message reached the user. The layering rationale is in Testing Strategy.

Tradeoffs Table

OptionProsConsWhen to Use
Client-only cartInstant, no backend, works signed outNo cross-device, no abandoned-cart recovery, prices never validatedPrototypes, or a purely local wishlist
Server-only cartAuthoritative, cross-device, recoverableA round trip on every quantity change; feels sluggish where users interact mostLow-traffic B2B tools where correctness dominates feel
Local mirror plus server syncInstant interaction and an authoritative source of truthA sync layer, conflict handling, and two representations to keep alignedDefault for any real storefront
Snapshot rollbackTrivial to writeDiscards changes that landed between apply and rejectionOnly when nothing else can mutate the cart concurrently
Inverse-operation rollbackSurvives concurrent price, stock and cross-tab changesEvery operation needs a correct inverse, and some need captured prior stateAny cart with live pricing or multiple sessions
union-max merge on loginKeeps items from both sides; no surprise quantitiesNot right for consumables where summing is intendedDefault merge policy
Reserve stock on addCheckout is certain to succeedAbandoned carts hold inventory; needs expiry and releaseScarce or high-demand inventory, ticketing
Reserve at paymentMaximises availability, no reservation bookkeepingA user can reach payment and failDefault for general retail

Where This Applies

A shopping cart is the canonical demonstration of the categorisation exercise in State Management Architecture: the same value is client-owned as intent, server-owned as price, optimistically mutated, mirrored across tabs, and visible in several components at once - and modelling it as one flat piece of state produces every drift bug at once. It is also where the trust boundary in Security Architecture is least negotiable, covering both the client-computed-total problem and the session rotation that must accompany the login merge. The operation queue with idempotency keys is the request-lifecycle discipline from Networking and Data Fetching.

Within this track, the optimistic-with-inverse-rollback pattern is the same one used in Real-Time Feed, and the cross-tab fan-out is a simplified form of Notification System. Checkout itself is a multi-step form, and the delivery-date field in it is a date picker with exactly the plain-date-versus-instant hazard that article describes.

Advertisement

Frequently Asked Questions

Should the cart live on the client or on the server?

Both, with the server authoritative and the client holding a fast local mirror. A client-only cart is instant and works for anonymous visitors with no backend at all, but it does not survive a device change, cannot be recovered for abandoned-cart email, and holds prices the server never agreed to. A server-only cart is authoritative and cross-device but makes every quantity change a round trip, so the interface feels sluggish exactly where users interact most. The production answer is a local cart that renders immediately and a sync layer that reconciles with the server, where the server owns pricing, availability and totals, and the client owns nothing except the intent - which items and how many. For anonymous users the cart is either purely local or attached to a server-side session keyed by a cookie, and it merges into the account cart on login.

What exactly gets rolled back when an add-to-cart is rejected?

Only the specific change you made, not a snapshot of the whole cart. Snapshot-and-restore is the tempting implementation and it is wrong in any system where other changes can land in between - a price update, another tab's edit, a stock change pushed from the server - because restoring the snapshot discards those too. The correct rollback applies the inverse of your own operation, so an add of two units is undone by removing two units from whatever the current quantity happens to be, and then the next authoritative server response corrects any remaining drift. The rejection also has to be explained rather than silently reverted, because an item that appears and then vanishes with no message reads as a bug rather than as an out-of-stock signal.

Why must the client never be the source of truth for price?

Because everything the client computes runs in an environment the user controls, so a total sent from the browser is a suggestion. The practical split is that the client computes an optimistic subtotal purely so quantity changes feel instant, and the server recomputes every line price, discount, tax and shipping cost from its own data at the moment of checkout. Prices also change between the moment an item enters a cart and the moment it is paid for, so even an honest client can be wrong through no fault of its own. That is why the server response for a cart operation should return the authoritative totals and the client should replace its optimistic figures with them, and why the checkout step must surface any difference explicitly rather than silently charging a number the user never saw.

How do you merge an anonymous cart into an account cart on login?

With an explicit, stated policy, because the naive approaches all lose data. Replacing the account cart discards what the user added on another device; discarding the local cart throws away what they just chose, which is worse because it is more recent. The defensible default is a union, where items present in only one cart are kept and items present in both are reconciled by a documented rule - usually taking the higher quantity rather than summing, since summing turns two visits to the same product into a quantity the user never intended. The merge must be performed on the server, since it is the only party that can validate stock and pricing for the result, and it must be idempotent, because the login callback can fire more than once. Anything dropped by stock limits should be reported to the user rather than silently trimmed.

What goes wrong with a cart across multiple tabs, and how do you fix it?

Each tab keeps its own copy, so adding an item in one leaves the others showing a stale badge and a stale list - and if both tabs then write their whole cart to storage or to the server, the second write silently erases the first. The fix has two parts. Broadcast every local mutation to the other tabs, either through a BroadcastChannel or by listening for storage events, so all tabs converge immediately without a network request. And make server writes operation-based rather than snapshot-based, sending "increment this item by one" instead of "here is my entire cart", so two concurrent tabs compose rather than overwrite. A version or updated-at value on the server cart lets it reject a write built on stale state instead of accepting a clobber.