DEV Community

Nventory
Nventory

Posted on

Why Your Inventory Sync Breaks Under Load And the Architecture That Fixes It

Most cloud inventory systems are built the same way: a cron job polls each channel's API on a schedule, fetches new orders, updates stock counts, and repeats. It works at 50 orders a day. At 500 it produces race conditions that cause overselling nobody notices until a customer complains.

The fix is architectural. Here's the problem in precise terms — and the event-driven pattern that solves it.

The race condition in one scenario

You have 1 unit of SKU-X. At 11:47:03, a customer buys it on Shopify. At 11:47:04, a different customer buys it on Amazon. Your polling job last ran at 11:45. It runs again at 12:00.

In that 13-minute window, both orders confirm. Both customers get confirmation emails. You have 1 unit and 2 confirmed orders.

The system didn't throw an error. It worked exactly as designed. The design is wrong.

Why polling can't fix this

The instinct is to shorten the polling interval. Poll every minute instead of every 15. But polling every minute means 60 API calls per channel per hour per SKU which hits rate limits before it solves the race condition. And even at 1-minute polling, a concurrent sale in the same minute still oversells.

The race condition isn't a polling frequency problem. It's a consistency model problem. Polling creates an eventually consistent system. For inventory, you need strong consistency - a guarantee that when a unit sells anywhere, it is unavailable everywhere before the next sale can confirm.

The webhook-driven architecture

Instead of your system asking each channel "anything new?" on a schedule, each channel tells your system the moment something happens.

javascript
// Register webhooks on channel connect
async function registerWebhooks(shop, accessToken) {
const webhooks = [
{ topic: 'orders/create', address: ${BASE_URL}/webhooks/shopify/orders },
{ topic: 'inventory_levels/update', address: ${BASE_URL}/webhooks/shopify/inventory }
];

for (const webhook of webhooks) {
await shopifyAPI.post('/webhooks.json', { webhook }, {
headers: { 'X-Shopify-Access-Token': accessToken }
});
}
}

// Webhook handler — respond fast, process async
app.post('/webhooks/shopify/orders', async (req, res) => {
// Always verify signature first
const hmac = req.headers['x-shopify-hmac-sha256'];
if (!verifyShopifySignature(hmac, req.rawBody, process.env.SHOPIFY_SECRET)) {
return res.status(401).send('Unauthorized');
}

// Respond immediately — Shopify retries if no response within 5s
res.status(200).send('OK');

// Process asynchronously
await orderQueue.add('process-order', {
channel: 'shopify',
order: req.body
});
});

Respond to the webhook immediately. Process asynchronously. If you do heavy processing synchronously, you'll miss the response window and the channel will retry producing duplicate events.

Atomic inventory decrements

Webhook-driven intake solves the awareness problem. Atomic decrements solve the consistency problem.

javascript
async function decrementInventory(sku, quantity, orderId) {
// Idempotency check webhook retries can produce duplicate events
const alreadyProcessed = await redis.get(order:${orderId});
if (alreadyProcessed) {
console.log(Order ${orderId} already processed — skipping);
return;
}

// Atomic decrement with availability check
// Using MongoDB findOneAndUpdate for atomicity
const result = await db.inventory.findOneAndUpdate(
{
sku,
availableQuantity: { $gte: quantity } // Only decrement if enough stock
},
{
$inc: { availableQuantity: -quantity },
$push: {
reservations: { orderId, quantity, reservedAt: new Date() }
}
},
{ returnDocument: 'after' }
);

if (!result) {
// Insufficient stock publish event for oversell prevention
await eventBus.publish('inventory.insufficient', { sku, quantity, orderId });
return;
}

// Mark order as processed (idempotency)
await redis.setex(order:${orderId}, 86400, 'processed');

// Propagate new stock level to all channels
await propagateStockUpdate(sku, result.availableQuantity);
}

The $gte: quantity check and the atomic findOneAndUpdate together prevent the race condition at the database level. Two concurrent requests for the last unit - the second one finds availableQuantity: 0 after the first decrements it, and correctly returns no result.

Propagating back to all channels

javascript
async function propagateStockUpdate(sku, newQuantity) {
const channelMappings = await db.inventoryMappings.find({ sku });

// Update all channels in parallel don't block on one channel's failure
const results = await Promise.allSettled(
channelMappings.map(async (mapping) => {
try {
await updateChannelInventory(mapping.channel, mapping.externalId, newQuantity);
} catch (err) {
// Failed updates go to retry queue — never dropped
await retryQueue.add('inventory-update', {
channel: mapping.channel,
sku,
newQuantity,
attempts: 0
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 }
});
}
})
);

return results;
}

Promise.allSettled ensures one channel failing doesn't block others. Exponential backoff retry means a temporary API outage doesn't permanently desync a channel's inventory.

The result

This is the architecture that proper inventory management software cloud based implements under the hood - webhook-driven intake, atomic decrements with idempotency, and parallel propagation with retry safety. The oversell window shrinks from 15 minutes to near zero. Concurrent orders for the same last unit are handled correctly at the database level. Channel failures are retried rather than dropped.

If you'd rather not build this yourself, Nventory ships this architecture across 30+ channels out of the box - free plan available.

Top comments (0)