DEV Community

Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on • Originally published at kolachitech.com

How Race Conditions Silently Break Shopify Orders (And How to Fix Them)

Flash sales. Product drops. Viral moments.

They're what every Shopify merchant dreams of — and exactly when race conditions turn that dream into a support ticket nightmare.

Let me break down what's actually happening, why Shopify's built-in protections aren't enough, and the specific patterns that fix it.

What Is a Race Condition in Shopify?
A race condition happens when two or more processes read and write the same resource simultaneously, and the result depends on which one finishes first.

In order processing, the classic scenario:

  1. Customer A checks stock → 1 unit available → proceeds to checkout
  2. Customer B checks stock → 1 unit available → proceeds to checkout
  3. Customer A completes purchase → inventory = 0
  4. Customer B completes purchase → inventory = -1

Both reads happened before either write completed. That gap between read and write is where race conditions live.

Why Shopify's Defaults Aren't Enough

Shopify has built-in inventory tracking and "deny checkout when out of stock" settings. For basic stores, that's fine.

It breaks down the moment you add:

  • Third-party fulfillment apps that write inventory via API
  • Custom order processing logic in private apps
  • Webhooks firing to external systems that write back to Shopify
  • Multi-location inventory with allocation logic
  • Headless storefronts (Hydrogen or custom) with custom cart logic

Once any of these exist, you are responsible for managing concurrency in your own code.

Fix #1: Use Atomic GraphQL Mutations

The Shopify GraphQL API's inventoryAdjustQuantities mutation applies inventory changes atomically. Instead of the dangerous read-calculate-write pattern:

❌ Read stock: 5
Calculate: 5 - 1 = 4
Write: 4
(Another process can read 5 between your read and write)

You send a delta:

mutation {
  inventoryAdjustQuantities(input: {
    reason: "correction"
    name: "available"
    changes: [{
      inventoryItemId: "gid://shopify/InventoryItem/12345"
      locationId: "gid://shopify/Location/67890"
      delta: -1
    }]
  }) {
    inventoryAdjustmentGroup {
      createdAt
      changes {
        name
        delta
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Shopify applies the delta at the database level. No read-before-write gap.

Fix #2: Idempotent Webhook Handlers

Shopify retries webhook delivery up to 19 times if your endpoint doesn't return a 200 OK within 5 seconds.

Without idempotency, one real order can trigger:

  • 1. Multiple fulfillment requests
  • 2. Duplicate customer notifications
  • 3. Repeated inventory deductions

Every Shopify webhook payload includes an X-Shopify-Webhook-Id header. Here's the pattern:

async function handleOrderWebhook(req, res) {
  const webhookId = req.headers['x-shopify-webhook-id'];

  // 1. Check if we've already processed this webhook
  const existing = await db.webhookLog.findOne({ webhookId });

  if (existing) {
    // Already processed — return 200 and stop here
    return res.status(200).send('Already processed');
  }

  // 2. Record this webhook BEFORE processing
  await db.webhookLog.create({ webhookId, processedAt: new Date() });

  // 3. Now process your order logic safely
  await processOrder(req.body);

  return res.status(200).send('OK');
}
Enter fullscreen mode Exit fullscreen mode

This ensures each logical event is processed exactly once, regardless of how many times Shopify retries.

Fix #3: Distributed Locking with Redis

For flash sales or any scenario with tight inventory, pessimistic locking prevents two processes from modifying the same resource simultaneously.

Redis SET NX EX (set if not exists, with expiry) is the standard pattern:

const redis = require('ioredis');
const client = new redis();

async function acquireLock(resourceId, ttlSeconds = 10) {
  const lockKey = `lock:product:${resourceId}`;
  const lockValue = `process-${Date.now()}-${Math.random()}`;

  // NX = only set if not exists, EX = expire after ttl seconds
  const result = await client.set(lockKey, lockValue, 'NX', 'EX', ttlSeconds);

  if (result === 'OK') {
    return lockValue; // Lock acquired
  }
  return null; // Lock already held by another process
}

async function releaseLock(resourceId, lockValue) {
  const lockKey = `lock:product:${resourceId}`;
  const current = await client.get(lockKey);

  // Only release if we own the lock
  if (current === lockValue) {
    await client.del(lockKey);
  }
}

// Usage in your order processing logic
async function processOrderWithLock(productId, orderId) {
  const lock = await acquireLock(productId);

  if (!lock) {
    throw new Error('Could not acquire lock — try again');
  }

  try {
    // Safe to read and write inventory here
    await updateInventory(productId, -1);
    await createFulfillment(orderId);
  } finally {
    await releaseLock(productId, lock);
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix #4: Queue-Based Order Processing

The most robust solution: eliminate the race condition entirely by serializing writes through a queue.

Webhook Arrives

Push to Queue (Redis / SQS / RabbitMQ)

Worker picks next job

Acquire lock on resource

Process order logic

Release lock → Acknowledge job

With a queue, you also get:

  • Rate limiting writes to the Shopify API (staying within API limits)
  • Safe retries on failures without duplicating successful operations
  • Independent scaling of workers vs API layer

Testing for Race Conditions (Before Production Finds Them)
Concurrent request simulation:
# k6 load test — 100 virtual users hitting checkout simultaneously
k6 run --vus 100 --duration 10s checkout-test.js

Chaos test: Deliberately delay your webhook endpoint to force retries. Count how many times your processing logic runs per order.

Database audit: Log every inventory write with the associated order ID. Duplicate entries for the same order ID = confirmed race condition.

Negative inventory monitoring:

// Run this on a cron job
async function checkNegativeInventory() {
  const products = await shopify.product.list({ limit: 250 });
  const oversold = products.filter(p =>
    p.variants.some(v => v.inventory_quantity < 0)
  );

  if (oversold.length > 0) {
    await alertSlack(`⚠️ Negative inventory detected: ${oversold.map(p => p.title).join(', ')}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Where to Start

Don't try to implement everything at once. Here's the priority order:

  1. Idempotent webhook handlers — highest impact, lowest complexity
  2. Atomic GraphQL mutations for inventory writes — swap out read-write patterns
  3. Distributed locking — for any custom inventory logic
  4. Queue-based processing — as order volume grows

Race conditions aren't a Shopify problem. They're a distributed systems problem that shows up in Shopify stores. The good news is the fixes are well-understood — they just have to be deliberately implemented.

Full guide with architecture patterns and a checklist: Race conditions Shopify Orders

Top comments (0)