Real-Time Stock Visibility Across Multiple Warehouses: The Ecommerce Fulfilment Challenge

Why do ecommerce businesses still oversell despite having 'inventory management'? Learn how real-time stock synchronisation across warehouses, channels, and fulfilment states prevents stockouts and overselling.

inventory-managementecommercewarehouse-managementstock-visibilitymulti-channel

Real-Time Stock Visibility Across Multiple Warehouses

It’s 2:47 PM on a Friday. Your Shopify store just sold the last unit of your best-selling product. Except it didn’t—because your Amazon listing still shows 3 in stock, and by 2:52 PM you’ve oversold twice. Now you’re choosing between cancelled orders, angry customers, or expensive air freight from your supplier.

This scenario plays out thousands of times daily across ecommerce businesses. Despite investing in “inventory management systems,” stock visibility remains a constant pain point. The problem isn’t lack of technology—it’s the architecture of that technology.

The Visibility Gap: Why Traditional Systems Fail

Most inventory management systems were designed for a simpler era:

  • Single warehouse
  • Single sales channel
  • Batch updates (nightly sync)
  • Manual counts as the source of truth

Modern ecommerce operates differently:

  • Multiple warehouses (your own + 3PL partners)
  • Multiple sales channels (website, marketplaces, wholesale)
  • Continuous transactions (orders every minute)
  • Inventory in multiple states (available, reserved, in-transit, being picked)

Traditional systems can’t bridge this gap because they treat inventory as a static number rather than a dynamic state.

The Five States of Inventory

To achieve true visibility, you need to track inventory through its lifecycle:

State 1: On-Hand

Physical units in the warehouse. This is what you’d count if you walked the aisles.

On-Hand: 100 units at Warehouse A
         50 units at Warehouse B
Total On-Hand: 150 units

State 2: Allocated

Units committed to orders but not yet picked. These should not be sold again.

On-Hand: 150 units
Allocated:
  - Order #1234: 10 units (Warehouse A)
  - Order #1235: 5 units (Warehouse B)
Total Allocated: 15 units

State 3: Reserved

Units held for non-order purposes: pending transfers, promotional holds, quality review.

Reserved:
  - Transfer to Warehouse B: 20 units
  - Quality Hold: 5 units
Total Reserved: 25 units

State 4: In-Transit

Units being received or transferred. These exist but aren’t yet available.

In-Transit:
  - PO #789 arriving tomorrow: 200 units
  - Transfer from Warehouse C: 30 units
Total In-Transit: 230 units

State 5: Available-to-Promise (ATP)

The number you can actually sell right now:

ATP = On-Hand - Allocated - Reserved
ATP = 150 - 15 - 25 = 110 units

Most overselling happens because systems show On-Hand (150) instead of ATP (110).

The Multi-Warehouse Complexity

With multiple warehouses, each location has its own inventory states:

Warehouse A (Brisbane):
  On-Hand: 100
  Allocated: 10
  Reserved: 20
  ATP: 70

Warehouse B (Sydney):
  On-Hand: 50
  Allocated: 5
  Reserved: 5
  ATP: 40

Company Total:
  On-Hand: 150
  ATP: 110

But ATP isn’t simply additive when selling across channels. Consider:

  • A Sydney customer should see 40 units available (ship from nearest warehouse)
  • A Brisbane customer should see 70 units available
  • Nationwide availability might be 110 units (either warehouse can ship)

Your sales channels need location-aware ATP, not just a total.

The Channel Synchronisation Problem

Each sales channel has its own inventory number:

Your System (source of truth): ATP = 110

Channel Inventory Records:
- Shopify: 112 (stale by 2 sales)
- Amazon: 115 (stale by 5 sales)
- eBay: 108 (actually accurate)
- Wholesale Portal: 110 (accurate)

The gap between your system and channel records is the sync lag. During high-velocity periods, this lag causes overselling.

The Sync Lag Math

If you sell 10 units per hour and sync every 15 minutes:

  • Maximum drift: 2.5 units between syncs
  • During a flash sale (100 units/hour): 25 units drift

That’s 25 potential oversells in 15 minutes.

Real-Time Architecture: Event-Driven Inventory

The solution is replacing batch syncs with event-driven updates:

Stock Movement Event → Event Bus → Channel Update
                                 → ATP Recalculation
                                 → Dashboard Update
                                 → Alert Check

Every inventory-affecting action publishes an event:

// Events that affect inventory state
{
  type: 'ORDER_PLACED',
  sku: 'WIDGET-001',
  quantity: 5,
  warehouse: 'warehouse_a',
  timestamp: '2026-01-26T14:47:00Z'
}

{
  type: 'ORDER_PICKED',
  sku: 'WIDGET-001',
  quantity: 5,
  warehouse: 'warehouse_a',
  timestamp: '2026-01-26T15:02:00Z'
}

