Skip to main content
Back to Blog
Technical ArchitectureMarch 15, 202614 min read

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.

A
Aetheria Team
Aetheria

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 cacheFull transaction engine
Queue sales, sync laterLocal commit + idempotent sync
Manual conflict resolutionAutomated idempotent reconciliation
Single cartMulti-cart hold (unlimited suspended)
No tax calc offlineFull ZATCA/VAT/GST engine local
No payments offlineCash + 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)

DataSizeSync Frequency
Catalog (50K SKUs)15 MBNightly + on-demand
Customers (100K)25 MBDaily + on-demand
Tax Rules (50)0.5 MBWeekly
Active Carts (50)1 MBReal-time
Outbox (1,000 txns)2 MBReal-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 TypeDecode TimeAccuracyUse Case
Camera (ML Kit / ZXing)35–45ms99.2%Mobile POS, pop-up
USB Laser/Imager8–12ms99.9%Fixed lane, high-volume
Bluetooth Ring20–30ms99.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

MetricTargetAchieved
App Cold Start< 2s1.3s
Catalog Search (50K SKUs)< 50ms18ms
Barcode Decode (Camera)< 50ms38ms
Cart Add Line< 10ms3ms
Tax Calculation (20 lines)< 20ms7ms
Payment Processing (Cash)< 5ms1ms
Transaction Commit (Local)< 10ms4ms
Outbox Write< 5ms2ms
Background Sync (100 txns)< 5s2.1s
Conflict Resolution UI< 500ms120ms

Failure Scenarios & Recovery

ScenarioDetectionRecovery
Network down mid-transactionService worker offline eventContinue local, queue in outbox
App crash mid-transactionIndexedDB transaction atomicityOn restart: incomplete txn in outbox → resume
Device lost/stolenRemote wipe (MDM)IndexedDB encrypted (Web Crypto API)
Server down 48+ hoursOutbox retry exponential backoffLocal ops continue, sync when up
Clock driftNTP sync on online, timestamp validationServer validates timestamp ±5min
Duplicate submissionIdempotency 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

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