Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 09: Offline-First Design

POS Client Architecture for Unreliable Networks

This chapter details the offline-first architecture for POS clients. Retail environments frequently experience network issues - internet outages, WiFi dropouts, or slow connections during peak hours. The POS must continue operating seamlessly regardless of connectivity.


Why Offline-First for POS?

Retail Network Reality
======================

Internet Outage at 2pm on Black Friday?
  Traditional POS: "Network Error - Cannot Process Sale" (DISASTER)
  Offline-First POS: Works normally, syncs when online (BUSINESS CONTINUES)

Slow WiFi during holiday rush?
  Traditional POS: 5-second delay per sale (FRUSTRATED CUSTOMERS)
  Offline-First POS: Instant response, sync in background (HAPPY CUSTOMERS)

Server maintenance window?
  Traditional POS: Store closes or uses manual paper (LOST REVENUE)
  Offline-First POS: No impact to operations (FULL REVENUE)

Offline-First Principles

PrincipleDescription
Local-FirstAll operations work against local database first
Async SyncSync happens in background, not blocking UI
Queue EverythingChanges queue when offline, sync when online
Conflict ResolutionDeterministic rules for conflicting changes
Eventual ConsistencyAccept that data may be temporarily out of sync

POS Client Architecture

POS Client Architecture
=======================

+-----------------------------------------------------------------------+
|                           POS CLIENT                                   |
|                                                                        |
|  +------------------------+        +-------------------------------+  |
|  |      Presentation      |        |        Local Storage          |  |
|  |                        |        |                               |  |
|  |  +------------------+  |        |  +-------------------------+  |  |
|  |  |   Sales Screen   |  |        |  |      SQLite Database     |  |  |
|  |  +------------------+  |        |  |                         |  |  |
|  |  |  Product Grid    |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | products_cache      | |  |  |
|  |  |   Cart Panel     |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | pending_sales       | |  |  |
|  |  |  Payment Dialog  |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | sync_queue          | |  |  |
|  |  |  Receipt Print   |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | events (local)      | |  |  |
|  +------------------------+        |  | +---------------------+ |  |  |
|             |                      |  | | customer_cache      | |  |  |
|             v                      |  | +---------------------+ |  |  |
|  +------------------------+        |  +-------------------------+  |  |
|  |   Application Layer    |        |                               |  |
|  |                        |        +-------------------------------+  |
|  |  +------------------+  |                      ^                    |
|  |  |   SaleService    |------------------------>|                    |
|  |  +------------------+  |                      |                    |
|  |  | InventoryService |------------------------>|                    |
|  |  +------------------+  |                      |                    |
|  |  | CustomerService  |------------------------>|                    |
|  |  +------------------+  |                                           |
|  +------------------------+                                           |
|             |                                                         |
|             v                                                         |
|  +------------------------+        +-------------------------------+  |
|  |     Sync Service       |        |     Connection Monitor        |  |
|  |                        |        |                               |  |
|  |  - Queue Manager       |<------>|  - Ping Central API           |  |
|  |  - Conflict Resolver   |        |  - Track online/offline       |  |
|  |  - Retry Handler       |        |  - Trigger sync when online   |  |
|  |  - Batch Uploader      |        |                               |  |
|  +------------------------+        +-------------------------------+  |
|             |                                                         |
+-------------|----------------------------------------------------------+
              |
              v (when online)
+-----------------------------------------------------------------------+
|                          CENTRAL API                                   |
+-----------------------------------------------------------------------+

Local Database Schema (SQLite)

The POS client maintains a local SQLite database for offline operation:

-- SQLite Schema for POS Client