{
  type: 'STOCK_RECEIVED',
  sku: 'WIDGET-001',
  quantity: 200,
  warehouse: 'warehouse_a',
  purchaseOrder: 'PO-789',
  timestamp: '2026-01-26T16:30:00Z'
}

Firestore Real-Time Implementation

Using Firestore’s real-time listeners, channel adapters receive instant updates:

// ATP document structure
// /businessdata/{clientId}/atp/{sku}
{
  sku: 'WIDGET-001',
  totalATP: 110,
  byWarehouse: {
    'warehouse_a': 70,
    'warehouse_b': 40
  },
  byChannel: {
    'shopify': 110,      // Can ship from any warehouse
    'amazon_au': 40,     // Only Sydney FBA eligible
    'wholesale': 110
  },
  lastUpdated: Timestamp,
  pendingInbound: 200,
  nextInboundDate: '2026-01-27'
}

Channel adapters listen for changes:

// Shopify channel adapter
db.collection('businessdata/client_a/atp')
  .onSnapshot((snapshot) => {
    snapshot.docChanges().forEach(async (change) => {
      const atpData = change.doc.data();
      await updateShopifyInventory(atpData.sku, atpData.byChannel.shopify);
    });
  });

Channel updates happen within seconds of stock movements, not minutes or hours.

The Allocation Race Condition

Even with real-time sync, a race condition exists:

T+0ms: ATP = 1 unit
T+0ms: Customer A views product page, sees "1 in stock"
T+0ms: Customer B views product page, sees "1 in stock"
T+50ms: Customer A adds to cart
T+100ms: Customer B adds to cart
T+200ms: Customer A completes checkout → ATP = 0
T+250ms: Customer B completes checkout → OVERSOLD

Both customers saw accurate inventory, but the 200ms between decisions allowed an oversell.

Solution: Soft Allocation on Cart Add

Reserve inventory when added to cart, not at checkout:

async function addToCart(userId, sku, quantity) {
  const result = await db.runTransaction(async (transaction) => {
    const atpDoc = await transaction.get(atpRef);
    const currentATP = atpDoc.data().totalATP;

    if (currentATP < quantity) {
      throw new Error('Insufficient stock');
    }

    // Create soft allocation
    transaction.set(softAllocationRef, {
      userId,
      sku,
      quantity,
      expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 min hold
      status: 'held'
    });

    // Reduce ATP
    transaction.update(atpRef, {
      totalATP: currentATP - quantity,
      softAllocated: FieldValue.increment(quantity)
    });

    return { success: true };
  });

  return result;
}

Soft allocations expire if checkout isn’t completed, returning stock to ATP.

Multi-Warehouse Fulfilment Logic

When ATP exists across multiple warehouses, order routing becomes complex:

Strategy 1: Nearest Warehouse

Ship from the closest warehouse to the customer:

function selectWarehouse(order, warehouseATPs) {
  const customerZone = getDeliveryZone(order.shippingAddress);

  // Filter warehouses with sufficient stock
  const viable = warehouseATPs
    .filter(w => w.atp >= order.quantity)
    .map(w => ({
      ...w,
      distance: calculateDistance(w.location, customerZone)
    }))
    .sort((a, b) => a.distance - b.distance);

  return viable[0] || null;
}

Strategy 2: Inventory Balancing

Distribute orders to balance stock levels:

function selectWarehouse(order, warehouseATPs) {
  const viable = warehouseATPs.filter(w => w.atp >= order.quantity);

  // Prefer warehouse with highest stock to balance inventory
  return viable.sort((a, b) => b.atp - a.atp)[0] || null;
}

Strategy 3: Cost Optimisation

Factor in shipping costs:

async function selectWarehouse(order, warehouseATPs) {
  const options = await Promise.all(
    warehouseATPs
      .filter(w => w.atp >= order.quantity)
      .map(async (w) => {
        const shippingCost = await getShippingQuote(w, order.shippingAddress);
        return { ...w, shippingCost };
      })
  );

  return options.sort((a, b) => a.shippingCost - b.shippingCost)[0] || null;
}

Strategy 4: Split Shipment

When no single warehouse can fulfil the order:

function planSplitShipment(order, warehouseATPs) {
  let remaining = order.quantity;
  const shipments = [];

  // Sort by ATP descending
  const sorted = [...warehouseATPs].sort((a, b) => b.atp - a.atp);

  for (const warehouse of sorted) {
    if (remaining <= 0) break;

    const shipQty = Math.min(warehouse.atp, remaining);
    if (shipQty > 0) {
      shipments.push({
        warehouse: warehouse.id,
        quantity: shipQty
      });
      remaining -= shipQty;
    }
  }

  if (remaining > 0) {
    return { success: false, shortfall: remaining };
  }

  return { success: true, shipments };
}

