DEV Community

Nventory
Nventory

Posted on

We evaluated 40+ inventory sync tools before building our own. Here's the architectural pattern that separates the good from the broken.

Before building Nventory we spent three months evaluating every inventory sync tool in the market.

Not as potential customers. As engineers trying to understand why the problem remained unsolved despite dozens of tools claiming to solve it.

The pattern we found was consistent and damning.

Almost every tool was built on the same broken foundation.

The polling problem

javascript
// What almost every inventory sync tool does under the hood
setInterval(async () => {
const stock = await getSourceOfTruth();
await syncToAllChannels(stock);
}, 15 * 60 * 1000); // every 15 minutes

// The math nobody shows you
const windowsPerDay = (24 * 60) / 15; // 96
const ordersPerWindow = 500 / windowsPerDay; // ~5.2 at normal velocity
const ordersPerWindowPeak = ordersPerWindow * 10; // ~52 during a flash sale

// 52 orders processed against potentially stale cross-channel stock
// per 15-minute window
// during a flash sale
// with no channel knowing what the others have sold
// Result: predictable, preventable oversells

96 windows per day where channels disagree about stock. Every marketing page says "real-time sync." Every settings panel says "sync every 15 minutes."

These are not the same thing.

Why this happens

Polling is the default architecture because it's simple, predictable, and easy to reason about. Build a cron job. Pull current state. Push to channels. Done.

The failure mode is invisible at low volume. A 15-minute sync interval at 20 orders per day means each window processes 0.2 orders on average. The probability of a cross-channel conflict is negligible.

At 500 orders per day during a flash sale - the same architecture processes 52 orders per window against stale data. The failure mode is no longer negligible. It's inevitable.

The tools that got built at low-volume assumptions never got rearchitected when the volume assumptions changed.

The five architectural failures we found

  1. No idempotency

javascript
// What happens when a webhook fires twice
// (which happens more than you think)

// Without idempotency
orderEventBus.on('order.confirmed', async (event) => {
await inventory.decrement(event.sku, event.qty);
// First delivery: stock goes from 10 to 9. Correct.
// Second delivery (retry): stock goes from 9 to 8. Wrong.
// Silent inventory corruption. Nobody notices until stocktake.
});

// With idempotency
orderEventBus.on('order.confirmed', async (event) => {
if (await idempotencyStore.exists(event.orderId)) return;
await inventory.decrement(event.sku, event.qty);
await idempotencyStore.mark(event.orderId);
// Retry handled safely. State correct.
});

  1. No optimistic locking

Two orders hitting the same last SKU from different channels simultaneously. Without locking, both decrement independently. Both succeed. One oversell.

javascript
// Without locking — race condition
const stock = await inventory.get(sku); // both reads return 1
await inventory.set(sku, stock - 1); // both writes set to 0
// Result: 2 orders fulfilled for 1 unit

// With optimistic locking
const result = await inventory.compareAndSwap(sku, {
expectedVersion: current.version,
newQty: current.qty - 1
});
if (!result.success) throw new ConcurrentUpdateError();
// One succeeds, one retries or fails safely

  1. Silent propagation failures

javascript
// What most tools do when a channel update fails
try {
await channel.updateInventory(sku, qty);
} catch (error) {
console.log('Update failed'); // logged and forgotten
// Channel now shows wrong stock
// Nobody knows until a seller asks why their eBay shows 10 units they don't have
}

// What you actually need
try {
await channel.updateInventory(sku, qty);
} catch (error) {
await deadLetterQueue.push({
channel: channel.id,
sku,
qty,
error: error.message,
retryAt: Date.now() + backoffMs(failureCount)
});
// Failure is captured, retried, and surfaced — never silently dropped
}

  1. No verification

javascript
// Trusting the API response
const response = await channel.setInventory(sku, qty);
if (response.status === 200) return true; // assumed correct

// The problem: WooCommerce returns 200 with silent validation errors
// eBay returns Ack: 'Success' with errors in a separate array
// Amazon queues updates that aren't immediately applied

// Verify every write
const response = await channel.setInventory(sku, qty);
const verification = await channel.getInventory(sku);
if (verification.qty !== qty) {
throw new InventoryMismatchError(sku, qty, verification.qty);
}

  1. No canonical data model

Every channel has a different data model. SKUs that are strings on Shopify are integers elsewhere. Variant attributes map differently across platforms. Without a normalisation layer, field mapping becomes the source of truth — and field mapping errors become inventory errors.

javascript
// Without canonical model — direct channel-to-channel sync
// Shopify variant → Amazon mapping → eBay mapping → WooCommerce mapping
// Each mapping is a potential error source
// Errors compound across the chain

// With canonical model
const internalProduct = normalise(shopifyProduct); // one normalisation
await Promise.all([
amazon.updateFromInternal(internalProduct),
ebay.updateFromInternal(internalProduct),
woocommerce.updateFromInternal(internalProduct)
]); // each channel maps from the same canonical source

What the correct architecture looks like

javascript
// Event-driven with all five failure modes addressed
orderEventBus.on('order.confirmed', async ({ sku, qty, channel, orderId }) => {
// 1. Idempotency
if (await idempotencyStore.exists(orderId)) return;

// 2. Optimistic locking
const result = await inventory.decrementWithLock(sku, qty);
if (!result.success) {
await oversellPrevention.handle({ sku, channel, orderId });
return;
}

// 3. Propagate with DLQ for failures
await Promise.allSettled(
connectedChannels
.filter(ch => ch.id !== channel)
.map(ch =>
ch.updateInventory(sku, result.newQty)
.catch(err => deadLetterQueue.push({ channel: ch.id, sku, err }))
)
);

// 4. Verify writes
const verifications = await Promise.all(
connectedChannels.map(ch => ch.getInventory(sku))
);
verifications.forEach(v => {
if (v.qty !== result.newQty) {
alerting.warn('Inventory mismatch detected', { sku, channel: v.channelId });
}
});

// 5. Audit trail
await auditLog.record({ sku, qty, channel, orderId, result, timestamp: Date.now() });

await idempotencyStore.mark(orderId);
});

Sync lag: milliseconds. Oversell probability: near zero. Failed propagations: captured and retried. Silent failures: impossible.

What we built

This is the architecture Nventory is built on event-driven sync across 40+ channels with idempotency, optimistic locking, DLQ propagation, write verification, and canonical data model throughout.

The five failure modes above aren't theoretical. We found all five in tools that sellers were actively using and paying for.

nventory.io — free forever
apps.shopify.com/nventory — Shopify App Store

Top comments (0)