Offline-First POS Architecture: IndexedDB, Service Workers, and Zero Transaction Loss
How Aetheria's offline-first POS survives complete network blackouts — IndexedDB catalog, multi-cart hold state, idempotent reconciliation, and pessimistic locking on reconnect.
Offline-First POS Architecture: IndexedDB, Service Workers, and Zero Transaction Loss
TL;DR: Aetheria POS doesn't just "cache the catalog" — it runs the full transaction engine offline: multi-cart hold, barcode scanning, tax calculation (ZATCA/VAT/GST), cash + tokenized card payments, customer lookup. Zero transaction loss under complete blackout. Sub-45ms barcode decode. Idempotent reconciliation with pessimistic bin locks on reconnect.
The Offline-First Philosophy
| Traditional "Offline Mode" | Aetheria Offline-First |
|---|---|
| Read-only catalog cache | Full transaction engine |
| Queue sales, sync later | Local commit + idempotent sync |
| Manual conflict resolution | Automated idempotent reconciliation |
| Single cart | Multi-cart hold (unlimited suspended) |
| No tax calc offline | Full ZATCA/VAT/GST engine local |
| No payments offline | Cash + tokenized EMV card capture |
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ POS Terminal (Android/Windows/iOS) │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ IndexedDB │ │ Service │ │ Tax Engine │ │
│ │ (Catalog + │◀─│ Worker │ │ (ZATCA/VAT/ │ │
│ │ Transactions)│ │ (Sync + │ │ GST) │ │
│ └──────────────┘ │ Lifecycle) │ └──────────────┘ │
│ ▲ └──────┬───────┘ ▲ │
│ │ │ │ │
│ ┌────┴───────────────────┴───────────────────┴────┐ │
│ │ POS Application (React/React Native) │ │
│ │ • Multi-Cart Hold State │ │
│ │ • Barcode Scanner (Camera/USB) │ │
│ │ • Tax Calculator (ZATCA/VAT/GST) │ │
│ │ • Payment: Cash + EMV Tokenized Card │ │
│ │ • Customer Lookup (Local IndexedDB Cache) │ │
│ │ • Idempotent Transaction Builder (UUID + Hash) │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────┴─────────┐
│ Network │
│ (Intermittent) │
└─────────┬─────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Aetheria Backend (Go Microservices) │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/pos/reconcile (Idempotent, UUID-keyed) │
│ → Pessimistic Bin Locks (SELECT FOR UPDATE) │
│ → Double-Entry Stock Ledger │
│ → Tax Invoice Generation (ZATCA/VAT/GST) │
│ → Payment Settlement (Cash/Card) │
└─────────────────────────────────────────────────────────────────┘
IndexedDB: The Local Transaction Store
Schema Design
// IndexedDB Stores (IndexedDB = browser/Node.js embedded DB)
const stores = {
// Product catalog (synced nightly + on-demand)
catalog: {
keyPath: 'sku',
indexes: ['category', 'barcode', 'name_ar', 'name_en'],
// ~50,000 SKUs = ~15MB
},
// Customer directory (synced daily + on-demand search)
customers: {
keyPath: 'customer_id',
indexes: ['phone', 'email', 'name_ar', 'name_en', 'loyalty_tier'],
// ~100,000 customers = ~25MB
},
// Active carts (multi-cart hold)
carts: {
keyPath: 'cart_id',
indexes: ['cashier_id', 'status', 'updated_at'],
// status: 'active' | 'suspended' | 'pending_sync' | 'synced'
},
// Line items per cart
cart_lines: {
keyPath: 'line_id',
indexes: ['cart_id', 'sku'],
},
// Payments (cash + tokenized card)
payments: {
keyPath: 'payment_id',
indexes: ['cart_id', 'method', 'status'],
// method: 'cash' | 'card_tokenized' | 'card_offline'
},
// Outbound transaction queue (idempotent keys)
outbox: {
keyPath: 'txn_id', // UUID v4
indexes: ['status', 'created_at', 'retry_count'],
// status: 'pending' | 'syncing' | 'synced' | 'conflict' | 'failed'
},
// Tax rules (ZATCA/VAT/GST) - synced weekly
tax_rules: {
keyPath: 'rule_id',
indexes: ['country', 'region', 'effective_from'],
},
};
Storage Estimates (Typical Retailer)
| Data | Size | Sync Frequency |
|---|---|---|
| Catalog (50K SKUs) | 15 MB | Nightly + on-demand |
| Customers (100K) | 25 MB | Daily + on-demand |
| Tax Rules (50) | 0.5 MB | Weekly |
| Active Carts (50) | 1 MB | Real-time |
| Outbox (1,000 txns) | 2 MB | Real-time |
| Total | ~44 MB | — |
Multi-Cart Hold State (The Cashier's Superpower)
State Machine
┌─────────┐ Scan/Edit ┌────────────┐ Hold ┌────────────┐
│ New │ ─────────────────▶ │ Active │ ───────────▶ │ Suspended │
│ Cart │ │ (Editing) │ │ (Held) │
└─────────┘ └────────────┘ └─────┬──────┘
│
Resume ──────────────┘
│
┌───────┴───────┐
│ │
Payment Complete Discard
(Sync) │
┌───────┴───────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Synced │ │ Discarded│
└──────────┘ └──────────┘
Hold Capacity
- Unlimited suspended carts per cashier
- Per-cashier limit: 50 active (configurable)
- Auto-suspend: 5 min inactivity → auto-hold
- Shift handoff: Suspended carts transferable to next cashier
Service Worker: The Sync Orchestrator
Lifecycle
// sw.js (Workbox-generated, customized)
self.addEventListener('sync', (event) => {
if (event.tag === 'pos-reconcile') {
event.waitUntil(reconcileOutbox());
}
});
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'catalog-refresh') {
event.waitUntil(refreshCatalog());
}
});
async function reconcileOutbox() {
const db = await openDB();
const pending = await db.getAllFromIndex('outbox', 'status', 'pending');
for (const txn of pending) {
try {
await syncTransaction(txn);
await db.put('outbox', { ...txn, status: 'synced', synced_at: Date.now() });
} catch (err) {
if (err.code === 'CONFLICT') {
await db.put('outbox', { ...txn, status: 'conflict', conflict_data: err.data });
} else if (txn.retry_count < 3) {
await db.put('outbox', { ...txn, retry_count: txn.retry_count + 1 });
} else {
await db.put('outbox', { ...txn, status: 'failed', error: err.message });
}
}
}
}
Connectivity Detection
// Online/offline detection with hysteresis
let isOnline = navigator.onLine;
let offlineSince = null;
window.addEventListener('online', () => {
isOnline = true;
offlineSince = null;
registration.sync.register('pos-reconcile');
});
window.addEventListener('offline', () => {
isOnline = false;
offlineSince = Date.now();
showOfflineBanner();
});
// Periodic background sync (when online)
if ('periodicSync' in registration) {
registration.periodicSync.register('catalog-refresh', {
minInterval: 24 * 60 * 60 * 1000, // 24 hours
});
}
Idempotent Reconciliation: The Core Protocol
Transaction UUID (The Idempotency Key)
{
"txn_id": "550e8400-e29b-41d4-a716-446655440000", // UUID v4
"terminal_id": "TERM-001",
"cashier_id": "CASH-042",
"cart": {
"cart_id": "cart-abc123",
"lines": [
{"sku": "ABC123", "qty": 2, "unit_price": 29.99, "tax_rate": 0.15}
],
"totals": {"subtotal": 59.98, "tax": 8.997, "total": 68.977}
},
"payments": [
{"method": "cash", "amount": 70.00, "change": 1.023}
],
"timestamp": "2026-03-15T14:32:10.123Z",
"hash": "sha256:abc123..." // Content hash for tamper detection
}
Server-Side Reconciliation (Go)
func (s *POSService) Reconcile(ctx context.Context, req *ReconcileRequest) (*ReconcileResponse, error) {
// 1. Idempotency check (prevent duplicate processing)
exists, err := s.txnExists(ctx, req.TxnID)
if err != nil { return nil, err }
if exists {
return &ReconcileResponse{Status: "already_processed", original_txn_id: req.TxnID}, nil
}
// 2. Validate hash (tamper detection)
if !verifyHash(req) {
return nil, ErrInvalidHash
}
// 3. Execute in single DB transaction with pessimistic locks
return s.db.Transaction(ctx, func(tx *sql.Tx) error {
// Lock bins in deterministic order (deadlock prevention)
binIDs := extractBinIDs(req.Cart.Lines)
if err := lockBinsInOrder(ctx, tx, binIDs); err != nil {
return err
}
// 4. Validate stock (pessimistic - locks held)
for _, line := range req.Cart.Lines {
available, err := getAvailableStock(ctx, tx, line.SKU, line.WarehouseID)
if err != nil { return err }
if available < line.Qty {
return &ConflictError{
Code: "INSUFFICIENT_STOCK",
SKU: line.SKU,
Requested: line.Qty,
Available: available,
}
}
}
// 5. Create double-entry stock movements
for _, line := range req.Cart.Lines {
if err := createStockMovement(ctx, tx, &StockMovement{
MovementID: uuid.New(),
BinID: line.BinID,
Type: "SALE",
Qty: -line.Qty,
RefID: req.TxnID,
RefType: "POS_TRANSACTION",
}); err != nil { return err }
}
// 6. Create financial entries (double-entry)
if err := createFinancialEntries(ctx, tx, req); err != nil {
return err
}
// 7. Generate tax invoice (ZATCA/VAT/GST)
invoice, err := generateTaxInvoice(ctx, tx, req)
if err != nil { return err }
// 8. Record transaction (idempotency key)
if err := recordTransaction(ctx, tx, req, invoice); err != nil {
return err
}
return nil
})
}
Conflict Resolution (When Stock Changed)
type ConflictError struct {
Code string
SKU string
Requested int
Available int
BinID string
ServerTxnID string // The transaction that beat us
}
// Client-side resolution:
func (c *POSClient) handleConflict(err *ConflictError) {
switch err.Code {
case "INSUFFICIENT_STOCK":
// Show cashier: "Only X available. Split? Partial? Cancel?"
// Options:
// 1. Partial fulfillment (sell available, backorder rest)
// 2. Substitute (suggest similar SKU)
// 3. Cancel line
// 4. Hold cart, check other terminal
}
}
Barcode Scanning: Sub-45ms Decode
Camera-Based (Mobile) vs USB (Fixed)
| Scanner Type | Decode Time | Accuracy | Use Case |
|---|---|---|---|
| Camera (ML Kit / ZXing) | 35–45ms | 99.2% | Mobile POS, pop-up |
| USB Laser/Imager | 8–12ms | 99.9% | Fixed lane, high-volume |
| Bluetooth Ring | 20–30ms | 99.5% | Hands-free, warehouse |
Offline Barcode Resolution
// Local catalog lookup (IndexedDB)
async function resolveBarcode(barcode) {
// 1. Exact match (EAN-13, UPC-A, Code-128)
let product = await db.get('catalog', barcode);
if (product) return product;
// 2. Prefix match (GS1-128 with AI prefixes)
product = await db.getFromIndex('catalog', 'barcode_prefix', barcode.substring(0, 14));
if (product) return product;
// 3. Internal SKU match
product = await db.getFromIndex('catalog', 'sku', barcode);
if (product) return product;
// 4. Not found → manual entry prompt
return null;
}
Tax Engine Offline (ZATCA/VAT/GST)
Local Tax Calculation
// Tax rules synced weekly, computed locally
function calculateTax(lines, customer, location) {
const rules = getTaxRules(location); // From IndexedDB
return lines.map(line => {
const rule = rules.find(r =>
r.category === line.category &&
r.effective_from <= now &&
(r.effective_to === null || r.effective_to >= now)
);
const taxable = line.qty * line.unit_price * (1 - line.discount_pct);
const tax = taxable * rule.rate;
return {
...line,
tax_rate: rule.rate,
tax_amount: round(tax, 3), // 3 decimal for SAR
tax_rule_id: rule.id,
};
});
}
ZATCA-Specific (Saudi Arabia)
- VAT Rate: 15% (standard), 0% (zero-rated), Exempt
- Invoice Type: Standard, Simplified, Export
- QR Code: TLV encoding (seller, VAT#, timestamp, total, VAT amount)
- Offline: QR generated locally, stamp applied on reconnect
Benchmarks: Offline Performance
| Metric | Target | Achieved |
|---|---|---|
| App Cold Start | < 2s | 1.3s |
| Catalog Search (50K SKUs) | < 50ms | 18ms |
| Barcode Decode (Camera) | < 50ms | 38ms |
| Cart Add Line | < 10ms | 3ms |
| Tax Calculation (20 lines) | < 20ms | 7ms |
| Payment Processing (Cash) | < 5ms | 1ms |
| Transaction Commit (Local) | < 10ms | 4ms |
| Outbox Write | < 5ms | 2ms |
| Background Sync (100 txns) | < 5s | 2.1s |
| Conflict Resolution UI | < 500ms | 120ms |
Failure Scenarios & Recovery
| Scenario | Detection | Recovery |
|---|---|---|
| Network down mid-transaction | Service worker offline event | Continue local, queue in outbox |
| App crash mid-transaction | IndexedDB transaction atomicity | On restart: incomplete txn in outbox → resume |
| Device lost/stolen | Remote wipe (MDM) | IndexedDB encrypted (Web Crypto API) |
| Server down 48+ hours | Outbox retry exponential backoff | Local ops continue, sync when up |
| Clock drift | NTP sync on online, timestamp validation | Server validates timestamp ±5min |
| Duplicate submission | Idempotency key (UUID) | Server returns already_processed |
FAQ
How does offline POS differ from cached catalog?
Cached catalog = read-only product lookup. Aetheria offline = full transaction engine: multi-cart hold, barcode scanning, tax calculation (ZATCA/VAT/GST), payment (cash/offline card), customer lookup — all local. Zero server dependency during blackout.
What happens when network returns?
Service worker detects connectivity → background sync → each offline transaction gets UUID → idempotent POST to /api/pos/reconcile → server validates with pessimistic bin locks → commits or returns conflict → client auto-resolves or prompts cashier.
Can offline transactions conflict with online sales?
Yes — same bin, same SKU. Aetheria uses UUID per transaction + pessimistic bin locks on reconnect. If bin stock insufficient, transaction goes to conflict queue for cashier resolution (split, partial, cancel). Zero data loss.
What about payment processing offline?
Cash = always works. Card = tokenized offline capture (EMV kernel on terminal) → stored encrypted → batch submitted on reconnect. No plaintext PAN ever stored.
Next Steps
- Request POS Demo — Live offline blackout simulation
- Download POS Architecture Whitepaper
- Read Zero-Overselling Integration
- Compare All Offline POS Systems
Frequently Asked Questions
How does offline POS differ from cached catalog?
Cached catalog = read-only product lookup. Aetheria offline = full transaction engine: multi-cart hold, barcode scanning, tax calculation (ZATCA/VAT/GST), payment (cash/offline card), customer lookup — all local. Zero server dependency during blackout.
What happens when network returns?
Service worker detects connectivity → background sync → each offline transaction gets UUID → idempotent POST to /api/pos/reconcile → server validates with pessimistic bin locks → commits or returns conflict → client auto-resolves or prompts cashier.
Can offline transactions conflict with online sales?
Yes — same bin, same SKU. Aetheria uses UUID per transaction + pessimistic bin locks on reconnect. If bin stock insufficient, transaction goes to conflict queue for cashier resolution (split, partial, cancel). Zero data loss.
What about payment processing offline?
Cash = always works. Card = tokenized offline capture (EMV kernel on terminal) → stored encrypted → batch submitted on reconnect. No plaintext PAN ever stored.
Share this article