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:
- Customer A checks stock → 1 unit available → proceeds to checkout
- Customer B checks stock → 1 unit available → proceeds to checkout
- Customer A completes purchase → inventory = 0
- 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
}
}
}
}
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');
}
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);
}
}
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(', ')}`);
}
}
Where to Start
Don't try to implement everything at once. Here's the priority order:
- Idempotent webhook handlers — highest impact, lowest complexity
- Atomic GraphQL mutations for inventory writes — swap out read-write patterns
- Distributed locking — for any custom inventory logic
- 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)