Offline-First Architecture Design Patterns

Whistl works fully offline—protection doesn't depend on internet connectivity. This technical guide explains offline-first architecture, local data storage, conflict resolution, and how Whistl maintains continuous protection even in airplane mode.

Why Offline-First?

Financial protection can't depend on connectivity:

  • Subways/planes: No signal during commute or travel
  • Rural areas: Spotty coverage outside cities
  • Network congestion: Events, emergencies overload networks
  • Latency: Round-trip to server adds delay
  • Privacy: Less data transmission = more privacy

Whistl's offline-first design ensures protection works anywhere, anytime.

Architecture Overview

All core functionality runs locally on device:

Local vs. Cloud Components

┌─────────────────────────────────────────────────┐
│              Local (On-Device)                  │
│  ┌─────────────────────────────────────────┐   │
│  │  ML Models (Impulse Prediction)         │   │
│  │  - Neural network inference             │   │
│  │  - Risk calculation                     │   │
│  └─────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────┐   │
│  │  Local Database (SQLCipher)             │   │
│  │  - Transactions                         │   │
│  │  - Settings                             │   │
│  │  - Goals                                │   │
│  └─────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────┐   │
│  │  Blocking Engine                        │   │
│  │  - DNS filtering                        │   │
│  │  - Geofencing                           │   │
│  └─────────────────────────────────────────┘   │
├─────────────────────────────────────────────────┤
│              Cloud (Optional Sync)              │
│  - Backup storage                               │
│  - Multi-device sync                            │
│  - Model updates                                │
└─────────────────────────────────────────────────┘

Local Data Storage

All data is stored locally with encryption:

Database Schema

