Selling through an outage: an offline POS queue with idempotent sync

How Inventra's till keeps selling when the network drops: an IndexedDB queue, clientRef idempotency keys backed by a unique partial index, and an honest look at negative stock.

DSDilawar Singh 6 min read 2 views
Selling through an outage: an offline POS queue with idempotent sync

A shop's internet connection tends to fail at the busiest moment, and a till that stops selling when it does is worse than no till. When we built the point-of-sale module in Inventra, our multi-tenant inventory and ERP platform on Next.js 15 and MongoDB, we wanted cashiers to keep ringing up sales through an outage without weakening the server-side stock and accounting. This article covers the offline queue, idempotent sync, and what happens when an offline sale turns out to have sold stock that was already gone.

What "offline" means here

  • Inventra is not a PWA. It has no service worker and no web manifest, so it cannot start up without a network. The till keeps working only when a page that was already open loses its connection.
  • Two flows tolerate an outage: the POS till and order entry in the field-sales app. The rest of the back office is online-only.
  • The ledger stays on the server. Stock levels, cost layers and journals change only inside server-side MongoDB transactions. The browser queues requests. It never holds the authoritative stock figures.

The client: an IndexedDB queue keyed by clientRef

The client side is lib/pos/offline.ts, a small module built on the idb library. It opens one database with two object stores (condensed):

interface PosDB extends DBSchema {
  catalog: { key: string; value: PosCatalog & { key: string } };
  queue: { key: string; value: { clientRef: string; tenant: string; endpoint: string; label: string;
    payload: unknown; createdAt: number; attempts: number; lastError?: string } };
}
dbp ??= openDB<PosDB>('inventra-pos', 1, {
  upgrade(d) {
    d.createObjectStore('catalog', { keyPath: 'key' });
    d.createObjectStore('queue', { keyPath: 'clientRef' });
  },
});

The catalog store caches each register's product catalogue: network first, cached copy when the fetch fails.

The queue store is keyed by clientRef. The till generates this value on the device, from the register number plus crypto.randomUUID().

Checkout always tries the network first. A sale is queued, with offline: true added, only when fetch throws, meaning no answer came back at all. The cashier sees "Saved offline" and moves on to the next customer.

Replaying the queue

The till syncs on the browser's online event and again every 30 seconds while navigator.onLine is true.

for (const item of await pendingSales(tenant)) {   // oldest first
  try {
    const res = await fetch(item.endpoint ?? `/api/v1/${tenant}/pos/sales`, { method: 'POST',
      headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item.payload) });
    if (res.ok) { await d.delete('queue', item.clientRef); synced++; }
    else if (res.status >= 400 && res.status < 500) {
      // Rejected by business rules (e.g. register closed, credit): keep it visible for the cashier.
      await d.put('queue', { ...item, attempts: item.attempts + 1, lastError: /* server message */ });
      failed++;
    } else { failed++; break; } // server trouble: try again later
  } catch { failed++; break; } // still offline
}

(condensed from syncQueue)

The loop handles three outcomes:

  • Success: the item is removed from the queue.
  • 4xx (a business-rule rejection): the item stays in the queue with its error recorded, and the loop moves on to the next item.
  • 5xx or a network failure: the loop stops and waits for the next trigger.

Idempotency on the server

A queue that retries is safe only if the server can tell a retry from a new sale. completeSale in lib/services/pos.ts checks for that before it does any work:

const dup = await Order.findOne({ tenantId: ctx.tenantId, clientRef: input.clientRef }).lean();
if (dup) {
  const inv = dup.invoiceId ? await Invoice.findOne({ tenantId: ctx.tenantId, _id: dup.invoiceId }).lean() : null;
  return { orderId: String(dup._id), orderNumber: dup.orderNumber, /* ...stored totals */ duplicate: true, shortfall: 0 };
}

(condensed)

This covers the most common failure: the server commits the sale but the response is lost, so the till queues a sale that actually succeeded. On the next sync the server returns duplicate: true, the route skips a second audit entry, and the till drops the item.