-- Product cache (synced from server)
CREATE TABLE products_cache (
    id              TEXT PRIMARY KEY,
    sku             TEXT UNIQUE NOT NULL,
    barcode         TEXT,
    name            TEXT NOT NULL,
    category_name   TEXT,
    price           REAL NOT NULL,
    cost            REAL,
    tax_code        TEXT,
    is_taxable      INTEGER DEFAULT 1,
    track_inventory INTEGER DEFAULT 1,
    image_url       TEXT,
    variants_json   TEXT,              -- JSON array of variants
    synced_at       TEXT NOT NULL,     -- When last synced from server
    created_at      TEXT DEFAULT (datetime('now'))
);

CREATE INDEX idx_products_barcode ON products_cache(barcode);
CREATE INDEX idx_products_name ON products_cache(name);

-- Inventory cache (synced from server)
CREATE TABLE inventory_cache (
    product_id      TEXT NOT NULL,
    variant_id      TEXT,
    location_id     TEXT NOT NULL,
    quantity        INTEGER NOT NULL,
    synced_at       TEXT NOT NULL,
    PRIMARY KEY (product_id, variant_id, location_id)
);

-- Customer cache (synced from server)
CREATE TABLE customers_cache (
    id              TEXT PRIMARY KEY,
    customer_number TEXT UNIQUE,
    first_name      TEXT,
    last_name       TEXT,
    email           TEXT,
    phone           TEXT,
    loyalty_points  INTEGER DEFAULT 0,
    store_credit    REAL DEFAULT 0,
    synced_at       TEXT NOT NULL
);

-- Local sales (created offline, pending sync)
CREATE TABLE local_sales (
    id              TEXT PRIMARY KEY,
    sale_number     TEXT UNIQUE NOT NULL,
    location_id     TEXT NOT NULL,
    register_id     TEXT NOT NULL,
    employee_id     TEXT NOT NULL,
    customer_id     TEXT,
    status          TEXT DEFAULT 'completed',
    subtotal        REAL NOT NULL,
    discount_total  REAL DEFAULT 0,
    tax_total       REAL DEFAULT 0,
    total           REAL NOT NULL,
    line_items_json TEXT NOT NULL,     -- JSON array of line items
    payments_json   TEXT NOT NULL,     -- JSON array of payments
    created_at      TEXT DEFAULT (datetime('now')),
    synced_at       TEXT              -- NULL until synced
);

CREATE INDEX idx_local_sales_synced ON local_sales(synced_at);

-- Event queue (append-only, sync to server)
CREATE TABLE event_queue (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    event_id        TEXT UNIQUE NOT NULL,
    aggregate_type  TEXT NOT NULL,
    aggregate_id    TEXT NOT NULL,
    event_type      TEXT NOT NULL,
    event_data      TEXT NOT NULL,     -- JSON
    created_at      TEXT NOT NULL,
    created_by      TEXT,
    synced_at       TEXT,              -- NULL until synced
    sync_attempts   INTEGER DEFAULT 0,
    last_error      TEXT
);

CREATE INDEX idx_event_queue_pending ON event_queue(synced_at) WHERE synced_at IS NULL;

-- Sync metadata
CREATE TABLE sync_status (
    key             TEXT PRIMARY KEY,
    value           TEXT NOT NULL,
    updated_at      TEXT DEFAULT (datetime('now'))
);

-- Track what we've synced
INSERT INTO sync_status (key, value) VALUES
    ('last_product_sync', '1970-01-01T00:00:00Z'),
    ('last_inventory_sync', '1970-01-01T00:00:00Z'),
    ('last_customer_sync', '1970-01-01T00:00:00Z'),
    ('last_event_push', '1970-01-01T00:00:00Z');

Sync Queue Design

The sync queue manages all pending changes:

Sync Queue Architecture
=======================

+-------------------+     +-------------------+     +-------------------+
|   Sale Created    |     |  Inventory Adj    |     | Customer Created  |
|   (Offline)       |     |  (Offline)        |     | (Offline)         |
+--------+----------+     +--------+----------+     +--------+----------+
         |                         |                         |
         v                         v                         v
