Shopify Multi-location Inventory Race Condition Playbook

By Milan Dhameliya · · 5 min read

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:

  1. ERP pushes absolute quantity update.
  2. WMS pushes another update within seconds.
  3. Shopify accepts both because no conflict guard exists.
  4. 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:

  1. Compare-and-set writes for inventory mutations.
  2. Idempotent webhook consumers.
  3. 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

  1. Read current inventory state.
  2. Attempt CAS update.
  3. On compare mismatch, backoff jitter 100-300ms.
  4. Re-read and re-apply once.
  5. 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

SEO Checklist