The findOne check alone cannot handle two replays of the same sale arriving at the same moment, because both can pass the check before either has saved. A unique partial index on Order covers that race:

OrderSchema.index({ tenantId: 1, clientRef: 1 },
  { unique: true, partialFilterExpression: { clientRef: { $type: 'string' } } });

Back-office orders have a null clientRef, so the partial filter leaves them out of the index. For POS sales, the order is inserted in the same transaction as the stock dispatch, the invoice and the journals. If a second insert hits the index, that entire transaction aborts and nothing from it is applied. The request returns a 5xx, the item stays queued, and the next pass finds the committed order and gets duplicate: true.

When the shelf was already empty

An offline till sells from a catalogue snapshot, not live stock, so it can sell units another channel already sold. We could reject a sale the customer has paid for and left with, or record it and flag the gap. We record it, but only for synced offline sales. completeSale passes allowNegative: input.offline to the inventory engine's dispatch (condensed):

try {
  allocations = await takeFromRows(tx, p, warehouseId, binId, input.qty, 'direct');
} catch (err) {
  if (!(err instanceof InsufficientStockError) || !input.allowNegative) throw err;
  // Offline sale synced late: take what exists, book the rest against the unbinned row (goes negative).
  const have = err.available;
  allocations = have > 0 ? await takeFromRows(tx, p, warehouseId, binId, have, 'direct') : [];
  shortfall = input.qty - have;
  await StockLevel.updateOne({ tenantId: tx.tenantId, productId: p._id, warehouseId, binId: null },
    { $inc: { quantityOnHand: -shortfall, quantityAvailable: -shortfall } },
    { upsert: true, session: tx.session });
}

Units with no remaining cost layer are costed at the product's standard cost price, so cost of goods sold (COGS) is still posted and the journal balances. The shortfall is flagged in three places:

  • the movement note, as NEGATIVE STOCK: n short at sync
  • the order notes
  • the response returned to the till

Online sales never take this path. For them, a shortfall is an ordinary InsufficientStockError.

The POS integration test syncs an offline sale of 4 units against 3 on hand and asserts a shortfall of 1, on-hand stock of -1 and a flagged movement. It also replays a sale with the same clientRef and checks that only one POS order exists.

Field sales reuses the queue

The queue is not specific to POS. The enqueue() function accepts any endpoint that dedupes by clientRef. The field-sales rep app uses it to queue orders to /api/v1/{tenant}/field/orders. That route rejects any request without a clientRef, and saveOrder runs the same duplicate lookup.

Field orders never take stock negative: they are reservations on the normal conditional-update path. If the server can't confirm one (credit, stock), it deletes the new draft, so a retry is evaluated fresh. Payment collection is disabled while offline.

Trade-offs

  • No cold start. Without a service worker, reloading the till while offline fails. Keep the tab open.
  • Prices are recalculated at sync time. If the cached price was out of date and the tenders no longer cover the total, the sale is rejected with a 4xx and stays queued with its error recorded.
  • Split dating. The order timestamps, payments and COGS journal use the soldAt time recorded on the till. The invoice and its revenue journal are dated when the sale syncs. A sale that syncs after midnight can therefore show its revenue and its cost on different days.
  • Closed shifts accept late syncs from sales queued before the close.
  • Limited visibility of failed syncs. The till shows how many items are queued. It stores lastError per item, but there is no screen yet to resolve rejected items.
  • The queue lives in one browser profile. Clearing site data before a sync loses any queued sales.

Conclusion

The offline design is deliberately small: a durable client-side queue, an idempotency key created at the point of sale, a server-side lookup backed by a unique index, and one flagged exception to the rule against negative stock. Everything else stays transactional on the server. The Inventra case study puts this in context, the live demo is read-only with one-click demo logins, and the source is on our marketplace. If you need offline-tolerant workflows in your own product, talk to our SaaS engineering team.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles