Shopify Multi-location Inventory Race Condition Playbook
Prevent Shopify overselling from concurrent inventory writes with compare-and-set updates, idempotent webhooks, and reconciliation jobs.
Overselling in multi-location Shopify setups is usually a race condition, not a demand forecasting issue. Two systems write stock at the same time, and the final write wins with stale state.
Failure pattern in production
This is the sequence seen repeatedly in scaling stores:
- ERP pushes absolute quantity update.
- WMS pushes another update within seconds.
- Shopify accepts both because no conflict guard exists.
- Negative available or stale available quantity appears in one location.
Community reports show the symptom as "negative inventory after transfers" even when business logic appears correct.
Inventory architecture that prevents race collisions
Use three controls together:
- Compare-and-set writes for inventory mutations.
- Idempotent webhook consumers.
- Scheduled reconciliation jobs for drift detection.
Write inventory with compare-and-set protection
Shopify added CAS support for inventorySetQuantities using compareQuantity and, later, ignoreCompareQuantity + changeFromQuantity controls for explicit conflict handling.
mutation InventorySet($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup {
reason
changes {
name
delta
}
referenceDocumentUri
}
userErrors {
field
message
}
}
}
Variables:
{
"input": {
"name": "available",
"reason": "correction",
"referenceDocumentUri": "logistics://sync/2026-02-24T10:45:00Z",
"quantities": [
{
"inventoryItemId": "gid://shopify/InventoryItem/30322695",
"locationId": "gid://shopify/Location/124656943",
"quantity": 11,
"compareQuantity": 1
}
]
}
}
If compare values do not match current state, reject and retry from fresh read.
Make webhook processing idempotent
Webhook duplicates happen. Your consumer must ignore duplicates using X-Shopify-Event-Id.
// app/api/webhooks/inventory/route.ts
import { NextRequest, NextResponse } from "next/server";
const processedEvents = new Set<string>(); // replace with Redis/DB in production
export async function POST(req: NextRequest) {
const eventId = req.headers.get("x-shopify-event-id");
if (!eventId) return NextResponse.json({ ok: false }, { status: 400 });
if (processedEvents.has(eventId)) {
return NextResponse.json({ ok: true, duplicate: true });
}
const payload = await req.json();
// Apply business logic here with transaction-safe write.
// Avoid side effects before dedupe mark.
processedEvents.add(eventId);
return NextResponse.json({ ok: true, processed: payload?.id ?? null });
}
Use a durable store with TTL for event IDs, not memory, in production.
Add conflict-aware retry logic
Retry strategy
- Read current inventory state.
- Attempt CAS update.
- On compare mismatch, backoff jitter 100-300ms.
- Re-read and re-apply once.
- Escalate to reconciliation queue after second failure.
async function safeInventoryWrite(input: InventorySetQuantitiesInput) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
const result = await adminGraphql(INVENTORY_SET_MUTATION, { input });
const errors = result?.inventorySetQuantities?.userErrors ?? [];
if (!errors.length) return { ok: true };
const conflict = errors.some((e: { message?: string }) =>
String(e.message || "").toLowerCase().includes("compare")
);
if (!conflict || attempt === 2) {
return { ok: false, errors };
}
await new Promise((r) => setTimeout(r, 120 + Math.floor(Math.random() * 180)));
}
return { ok: false };
}
Reconcile inventory drift daily
Reconciliation query loop
Run a scheduled job to compare Shopify location-level inventory with ERP snapshots and flag variances.
query InventorySnapshot($cursor: String) {
inventoryItems(first: 100, after: $cursor) {
nodes {
id
sku
inventoryLevels(first: 20) {
nodes {
location {
id
name
}
quantities(names: ["available"]) {
name
quantity
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Don’t wait for support tickets to detect drift.
Technical Checklist
- Inventory writes use CAS (
compareQuantity) rather than blind updates. - Duplicate webhook handling keyed by
X-Shopify-Event-Idis implemented. - Conflict retries are bounded and observable.
- Reconciliation job compares Shopify and external inventory snapshots daily.
- Location transfer workflows are covered in QA with concurrent updates.
- Incident alerting exists for repeated compare conflicts.
SEO Checklist
- Out-of-stock states on indexed products are accurate, not stale from race conditions.
- Structured data availability reflects real purchasability.
- Avoid indexing pages with invalid availability messaging caused by stale inventory.
- Collection pages don’t surface sold-out products due to delayed sync.
- High-intent product pages maintain trust via consistent stock visibility.