Skip to main content

November 2, 2026

Correcting a cart you do not own

Shopify POS owns the receipt. A bag tile can only add or remove whole lines async. I serialized swaps, claimed paper per cotton line, and re-read the live cart.

Sander Korf5 min read
shopifypostypescriptconcurrency

Shop staff at a clothing retailer ring up sales on an iPad till running Shopify POS. A small app inside the till, a Shopify POS UI extension with a home-screen tile and a picker modal, lets them add the shopping bags they hand out to the receipt so bag stock can be replenished automatically. The till owns the cart. The extension only sees line items and two async calls: add a variant by id, or remove a whole line by uuid.

While building that extension I hit a store rule about which bag a receipt may carry. There are three bag variants: Small Paper, Big Paper, and Big Cotton. Big Cotton is allowed only when the receipt has at least four products, counting quantity on every non-bag line. If staff remove products and the count drops below four while Big Cotton is still on the ticket, the app must swap each Big Cotton line for the same quantity of Small Paper. The app cannot block payment or hide bag lines. It is a soft guard that can only correct the receipt after the fact.

A cashier edits the cart quickly during a sale. I subscribe to cart changes and try to enforce the rule in the background. There is no transaction, no way to lower a line quantity (remove drops the entire line), and no reliable receipt ended event. Staff can change the cart while my writes are still in flight.

Add Big Cotton, use Remove a product until the product counter shows three, then press Fast edits in Naive mode and watch Small Paper pile up. Turn on Make the next remove call fail and watch Big Cotton stay on the receipt while Small Paper is added anyway, so the receipt now carries both bags. Switch to Careful, repeat the sequence, and you should get one Small Paper per cotton line removed, or a toast that tells staff to fix the ticket by hand.

Simulation

Bag swap on a host-owned cart

Fake POS receipt. Async writes use a short delay so overlapping corrections are visible.

Receipt lines

Products (non-bag qty)

5

  • Product×4
  • Product×1

Bag counts

Small Paper on ticket

0

Big Cotton on ticket

0

Write log

  • No writes yet.

Edit the receipt like a cashier would. Big Cotton needs four product units on the ticket.

Correction strategy

Why replanning from each event snapshot hurts

The first version looked reasonable: on every cart change, read the lines in that event, build a plan (remove these cotton lines, add this much Small Paper), run it. On a host-owned cart that plan goes stale fast.

Two cart changes in quick succession each compute from their own snapshot and each add paper, so you get surplus paper. Staff can change cotton quantity or delete the line while a swap is queued, and the plan targets lines that no longer match reality. If you remove cotton first and the add fails, the customer can have a bag in hand that is not on the receipt. If the add succeeds and the remove fails, the next event still sees cotton and adds paper again. Two overlapping swaps each read their own paper baseline, so paper staff delete mid-swap looks like a shortage to the other swap.

Ordering, claims, and a single queue

A simplified version of the shape:

type CartWriter = {
  addLineItem(variantId: number, qty: number): Promise<unknown>
  removeLineItem(uuid: string): Promise<void>
  getLineItems(): readonly CartLine[]
}
 
const claimed = new Map<string, number>()
let queue: Promise<unknown> = Promise.resolve()
 
function onCartChange(lines: readonly CartLine[]) {
  if (cottonAllowed(lines)) return
  const cotton = lines.filter((l) => l.variantId === BIG_COTTON && !claimed.has(l.uuid))
  if (cotton.length === 0) return
  for (const l of cotton) claimed.set(l.uuid, l.quantity)
  queue = queue.then(() => swap(cotton.map((l) => l.uuid)))
}
 
async function swap(uuids: string[]) {
  const live = cart.getLineItems()
  const targets = live.filter((l) => uuids.includes(l.uuid))
  if (cottonAllowed(live) || targets.length === 0) {
    uuids.forEach((u) => claimed.delete(u))
    return
  }
  await cart.addLineItem(SMALL_PAPER, sum(targets))
  for (const t of targets) await cart.removeLineItem(t.uuid)
}

Add Small Paper before removing Big Cotton so a failed add never strips cotton first. Record paper covered per cotton line id in a claim ledger outside the component, so it survives the tile remounting and a failed remove does not enqueue a second add on retry. Serialize swaps through a promise queue; when a swap runs, re-plan from getLineItems() at execution time, not from the snapshot that enqueued it. Settle each line in a loop against live quantity, top up paper when cotton quantity grew, then remove. POS cannot lower quantity, so surplus paper or cotton that will not remove goes into a toast for manual cleanup. An empty cart bumps an epoch so a slow swap for the previous receipt cannot write into the next one.

When you correct state you do not own through async, non-transactional writes, order writes so every failure leaves a valid receipt, record what you already did per line so retries stay idempotent, run one correction at a time, and re-read live state when the write actually runs.

The bag rule and planner live in a pure domain module with a separate use case for ordering and the ledger, covered by unit tests against a fake POS host. I also exercised it manually on a real iPad against a staging POS store, because cart-update timing and tile or modal lifecycle are what the fake host never reproduces. The cart hook re-reads the cart on subscribe so nothing between first render and subscribe is missed.

When this does not apply

Skip the ledger and queue when you own the cart state in your process, when writes are synchronous, or when the platform gives you atomic multi-line updates. The pattern is for carts a host platform owns, where you only get async add and remove calls and no way to edit a quantity.

Happy coding!

Sander