+-----------------------------------------------------------------------+
|                          SYNC QUEUE                                    |
|                                                                        |
|  Priority  | Type              | Status    | Retries | Last Error     |
|  ---------------------------------------------------------------      |
|  1         | SaleCreated       | pending   | 0       |                |
|  1         | PaymentReceived   | pending   | 0       |                |
|  2         | InventoryAdjusted | pending   | 0       |                |
|  3         | CustomerCreated   | failed    | 3       | Timeout        |
|  1         | SaleCompleted     | pending   | 0       |                |
|                                                                        |
|  Priority Legend:                                                      |
|  1 = Critical (sales, payments) - sync immediately                    |
|  2 = Important (inventory) - sync within minutes                      |
|  3 = Normal (customers) - sync when convenient                        |
+-----------------------------------------------------------------------+
              |
              | Sync Processor (runs when online)
              v
+-----------------------------------------------------------------------+
|                          CENTRAL API                                   |
|                                                                        |
|  POST /api/sync/events                                                |
|  [                                                                     |
|    { eventType: "SaleCreated", ... },                                 |
|    { eventType: "PaymentReceived", ... },                             |
|    ...                                                                 |
|  ]                                                                     |
|                                                                        |
|  Response: { synced: 5, conflicts: 0, errors: [] }                    |
+-----------------------------------------------------------------------+

Sync Priority Rules

PriorityEvent TypesSync Timing
1 (Critical)Sales, Payments, Refunds, VoidsImmediate when online
2 (Important)Inventory adjustments, TransfersWithin 5 minutes
3 (Normal)Customer updates, Loyalty changesWithin 15 minutes
4 (Low)Analytics events, LogsBatch sync hourly

Conflict Resolution Strategies

Different data types require different conflict resolution approaches:

Conflict Resolution Matrix
==========================

+------------------+---------------------+--------------------------------+
| Data Type        | Strategy            | Reasoning                      |
+------------------+---------------------+--------------------------------+
| Sales            | Append-Only         | Each sale is unique, no        |
|                  | (No Conflicts)      | conflicts possible             |
+------------------+---------------------+--------------------------------+
| Inventory        | Last-Write-Wins     | Central server is authority,   |
|                  | (Server Wins)       | client updates are suggestions |
+------------------+---------------------+--------------------------------+
| Customers        | Merge on Key        | Merge by email, combine        |
|                  | (Email = Key)       | non-conflicting fields         |
+------------------+---------------------+--------------------------------+
| Products         | Server Authority    | Product catalog managed        |
|                  | (Read-Only Client)  | centrally, client is cache     |
+------------------+---------------------+--------------------------------+
| Employees        | Server Authority    | HR data managed centrally      |
|                  | (Read-Only Client)  |                                |
+------------------+---------------------+--------------------------------+
| Settings         | Server Authority    | Config managed by admin        |
|                  | (Read-Only Client)  |                                |
+------------------+---------------------+--------------------------------+

Strategy 1: Append-Only (Sales)

Sales never conflict because each sale has a unique UUID generated locally:

Sale Conflict Resolution: None Required
========================================

Client A (Offline):                  Client B (Offline):
  Sale S-001 created @ 10:15           Sale S-002 created @ 10:16
  LineItem: Product X, Qty 2           LineItem: Product Y, Qty 1
  Payment: $50 cash                    Payment: $25 credit

When both sync:
  Server: Accepts S-001 (unique ID)
  Server: Accepts S-002 (unique ID)
  Result: Both sales recorded, no conflict

Strategy 2: Last-Write-Wins (Inventory)

Central server maintains authoritative inventory; client adjustments are “suggestions”:

Inventory Conflict Resolution: Server Authority
===============================================

Server State:
  Product X @ Location HQ: 100 units

Client A (Offline):                  Client B (Offline):
  Sells 5 units of Product X           Sells 3 units of Product X
  Local: 95 units                      Local: 97 units

