All posts
Forward Development

Medusa inventory under concurrent checkout: preventing overselling

A Medusa 2.19 bug lets concurrent carts oversell stock and lose reserved_quantity updates across servers. How to test your store and what to change.

  • Medusa.js
  • Migrations
  • Operations

Two orders land a second apart. Both confirm, both take payment, both go to the warehouse. There were ten units on the shelf and the orders need twelve. The admin reads −2. Sometimes it shows the correct number even though two customers were each promised the last unit. Nobody notices until a picker comes back empty-handed.

Medusa issue #17032 describes this failure. It was filed against @medusajs/medusa 2.19.0 on PostgreSQL 17.11 and Node 22. The reporter notes the code is unchanged on develop, so when the issue was filed no release fixed it. If you run Medusa v2, or are planning a move onto it, check this before your next traffic peak.

How Medusa v2 reserves stock at checkout

When completeCartWorkflow runs, reserveInventoryStep creates reservation items for the cart's lines. It takes a per-item lock. Inside that lock, the inventory module's ensureInventoryLevels checks the request against each level's available_quantity, then createReservationItems_ writes reservation rows and updates reserved_quantity on the level.

For extra validation, Medusa points stores at the validate hook on completeCartWorkflow. That hook is the documented extension point, and it runs before the lock. That ordering is what the rest of this article turns on.

Where the multi-line race happens

The check in ensureInventoryLevels looks at each reservation input on its own. It never adds up inputs that share an (inventory_item_id, location_id) pair. That only matters when one cart holds the same inventory item on more than one line: loose units plus a multi-pack, or a bundle plus a single. Kits and bundles create exactly that.

The issue's example:

  • stock is 10
  • cart A: 5 × piece
  • cart B: 3 × piece plus 2 × two-pack (4 units of the same item)

Both carts pass any aggregate check in the validate hook, because the hook runs outside the lock and both see 10 available. A reserves 5 under the lock. B then reserves under the lock and checks each line against the remaining 5: 3 ≤ 5 and 4 ≤ 5, so both lines pass. Reserved becomes 12 against 10 stocked.

The reporter fired both completions at once over HTTP on a scratch store. 14 of 30 runs oversold, which was every run where A reserved first. In-process it was 10 of 30. With single-line carts the lock held: 0 of 60. If you never sell one item across multiple lines, this half of the bug doesn't affect you. Most stores with bundles do.

Why several server instances make it worse

The second problem is separate, and it affects single-line carts too. By default the lock only works within one process. createReservationItems_ writes reserved_quantity as an absolute value computed from an earlier read, not as an increment. Two processes read the same value and compute the same new value. The ORM sees no change on the second write and skips it.

With two servers on one database, the last unit sold twice in 23 of 90 runs. Every time, the level showed reserved 1 against two reservation rows. available_quantity looked healthy, so the oversell didn't show in the number most admin screens report.

If more than one instance completes carts, you are in this configuration. That includes several instances behind a load balancer, or separate server and worker processes.

Testing your own stack with parallel checkouts

Measure your exposure rather than reasoning about it. On staging, with the same process layout as production:

  1. Create an inventory item with stock of 10 at one location.
  2. Build two carts through the Store API. One holds single units. The other holds the item on two lines, through a variant and a multi-pack or bundle that shares the inventory item.
  3. Fire both cart completions at the same moment: Promise.all over two fetch calls, or two backgrounded curl commands followed by wait.
  4. Reset and repeat at least 30 times. A race that hits a third of runs can easily hide across three attempts.
  5. Repeat with two server instances on the same database, single-line carts, and stock of 1.

After each run, compare the level against the reservation rows, not just available_quantity:

SELECT il.inventory_item_id, il.location_id,
       il.stocked_quantity, il.reserved_quantity,
       COALESCE(SUM(ri.quantity), 0) AS live_reserved
FROM inventory_level il
LEFT JOIN reservation_item ri
  ON ri.inventory_item_id = il.inventory_item_id
 AND ri.location_id = il.location_id
 AND ri.deleted_at IS NULL
WHERE il.deleted_at IS NULL
GROUP BY il.id
HAVING COALESCE(SUM(ri.quantity), 0) <> il.reserved_quantity
    OR COALESCE(SUM(ri.quantity), 0) > il.stocked_quantity;

Check the table and column names against your schema before you rely on this. Every row it returns is a lost update, an oversell, or both.

Mitigations while upstream is unfixed

Share the lock across processes. If you run more than one instance, back Medusa's locking with a distributed provider (Redis is the usual choice) instead of the in-memory default. The per-item lock then covers every instance, which targets the cross-process lost update. It does nothing for the multi-line case, which fails even when the lock works. Rerun step 5 afterwards to confirm.

Enforce the total in the database. The reporter's stopgap is a Postgres AFTER INSERT OR UPDATE trigger on reservation_item. It locks the level row, sums live reservations, and raises an error if the sum exceeds stocked_quantity, skipping allow_backorder rows. They measured 0 of 30 oversold on the multi-line case and 0 of 90 across two processes. We think this is the right layer for this rule: the database enforces it no matter which code path writes a reservation. The trade-offs are real, though. You own the trigger outside Medusa's migrations, you have to re-check it on every upgrade, and a raised error shows up as a failed cart completion that the storefront must handle cleanly.

Reconcile on a schedule. Run the query above hourly and alert on any row. It catches drift from this bug and from anything else that writes reservations. It's cheap, and it's the only way to see the cross-process case at all.

Pin exact versions. Pin @medusajs/* to exact versions, not ranges. Watch #17032, the earlier single-cart report #16502, and the related integrity issues the reporter linked (#16998, #17003, #17030, #17031). Rerun the parallel checkout test on every upgrade. When a fix ships, that test tells you whether you can drop the trigger.

What to do before peak

Answer two questions. Does any cart put one inventory item on more than one line? Do you complete carts from more than one process? If either answer is yes, run the test now, not in the week before your peak. Add the reconciliation query either way.

If you're replatforming, make concurrent checkout testing part of the acceptance criteria, not a post-launch task. Don't assume stock reservation on the new platform behaves the way it did on the old one. We describe how we approach that in Magento 2 and Adobe Commerce to Medusa or Vendure migrations. For a second look at your inventory path, or help writing and testing the trigger, get in touch.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us