Medusa can oversell stock under concurrent checkouts
Medusa 2.19.0's inventory reservations are not concurrency-safe when called directly — here's where the race is and how to detect and close it.
- Medusa.js
- Migrations
- Operations

Two customers buy the last unit within the same second. Both checkouts succeed. Your warehouse has one unit, your order table has two, and someone in support is about to write an apology email. Worse, the number you'd normally check to catch this — reserved_quantity on the inventory level — reads perfectly healthy.
This is a real, reproducible failure in Medusa's Inventory Module, filed against 2.19.0 (medusajs/medusa#16569). It is worth understanding in detail before you put a Medusa store into production, particularly if you've been building custom reservation logic during a replatform.
The race lives in a read-modify-write on availability
IInventoryService.createReservationItems() checks availability, then writes. Those are separate steps, and nothing in the module holds a lock across them. Two concurrent calls in separate transactions both read available_quantity = 1, both decide the reservation is legal, and both insert a ReservationItem row.
The reporter's setup is as small as it gets — one inventory item, one level:
stocked_quantity = 1
reserved_quantity = 0
available_quantity = 1
Then fire two direct calls at it:
const reserve = () =>
inventoryModuleService.createReservationItems({
inventory_item_id,
location_id,
quantity: 1,
})
const result = await Promise.allSettled([reserve(), reserve()])
Measured over 50 runs, the unlocked version produced two reservation rows 50 times out of 50. Not a narrow window you might get away with — a wide-open one. The environment was Medusa 2.19.0 (@medusajs/framework, @medusajs/medusa, @medusajs/inventory, @medusajs/locking all at 2.19.0), Node v22.23.2, PostgreSQL 17.7.
The counter that should reveal the problem hides it instead
Here's the part that makes this nastier than a plain oversell. When both reservations land, the aggregate counter takes a lost update — it gets incremented once, not twice:
stocked_quantity = 1
reserved_quantity = 1
live ReservationItem rows = 2
available_quantity = 0
The invariant most people would monitor, reserved_quantity <= stocked_quantity, still holds. One is less than or equal to one. The store looks fine from every dashboard reading the inventory level, while two reservations sit against a single unit. Detecting the oversubscription requires counting ReservationItem rows, not reading the counter.
The damage outlives the reservations, too. Deleting both rows decrements the counter once per deletion against a single increment. In repeated reproduction, reserved_quantity went negative with no live reservation rows remaining. That drift is permanent as far as the module's public API goes: the reporter found no supported Inventory Module method for reconciling reserved_quantity, and updating the inventory level doesn't accept the field.
The shipped flows lock; your custom code probably doesn't
The report is explicit about scope, and so should you be. The standard cart-completion flow and the admin reservation flow both wrap reservation creation in the Locking Module keyed on inventory_item_id. Those flows were not observed overselling. What's at issue is the safety contract of the service method itself when application code calls it directly — and the method looks entirely self-contained, which is exactly how you end up calling it without a lock.
Wrapping the same call in the official Locking Module closes it:
await locking.execute([inventory_item_id], async () => {
return await inventoryModuleService.createReservationItems(...)
})
With the lock: 0 oversubscriptions in 50 runs.
This matters most on migrations. When you move off Magento 2 or a legacy custom platform, the reservation logic is one of the pieces most likely to get rewritten — a headless checkout, a POS bridge, an ERP sync, a bulk allocation job for preorders. Every one of those is a place where somebody calls the inventory service directly because it reads like a safe primitive. It isn't, unless you bring your own lock. We flag this specifically during Magento 2 to Medusa work, because Magento's own inventory reservation model handles this at the database level and the assumption travels with the team.
A related batch bug is fixed; this one is a different mechanism
Don't confuse this with #16502, which reports over-reservation from multiple entries for the same (inventory_item_id, location_id) inside a single createReservationItems batch, each validated against the same undecremented available_quantity. That one is deterministic and involves no concurrency. PR #16505 aggregates demand per key within a batch — a real fix for that shape, but it introduces no locking or row-level protection, so separate concurrent calls are unaffected by it.
The two also fail differently on the counter. In #16502, reserved_quantity exceeds stocked_quantity, which is at least visible. Here the increment is lost and the counter reads lower than reality. If you patched for the batch case and moved on, you have not addressed this.
Check whether your store has already drifted
Before mitigating, find out if you've been bitten. Compare live reservation rows against the level counter per inventory item and location:
SELECT il.inventory_item_id,
il.location_id,
il.stocked_quantity,
il.reserved_quantity,
COALESCE(SUM(ri.quantity), 0) AS reserved_from_rows,
COUNT(ri.id) AS reservation_rows
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.inventory_item_id, il.location_id,
il.stocked_quantity, il.reserved_quantity
HAVING il.reserved_quantity <> COALESCE(SUM(ri.quantity), 0)
OR COALESCE(SUM(ri.quantity), 0) > il.stocked_quantity
OR il.reserved_quantity < 0;
Verify the table and column names against your own schema before trusting the output. Any row returned is drift: the counter disagrees with the rows, reservations exceed stock, or the counter has gone negative. Run it on a schedule and alert on non-zero results. Negative reserved_quantity with no live rows is the signature of the delete-side decrement described above.
Close the hole at the database, not in application logic
Three layers, in order of how much you should trust them.
Serialize per inventory item. Wrap every direct call to createReservationItems — and every other read-modify-write against inventory levels — in locking.execute([inventory_item_id], ...), matching what the shipped workflows already do. This is the immediate, in-framework mitigation. Audit your codebase for direct Inventory Module calls; anything outside a locked workflow is suspect.
Make the update conditional at the row. The durable fix is an atomic guarded write: a single UPDATE ... SET reserved_quantity = reserved_quantity + :qty WHERE ... AND stocked_quantity - reserved_quantity >= :qty, treating zero affected rows as "not enough stock" rather than checking availability in a prior statement. Version columns with optimistic-concurrency retries work too. The principle either way is that the availability check and the write must be one statement the database adjudicates, not two the application coordinates.
Reconcile on a schedule. Since there's no supported API to recompute reserved_quantity from reservation rows, treat the monitoring query above as a correction job as well as an alert. Recomputing from ReservationItem rows is the only source of truth available.
The open API-contract question in the issue is worth watching: whether callers are expected to hold the lock, whether the service method should be safe on its own, or whether some other primitive is intended. Until that's answered upstream, write your code as if locking is your responsibility.
What to do this week
Run the drift query against production today — it's read-only and takes seconds. Then grep for direct createReservationItems and inventory-level writes outside of Medusa's shipped workflows, and wrap each one in the Locking Module. Schedule the query as a recurring check so drift surfaces before a customer finds it.
If you're mid-replatform and building custom checkout, allocation, or ERP-sync paths on Medusa, this is the class of bug that shows up on your first real traffic spike and not before. Talk to us about an inventory-concurrency review, or read how we approach platform migrations generally — the reservation layer is where we spend a disproportionate amount of the review budget, for exactly this reason.
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