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
| Principle | Description |
|---|---|
| Local-First | All operations work against local database first |
| Async Sync | Sync happens in background, not blocking UI |
| Queue Everything | Changes queue when offline, sync when online |
| Conflict Resolution | Deterministic rules for conflicting changes |
| Eventual Consistency | Accept 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
| Priority | Event Types | Sync Timing |
|---|---|---|
| 1 (Critical) | Sales, Payments, Refunds, Voids | Immediate when online |
| 2 (Important) | Inventory adjustments, Transfers | Within 5 minutes |
| 3 (Normal) | Customer updates, Loyalty changes | Within 15 minutes |
| 4 (Low) | Analytics events, Logs | Batch 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:
- Continuous Operation - Sales never blocked by network issues
- Instant Response - All operations work against local database
- Reliable Sync - Event queue with retry and conflict resolution
- Data Integrity - Event sourcing enables deterministic merging
- 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