Channel-Specific ATP Rules

Different channels need different ATP calculations:

Amazon FBA

Only show stock physically in Amazon’s warehouses:

const amazonATP = warehouseATPs
  .filter(w => w.type === 'amazon_fba')
  .reduce((sum, w) => sum + w.atp, 0);

Marketplace with Long Lead Time

Include in-transit stock for channels that accept longer delivery windows:

const wholesaleATP = totalATP + pendingInbound.filter(po =>
  po.expectedDate <= addDays(today, 7)
).reduce((sum, po) => sum + po.quantity, 0);

Pre-Order Channel

Show future inventory for pre-order campaigns:

const preorderATP = pendingInbound
  .filter(po => po.status === 'confirmed')
  .reduce((sum, po) => sum + po.quantity, 0);

Low Stock Alerts and Reorder Points

Real-time visibility enables proactive inventory management:

// Firestore trigger on ATP changes
exports.checkReorderPoint = functions.firestore
  .document('businessdata/{clientId}/atp/{sku}')
  .onUpdate(async (change, context) => {
    const newData = change.after.data();
    const sku = context.params.sku;
    const clientId = context.params.clientId;

    // Get reorder configuration
    const config = await getReorderConfig(clientId, sku);

    if (newData.totalATP <= config.reorderPoint) {
      await createReorderAlert({
        clientId,
        sku,
        currentATP: newData.totalATP,
        reorderPoint: config.reorderPoint,
        suggestedQuantity: config.reorderQuantity,
        severity: newData.totalATP <= config.safetyStock ? 'critical' : 'warning'
      });
    }
  });

Audit Trail for Stock Movements

Every ATP change must be traceable:

// Movement log entry
{
  timestamp: '2026-01-26T14:47:00Z',
  sku: 'WIDGET-001',
  warehouse: 'warehouse_a',
  movementType: 'ORDER_ALLOCATION',
  quantityChange: -5,
  atpBefore: 75,
  atpAfter: 70,
  reference: {
    type: 'order',
    id: 'ORD-12345'
  },
  userId: 'system', // or actual user for manual adjustments
  metadata: {
    orderChannel: 'shopify',
    customerId: 'CUST-789'
  }
}

This audit trail allows you to:

  1. Investigate discrepancies: “Why did ATP drop from 75 to 70?”
  2. Reconstruct historical state: “What was ATP at 2 PM yesterday?”
  3. Identify patterns: “Which channel drives the most volatility?”

Implementation Checklist

Phase 1: Foundation

  • Implement ATP calculation (On-Hand - Allocated - Reserved)
  • Create real-time ATP document per SKU
  • Set up Firestore listeners for stock movements
  • Build audit trail logging

Phase 2: Multi-Warehouse

  • Track ATP per warehouse
  • Implement warehouse selection strategies
  • Handle split shipment scenarios
  • Create cross-warehouse transfer workflows

Phase 3: Channel Integration

  • Build channel adapters with real-time listeners
  • Implement channel-specific ATP rules
  • Add soft allocation on cart add
  • Create reconciliation for channel sync failures

Phase 4: Intelligence

  • Set up reorder point monitoring
  • Build velocity tracking (sales rate per SKU/warehouse)
  • Implement demand forecasting
  • Create stock distribution recommendations

Measuring Success

Track these metrics to validate your real-time visibility implementation:

MetricBeforeTarget
Oversell rate2.3% of ordersUnder 0.1%
Channel sync lag15 minutesUnder 30 seconds
Stock discrepancy rate4% of SKUsUnder 0.5%
Time to detect stockout4 hoursUnder 1 minute
Order cancellation (stock)1.8%Under 0.2%

Conclusion

Real-time stock visibility isn’t a nice-to-have—it’s table stakes for modern ecommerce. The cost of overselling (refunds, reputation damage, marketplace penalties) far exceeds the investment in proper inventory architecture.

The key insights:

  1. ATP, not On-Hand: Sell what’s actually available, not what’s physically present.
  2. Event-driven, not batch: Push changes instantly, don’t wait for sync cycles.
  3. Location-aware: Different customers and channels need different ATP views.
  4. Allocation at cart: Reserve stock early to prevent race conditions.
  5. Complete audit trail: Every movement logged for investigation and compliance.

The technology exists. Firestore’s real-time capabilities, combined with event-driven architecture, can deliver sub-second stock visibility across warehouses and channels. The question is whether you’ll implement it before your next oversell becomes a viral customer complaint.


Need real-time inventory visibility across warehouses and channels? See how EQUOS delivers sub-second stock synchronisation for multi-location ecommerce operations.