If you have ever built a Shopify integration and started seeing weird inventory counts, duplicate orders, or fulfillments firing before payment confirmation — welcome to the club.
The culprit is almost always the same: out-of-order webhook delivery.
Shopify does not guarantee the order in which webhook events arrive. That one sentence should be in every Shopify integration tutorial. It rarely is.
Let me break down why this happens and how to actually fix it.
Why Events Arrive Out of Order
Each webhook Shopify fires is an independent HTTP POST. No coordination. No sequence guarantee.
Here is what causes ordering to break:
- Network latency — two requests take different paths, arrive in reverse
- Retry logic — a failed event retried 30 minutes later arrives after newer events
- Parallel workers — your consumer processes two events simultaneously; lighter one finishes first
- FIFO queue trade-offs — standard queues (SQS, etc.) don't guarantee strict ordering
So you subscribe to orders/create and orders/updated. The update can genuinely arrive first. Your handler looks up the order, finds nothing, and either errors or skips. The order never enters your system.
The Damage This Causes
Here are the real scenarios I see teams hit:
1. Order Updated Before Created
Handler can't find the order. Skips silently. Order is now invisible to your system.
2. Fulfillment Before Payment
Your 3PL integration fires a pick request for an unpaid order. Policy violation at minimum. Financial exposure at worst.
3. Double Inventory Decrement
Flash sale. Rapid-fire inventory_levels/update events. Parallel consumers. Same decrement applies twice. Stock goes negative.
4. Cancellation Before Creation
orders/cancelled arrives. No record found. Discarded. Then orders/create arrives and creates an active order with no cancelled state.
The Fixes (Layered Approach)
No single fix solves this. You layer strategies based on your risk tolerance.
Fix 1: Timestamp Comparison
Before applying any event, compare its updated_at against your stored record:
if (incomingEvent.updated_at <= storedRecord.updated_at) {
console.log('Stale event, discarding');
return;
}
applyUpdate(incomingEvent);
Simple. Works well for orders/updated and inventory_levels/update.
Fix 2: Idempotent Handlers
Every handler must be safe to run twice. Use the X-Shopify-Webhook-Id header to track processed events:
const webhookId = req.headers['x-shopify-webhook-id'];
const alreadyProcessed = await db.webhookLog.findOne({ webhookId });
if (alreadyProcessed) return res.sendStatus(200); // acknowledge but skip
await db.webhookLog.create({ webhookId });
await processEvent(payload);
Always return 200 even when skipping. Shopify retries anything that isn't a 2xx.
Fix 3: State Machine for Orders
Model your order lifecycle explicitly. Reject invalid transitions:
const validTransitions = {
null: ['created'],
created: ['paid', 'cancelled'],
paid: ['fulfilled', 'refunded', 'cancelled'],
fulfilled: ['refunded'],
cancelled: [],
};
function canTransition(currentState, newState) {
return validTransitions[currentState]?.includes(newState) ?? false;
}
If orders/fulfilled arrives and your order is still in created state, that is an invalid transition. Route it to a dead letter queue, not the bin.
Fix 4: FIFO Queue Per Resource ID
Route all webhook events into a FIFO queue. Use the resource ID (order ID, product ID) as the partition key.
Shopify --> Webhook endpoint (ACK immediately)
--> SQS FIFO Queue (MessageGroupId = order_id)
--> Consumer (processes one event per order at a time)
This guarantees events for the same order process sequentially, not in parallel.
Fix 5: Fetch Before Acting
For critical operations, don't trust the payload alone. Fetch current state from the Shopify API before making changes:
async function handleFulfillment(payload) {
const order = await shopify.order.get(payload.id);
if (order.financial_status !== 'paid') {
console.warn('Order not paid, skipping fulfillment');
return;
}
await fulfillmentService.process(order);
}
Costs one extra API call. Eliminates an entire class of stale-state bugs.
Fix 6: Dead Letter Queue
Never silently discard events you can't process. Route them to a DLQ:
try {
await processWebhook(event);
} catch (err) {
if (err.type === 'UNPROCESSABLE') {
await dlq.send(event);
} else {
throw err; // let the queue retry
}
}
Review DLQ items daily. Most will be ordering artifacts you can replay manually.
Fix 7: Reconciliation Job
No ordering strategy is perfect. Run a scheduled job that compares your local state against Shopify's API:
// Runs every 30 minutes
async function reconcile() {
const recentOrders = await shopify.order.list({ updated_at_min: thirtyMinutesAgo });
for (const order of recentOrders) {
const localOrder = await db.orders.findOne({ shopifyId: order.id });
if (!localOrder || localOrder.status !== order.financial_status) {
await syncOrder(order);
}
}
}
This is your safety net. It catches everything the real-time system misses.
The Full Architecture
Shopify
--> Webhook Endpoint (ACK < 5s, no processing)
--> FIFO Message Queue (partitioned by resource ID)
--> Consumer Worker
--> Dedup check (X-Shopify-Webhook-Id)
--> Timestamp comparison (discard stale)
--> State machine validation
--> Fetch current state (for critical ops)
--> Apply update
--> Dead Letter Queue (unprocessable events)
--> Reconciliation Job (every 30 min)
This handles duplicate events, out-of-order events, lost events, and stale overwrites.
Quick Reference
| Problem | Fix |
|---|---|
| Update arrives before create | State machine + DLQ for invalid transitions |
| Duplicate events | Idempotency via X-Shopify-Webhook-Id |
| Stale overwrites | Timestamp comparison before applying |
| Parallel worker conflicts | FIFO queue per resource ID |
| Unresolvable ordering | Dead letter queue + manual review |
| Missed events | Reconciliation job |
TL;DR
- Shopify does not guarantee webhook event order
- Network latency, retries, and parallel workers all cause out-of-order delivery
- Layer your defenses: timestamps, idempotency, state machines, FIFO queues, DLQ, reconciliation
- Always acknowledge webhooks immediately and process asynchronously
- Never silently discard events you can't handle
Build for out-of-order delivery from day one and you will save yourself a lot of production debugging.
Have you hit webhook ordering bugs in production? Drop your war story in the comments.
Top comments (0)