-- Transactions table
CREATE TABLE transactions (
    id TEXT PRIMARY KEY,
    amount REAL NOT NULL,
    merchant TEXT,
    mcc TEXT,
    category TEXT,
    date TIMESTAMP NOT NULL,
    location_lat REAL,
    location_lng REAL,
    risk_score REAL,
    synced INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Settings table
CREATE TABLE settings (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    encrypted INTEGER DEFAULT 0,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Goals table
CREATE TABLE goals (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    image_url TEXT,
    target_amount REAL,
    current_amount REAL,
    deadline DATE,
    priority INTEGER,
    synced INTEGER DEFAULT 0
);

-- Risk patterns table
CREATE TABLE risk_patterns (
    id TEXT PRIMARY KEY,
    pattern_type TEXT,
    pattern_data TEXT,  -- JSON
    confidence REAL,
    last_triggered TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Data Access Layer

class LocalDatabase {
    private let db: SQLiteDatabase
    
    // Read transactions (always available offline)
    func getTransactions(
        from: Date,
        to: Date,
        category: String? = nil
    ) throws -> [Transaction] {
        var query = "SELECT * FROM transactions WHERE date BETWEEN ? AND ?"
        var params: [Any] = [from, to]
        
        if let category = category {
            query += " AND category = ?"
            params.append(category)
        }
        
        query += " ORDER BY date DESC"
        
        return try db.query(query, params: params)
            .map { row in Transaction(from: row) }
    }
    
    // Insert transaction (queued for sync)
    func insertTransaction(_ transaction: Transaction) throws {
        try db.execute("""
            INSERT INTO transactions 
            (id, amount, merchant, mcc, category, date, synced)
            VALUES (?, ?, ?, ?, ?, ?, 0)
        """, params: [
            transaction.id,
            transaction.amount,
            transaction.merchant,
            transaction.mcc,
            transaction.category,
            transaction.date
        ])
    }
}

Offline Protection Features

Core protection works without internet:

Always-Available Features

FeatureOffline SupportNotes
DNS BlockingFullBlocklist stored locally
GeofencingFullGPS works without internet
ML InferenceFullOn-device neural network
SpendingShieldFullLocal risk calculation
Transaction HistoryFullStored in local database
Goals/Dream BoardFullImages cached locally
Partner MessagingLimitedQueued for when online
Bank SyncNoneRequires internet

Sync Strategy

When connectivity returns, data syncs automatically:

Sync Queue

class SyncQueue {
    private let queue: OperationQueue
    private var pendingOperations: [SyncOperation] = []
    
    func enqueue(_ operation: SyncOperation) {
        pendingOperations.append(operation)
        
        // Process queue when online
        if NetworkMonitor.shared.isOnline {
            processQueue()
        }
    }
    
    private func processQueue() {
        for operation in pendingOperations {
            queue.addOperation(operation)
        }
        pendingOperations = []
    }
    
    // Sync operations
    func syncTransactions() {
        enqueue(TransactionSyncOperation())
    }
    
    func syncSettings() {
        enqueue(SettingsSyncOperation())
    }
    
    func syncGoals() {
        enqueue(GoalsSyncOperation())
    }
}

Sync Priority

enum SyncPriority: Int, Comparable {
    case critical = 0  // Blocking state, intervention results
    case high = 1      // Settings changes, new goals
    case normal = 2    // Transaction history, patterns
    case low = 3       // Analytics, model telemetry
    
    static func < (lhs: SyncPriority, rhs: SyncPriority) -> Bool {
        return lhs.rawValue < rhs.rawValue
    }
}

class SyncScheduler {
    func scheduleSync(_ operation: SyncOperation) {
        // Critical operations sync immediately
        if operation.priority == .critical {
            operation.start()
            return
        }
        
        // Lower priority operations batched
        syncQueue.enqueue(operation)
    }
}

Conflict Resolution

When multiple devices modify data, conflicts are resolved:

Conflict Detection

class ConflictDetector {
    func detectConflicts(local: SyncData, remote: SyncData) -> [Conflict] {
        var conflicts: [Conflict] = []
        
        // Compare by entity ID and timestamp
        for localEntity in local.entities {
            if let remoteEntity = remote.entities.first(where: { $0.id == localEntity.id }) {
                if localEntity.updatedAt != remoteEntity.updatedAt {
                    conflicts.append(Conflict(
                        entityType: localEntity.type,
                        entityId: localEntity.id,
                        localVersion: localEntity,
                        remoteVersion: remoteEntity
                    ))
                }
            }
        }
        
        return conflicts
    }
}

Resolution Strategies

Data TypeStrategyDescription
SettingsLast-write-winsMost recent change takes precedence
GoalsMergeCombine progress from both devices
TransactionsUnionAdd missing transactions from both
PatternsMergeCombine pattern data
Intervention ResultsUnionAll results preserved

Network State Management

App responds gracefully to network changes:

Network Monitor

import Network

class NetworkMonitor {
    static let shared = NetworkMonitor()
    
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")
    
    var isOnline: Bool { currentPath.status == .satisfied }
    var isExpensive: Bool { currentPath.isExpensive }  // Cellular
    var isConstrained: Bool { currentPath.isConstrained }  // Low data mode
    
    private var currentPath: NWPath = NWPath()
    
    var statusHandler: ((NetworkStatus) -> Void)?
    
    func start() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.currentPath = path
            self?.statusHandler?(NetworkStatus(from: path))
        }
        monitor.start(queue: queue)
    }
    
    func stop() {
        monitor.cancel()
    }
}

UI State Updates

class NetworkStateViewModel: ObservableObject {
    @Published var isOnline: Bool = true
    @Published var syncPending: Bool = false
    @Published var lastSyncDate: Date?
    
    init() {
        NetworkMonitor.shared.statusHandler = { [weak self] status in
            DispatchQueue.main.async {
                self?.isOnline = status.isOnline
                self?.updateSyncStatus()
            }
        }
    }
    
    private func updateSyncStatus() {
        if isOnline && SyncQueue.shared.hasPendingOperations {
            syncPending = true
            SyncQueue.shared.processQueue()
        } else {
            syncPending = false
        }
    }
}

Offline Indicators

Users are informed of offline status:

UI Indicators

  • Status bar: Orange indicator when offline
  • Sync badge: Shows pending sync count
  • Toast notification: "You're offline. Changes will sync when connected."
  • Disabled features: Greyed out when unavailable

Offline Banner

struct OfflineBanner: View {
    @ObservedObject var viewModel: NetworkStateViewModel
    
    var body: some View {
        if !viewModel.isOnline {
            HStack {
                Image("wifi-off")
                    .foregroundColor(.white)
                Text("You're offline. Protection is still active.")
                    .font(.caption)
                    .foregroundColor(.white)
                Spacer()
                if viewModel.syncPending {
                    Text("\(SyncQueue.shared.pendingCount) pending")
                        .font(.caption2)
                        .padding(.horizontal, 8)
                        .padding(.vertical, 4)
                        .background(Color.white.opacity(0.3))
                        .cornerRadius(4)
                }
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 8)
            .background(Color.orange)
        }
    }
}

Caching Strategy

Resources are cached for offline access:

Cache Configuration

  • Goal images: Cached indefinitely
  • Blocklist: Cached, updated weekly
  • ML models: Cached, updated monthly
  • Transaction history: Last 90 days cached
  • Settings: Cached indefinitely

Image Caching

class ImageCache {
    private let cache = NSCache()
    private let fileManager = FileManager.default
    
    func getImage(for url: URL) -> UIImage? {
        // Check memory cache
        if let cached = cache.object(forKey: url.absoluteString as NSString) {
            return cached
        }
        
        // Check disk cache
        let path = getCachePath(for: url)
        if let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
           let image = UIImage(data: data) {
            cache.setObject(image, forKey: url.absoluteString as NSString)
            return image
        }
        
        return nil
    }
    
    func cacheImage(_ image: UIImage, for url: URL) {
        cache.setObject(image, forKey: url.absoluteString as NSString)
        
        let path = getCachePath(for: url)
        try? image.jpegData(compressionQuality: 0.8)?
            .write(to: URL(fileURLWithPath: path))
    }
}

Testing Offline Scenarios

Offline functionality is thoroughly tested:

Test Scenarios

  • Airplane mode: All features without any connectivity
  • Intermittent connection: Switching between online/offline
  • Slow network: 2G/3G simulation
  • Long offline period: Days without sync
  • Multi-device conflict: Changes on multiple devices while offline

Conclusion

Whistl's offline-first architecture ensures protection works anywhere—subways, planes, rural areas, or anywhere with poor connectivity. All core features (blocking, ML inference, risk calculation) run locally, while sync happens automatically when connectivity returns.

Your protection doesn't depend on internet—Whistl works when and where you need it.

Get Always-On Protection

Whistl works fully offline—protection doesn't depend on internet. Download free and experience reliable offline-first protection.

Download Whistl Free

Related: Local Storage Encryption | DNS Filtering | Cloud Sync