When both sync:
  Server receives: "Sold 5 units" from A
  Server receives: "Sold 3 units" from B
  Server calculates: 100 - 5 - 3 = 92 units
  Server pushes new quantity to all clients

Result:
  All clients update to 92 units
  Individual decrements preserved
  No quantity lost or duplicated

Strategy 3: Merge on Key (Customers)

Customer records merge based on email as the unique identifier:

Customer Conflict Resolution: Merge
===================================

Server State:
  Customer email: john@example.com
  Name: John Doe
  Phone: (blank)
  Loyalty: 500 points

Client A (Offline):                  Client B (Offline):
  Updates phone to 555-1234            Updates loyalty to 600 points

When both sync:
  Server merges non-conflicting fields:
    Name: John Doe (unchanged)
    Phone: 555-1234 (from A)
    Loyalty: 600 points (from B)

If same field changed:
  Server uses timestamp to pick latest
  Or prompts admin for resolution

Sync Processor Workflow

Sync Processor State Machine
============================

                    +-------------+
                    |    IDLE     |
                    +------+------+
                           |
                           | Connection detected
                           v
                    +-------------+
                    |   SYNCING   |
                    +------+------+
                           |
        +------------------+------------------+
        |                  |                  |
        v                  v                  v
+-------------+    +-------------+    +-------------+
| PUSH EVENTS |    | PULL DATA   |    |  COMPLETE   |
|             |    |             |    |             |
| - Sales     |    | - Products  |    | - Update    |
| - Payments  |    | - Inventory |    |   metadata  |
| - Inventory |    | - Customers |    | - Return    |
|   changes   |    | - Settings  |    |   to IDLE   |
+------+------+    +------+------+    +-------------+
       |                  |
       +------------------+
                |
                v
        +-------------+
        | HANDLE      |
        | CONFLICTS   |
        +------+------+
               |
               v
        +-------------+
        |  COMPLETE   |
        +-------------+

Sync Service Implementation

// SyncService.cs

public class SyncService : IHostedService
{
    private readonly ILocalDatabase _localDb;
    private readonly IApiClient _apiClient;
    private readonly IConnectionMonitor _connectionMonitor;
    private readonly IConflictResolver _conflictResolver;
    private readonly ILogger<SyncService> _logger;

    private Timer? _syncTimer;
    private bool _isSyncing = false;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _connectionMonitor.OnlineStatusChanged += HandleConnectionChange;

        // Check for pending sync every 30 seconds
        _syncTimer = new Timer(
            async _ => await TrySyncAsync(),
            null,
            TimeSpan.Zero,
            TimeSpan.FromSeconds(30)
        );
    }

    private async void HandleConnectionChange(object? sender, bool isOnline)
    {
        if (isOnline)
        {
            _logger.LogInformation("Connection restored, starting sync");
            await TrySyncAsync();
        }
    }

    private async Task TrySyncAsync()
    {
        if (_isSyncing) return;
        if (!_connectionMonitor.IsOnline) return;

        _isSyncing = true;
        try
        {
            // 1. Push local events to server
            await PushEventsAsync();

            // 2. Pull updated data from server
            await PullProductsAsync();
            await PullInventoryAsync();
            await PullCustomersAsync();

            // 3. Update sync timestamps
            await UpdateSyncMetadataAsync();

            _logger.LogInformation("Sync completed successfully");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Sync failed");
        }
        finally
        {
            _isSyncing = false;
        }
    }

    private async Task PushEventsAsync()
    {
        // Get pending events ordered by priority
        var pendingEvents = await _localDb.GetPendingEventsAsync();

        if (!pendingEvents.Any()) return;

        // Batch events (max 100 per request)
        var batches = pendingEvents.Chunk(100);

        foreach (var batch in batches)
        {
            try
            {
                var response = await _apiClient.PostEventsAsync(batch);

                // Mark synced events
                foreach (var evt in response.Synced)
                {
                    await _localDb.MarkEventSyncedAsync(evt.EventId);
                }

                // Handle conflicts
                foreach (var conflict in response.Conflicts)
                {
                    await _conflictResolver.ResolveAsync(conflict);
                }
            }
            catch (HttpRequestException)
            {
                // Network error, increment retry count
                foreach (var evt in batch)
                {
                    await _localDb.IncrementEventRetryAsync(evt.EventId);
                }
                throw;
            }
        }
    }

    private async Task PullProductsAsync()
    {
        var lastSync = await _localDb.GetSyncTimestampAsync("products");

        var products = await _apiClient.GetProductsUpdatedSinceAsync(lastSync);

        foreach (var product in products)
        {
            await _localDb.UpsertProductCacheAsync(product);
        }
    }

    private async Task PullInventoryAsync()
    {
        var locationId = await GetCurrentLocationIdAsync();
        var lastSync = await _localDb.GetSyncTimestampAsync("inventory");

        var inventory = await _apiClient.GetInventoryUpdatedSinceAsync(locationId, lastSync);

        foreach (var item in inventory)
        {
            // Apply server's quantity (server is authority)
            await _localDb.UpdateInventoryCacheAsync(item);
        }
    }
}

Sale Creation Flow (Offline-Capable)

Offline Sale Flow
=================

1. Cashier scans items
   +----------------+
   | Local Lookup   |
   | products_cache |
   +----------------+
         |
         v
2. Add to cart (no network needed)
   +----------------+
   | In-Memory Cart |
   +----------------+
         |
         v
3. Customer pays
   +----------------+
   | Payment Dialog |
   | (card or cash) |
   +----------------+
         |
         v
4. Save sale locally
   +----------------+
   | local_sales    |
   | (SQLite)       |
   +----------------+
         |
         v
5. Queue sync events
   +----------------+
   | event_queue    |
   | SaleCreated    |
   | ItemAdded x N  |
   | PaymentRcvd    |
   | SaleCompleted  |
   +----------------+
         |
         v
6. Decrement local inventory
   +----------------+
   | inventory_cache|
   | (optimistic)   |
   +----------------+
         |
         v
7. Print receipt
   +----------------+
   | Receipt ready  |
   | (no waiting)   |
   +----------------+
         |
         v
8. Background sync (when online)
   +----------------+
   | SyncService    |
   | pushes events  |
   +----------------+

Sale Service Implementation

// SaleService.cs

public class SaleService
{
    private readonly ILocalDatabase _localDb;
    private readonly IEventQueue _eventQueue;
    private readonly IReceiptPrinter _printer;

    public async Task<Sale> CompleteSaleAsync(Cart cart, List<Payment> payments)
    {
        // 1. Generate local IDs
        var saleId = Guid.NewGuid();
        var saleNumber = GenerateSaleNumber();

        // 2. Create sale record
        var sale = new Sale
        {
            Id = saleId,
            SaleNumber = saleNumber,
            LocationId = GetCurrentLocationId(),
            RegisterId = GetCurrentRegisterId(),
            EmployeeId = GetCurrentEmployeeId(),
            CustomerId = cart.CustomerId,
            Status = "completed",
            Subtotal = cart.Subtotal,
            DiscountTotal = cart.DiscountTotal,
            TaxTotal = cart.TaxTotal,
            Total = cart.Total,
            LineItems = cart.Items.Select(MapToLineItem).ToList(),
            Payments = payments,
            CreatedAt = DateTime.UtcNow
        };

        // 3. Save to local database
        await _localDb.InsertSaleAsync(sale);

        // 4. Queue events for sync
        await _eventQueue.EnqueueAsync(new SaleCreated
        {
            SaleId = saleId,
            SaleNumber = saleNumber,
            LocationId = sale.LocationId,
            EmployeeId = sale.EmployeeId,
            CustomerId = sale.CustomerId,
            CreatedAt = sale.CreatedAt
        });

        foreach (var item in sale.LineItems)
        {
            await _eventQueue.EnqueueAsync(new SaleLineItemAdded
            {
                SaleId = saleId,
                LineItemId = item.Id,
                ProductId = item.ProductId,
                Sku = item.Sku,
                Name = item.Name,
                Quantity = item.Quantity,
                UnitPrice = item.UnitPrice
            });

            // 5. Decrement local inventory (optimistic)
            await _localDb.DecrementInventoryAsync(
                item.ProductId,
                item.VariantId,
                sale.LocationId,
                item.Quantity
            );
        }

        foreach (var payment in payments)
        {
            await _eventQueue.EnqueueAsync(new PaymentReceived
            {
                SaleId = saleId,
                PaymentId = payment.Id,
                PaymentMethod = payment.Method,
                Amount = payment.Amount
            });
        }

        await _eventQueue.EnqueueAsync(new SaleCompleted
        {
            SaleId = saleId,
            Total = sale.Total,
            CompletedAt = DateTime.UtcNow
        });

        // 6. Print receipt (async, don't wait)
        _ = _printer.PrintReceiptAsync(sale);

        return sale;
    }

    private string GenerateSaleNumber()
    {
        // Format: HQ-20251229-0001
        // Location-Date-Sequence
        var location = GetCurrentLocationCode();
        var date = DateTime.Now.ToString("yyyyMMdd");
        var sequence = GetNextLocalSequence();
        return $"{location}-{date}-{sequence:D4}";
    }
}

Connection Monitor

// ConnectionMonitor.cs

public class ConnectionMonitor : IHostedService
{
    private readonly IApiClient _apiClient;
    private readonly ILogger<ConnectionMonitor> _logger;

    private Timer? _pingTimer;
    private bool _isOnline = false;

    public bool IsOnline => _isOnline;
    public event EventHandler<bool>? OnlineStatusChanged;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Ping server every 10 seconds
        _pingTimer = new Timer(
            async _ => await CheckConnectionAsync(),
            null,
            TimeSpan.Zero,
            TimeSpan.FromSeconds(10)
        );

        return Task.CompletedTask;
    }

    private async Task CheckConnectionAsync()
    {
        var wasOnline = _isOnline;

        try
        {
            // Simple health check endpoint
            var response = await _apiClient.PingAsync();
            _isOnline = response.IsSuccessStatusCode;
        }
        catch
        {
            _isOnline = false;
        }

        if (_isOnline != wasOnline)
        {
            _logger.LogInformation(
                "Connection status changed: {Status}",
                _isOnline ? "ONLINE" : "OFFLINE"
            );

            OnlineStatusChanged?.Invoke(this, _isOnline);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _pingTimer?.Dispose();
        return Task.CompletedTask;
    }
}

Offline Indicator UI

Offline Indicator Design
========================

When ONLINE:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                                    [GM Store] [John D] |
|  Status: Connected                                                     |
+-----------------------------------------------------------------------+

When OFFLINE:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                          [!] OFFLINE MODE   [GM Store] |
|  +-----------------------------------------------------------------+  |
|  | Working offline. 5 sales pending sync.                          |  |
|  +-----------------------------------------------------------------+  |
+-----------------------------------------------------------------------+

When SYNCING:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                     [<->] Syncing... 3/5   [GM Store]  |
+-----------------------------------------------------------------------+

Summary

The offline-first architecture ensures:

  1. Continuous Operation - Sales never blocked by network issues
  2. Instant Response - All operations work against local database
  3. Reliable Sync - Event queue with retry and conflict resolution
  4. Data Integrity - Event sourcing enables deterministic merging
  5. User Confidence - Clear offline indicator and sync status

Key components:

  • Local SQLite database with product, inventory, and customer caches
  • Event queue for all changes with priority-based sync
  • Conflict resolution matrix per data type
  • Connection monitor with automatic sync trigger
  • Sync service with batch upload and pull

Next: Chapter 10: Architecture Decision Records