Offline-First Storage Patterns for Mobile Banking

How to design offline-first storage for mobile banking apps, covering conflict resolution, sync strategies, and optimistic updates — with patterns from KlivvrStorageKit.

technical6 min readBy Klivvr Engineering
Share:

Mobile banking apps must work in unreliable network conditions. Users check balances on subway commutes, review transactions in areas with spotty coverage, and expect the app to respond instantly regardless of connectivity. An app that shows a loading spinner every time the network drops is an app that users abandon.

Offline-first design treats the local device as the primary data source and the server as a sync target. The app reads from and writes to local storage first, then synchronizes with the server when connectivity is available. This article covers the offline-first storage patterns used in Klivvr's mobile apps through KlivvrStorageKit.

Local-First Architecture

In an offline-first architecture, every read operation hits local storage. The UI never waits for a network response to display data. Server sync happens in the background, updating local storage when new data arrives.

protocol OfflineFirstRepository {
    associatedtype Model: Codable & Identifiable
 
    func getLocal(id: String) async throws -> Model?
    func getAllLocal() async throws -> [Model]
    func saveLocal(_ model: Model) async throws
    func deleteLocal(id: String) async throws
    func sync() async throws
}
 
final class TransactionRepository: OfflineFirstRepository {
    typealias Model = Transaction
 
    private let localStorage: SecureStorage
    private let apiClient: APIClient
    private let syncQueue: SyncQueue
 
    func getLocal(id: String) async throws -> Transaction? {
        try localStorage.retrieve(
            Transaction.self,
            forKey: "txn_\(id)",
            security: .private
        )
    }
 
    func getAllLocal() async throws -> [Transaction] {
        let index: [String] = try localStorage.retrieve(
            [String].self,
            forKey: "txn_index",
            security: .standard
        ) ?? []
 
        return try index.compactMap { id in
            try localStorage.retrieve(
                Transaction.self,
                forKey: "txn_\(id)",
                security: .private
            )
        }
    }
 
    func saveLocal(_ transaction: Transaction) async throws {
        try localStorage.store(
            transaction,
            forKey: "txn_\(transaction.id)",
            security: .private
        )
 
        // Update index
        var index = try localStorage.retrieve(
            [String].self,
            forKey: "txn_index",
            security: .standard
        ) ?? []
 
        if !index.contains(transaction.id) {
            index.append(transaction.id)
            try localStorage.store(index, forKey: "txn_index", security: .standard)
        }
    }
 
    func sync() async throws {
        let lastSyncTimestamp = try localStorage.retrieve(
            Date.self,
            forKey: "txn_last_sync",
            security: .standard
        ) ?? Date.distantPast
 
        let remoteTransactions = try await apiClient.getTransactions(
            since: lastSyncTimestamp
        )
 
        for transaction in remoteTransactions {
            try await saveLocal(transaction)
        }
 
        try localStorage.store(
            Date(),
            forKey: "txn_last_sync",
            security: .standard
        )
    }
}

Sync Queue for Pending Operations

When a user initiates an action offline — such as scheduling a payment or updating their profile — the operation is queued locally and executed when connectivity returns.

struct PendingOperation: Codable, Identifiable {
    let id: String
    let type: OperationType
    let payload: Data
    let createdAt: Date
    var retryCount: Int
    var lastAttempt: Date?
    var status: OperationStatus
 
    enum OperationType: String, Codable {
        case createPayment
        case updateProfile
        case setPreference
    }
 
    enum OperationStatus: String, Codable {
        case pending
        case inProgress
        case failed
        case completed
    }
}
 
final class SyncQueue {
    private let storage: SecureStorage
    private let apiClient: APIClient
    private let maxRetries = 3
 
    func enqueue(_ operation: PendingOperation) throws {
        var queue = try loadQueue()
        queue.append(operation)
        try saveQueue(queue)
    }
 
    func processQueue() async {
        var queue = (try? loadQueue()) ?? []
        guard !queue.isEmpty else { return }
 
        for i in queue.indices {
            guard queue[i].status == .pending else { continue }
 
            queue[i].status = .inProgress
            queue[i].lastAttempt = Date()
 
            do {
                try await execute(queue[i])
                queue[i].status = .completed
            } catch {
                queue[i].retryCount += 1
                queue[i].status = queue[i].retryCount >= maxRetries
                    ? .failed
                    : .pending
            }
        }
 
        // Remove completed operations
        queue.removeAll { $0.status == .completed }
        try? saveQueue(queue)
    }
 
    private func execute(_ operation: PendingOperation) async throws {
        switch operation.type {
        case .createPayment:
            let payment = try JSONDecoder().decode(
                PaymentRequest.self,
                from: operation.payload
            )
            try await apiClient.createPayment(payment)
 
        case .updateProfile:
            let profile = try JSONDecoder().decode(
                ProfileUpdate.self,
                from: operation.payload
            )
            try await apiClient.updateProfile(profile)
 
        case .setPreference:
            let pref = try JSONDecoder().decode(
                PreferenceUpdate.self,
                from: operation.payload
            )
            try await apiClient.setPreference(pref)
        }
    }
 
    private func loadQueue() throws -> [PendingOperation] {
        try storage.retrieve(
            [PendingOperation].self,
            forKey: "sync_queue",
            security: .private
        ) ?? []
    }
 
    private func saveQueue(_ queue: [PendingOperation]) throws {
        try storage.store(queue, forKey: "sync_queue", security: .private)
    }
}

Conflict Resolution

When the same data is modified both locally and on the server, conflicts arise. KlivvrStorageKit supports multiple conflict resolution strategies depending on the data type.

enum ConflictResolution {
    case serverWins
    case clientWins
    case lastWriteWins
    case merge(MergeStrategy)
}
 
protocol MergeStrategy {
    func resolve<T: Codable>(local: T, remote: T, base: T?) -> T
}
 
final class ConflictResolver {
    func resolve<T: Codable & Timestamped>(
        local: T,
        remote: T,
        strategy: ConflictResolution
    ) -> T {
        switch strategy {
        case .serverWins:
            return remote
        case .clientWins:
            return local
        case .lastWriteWins:
            return local.updatedAt > remote.updatedAt ? local : remote
        case .merge(let mergeStrategy):
            return mergeStrategy.resolve(
                local: local,
                remote: remote,
                base: nil
            )
        }
    }
}
 
protocol Timestamped {
    var updatedAt: Date { get }
}

For financial data like transaction history, the server is always the source of truth (server-wins). For user preferences like theme and notification settings, last-write-wins is appropriate since the user's most recent choice should prevail. For complex objects like profile data where different fields might be updated independently, field-level merge strategies combine non-conflicting changes.

Network Connectivity Monitoring

The sync system needs to know when to attempt synchronization. KlivvrStorageKit monitors network state and triggers sync operations when connectivity is restored.

import Network
 
final class ConnectivityMonitor {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "connectivity-monitor")
    private var onConnected: (() -> Void)?
    private var isConnected = false
 
    func startMonitoring(onConnected: @escaping () -> Void) {
        self.onConnected = onConnected
 
        monitor.pathUpdateHandler = { [weak self] path in
            let wasConnected = self?.isConnected ?? false
            let nowConnected = path.status == .satisfied
 
            self?.isConnected = nowConnected
 
            if !wasConnected && nowConnected {
                // Connection restored - trigger sync
                DispatchQueue.main.async {
                    self?.onConnected?()
                }
            }
        }
 
        monitor.start(queue: queue)
    }
 
    func stopMonitoring() {
        monitor.cancel()
    }
}
 
// Integration with sync queue
let connectivity = ConnectivityMonitor()
let syncQueue = SyncQueue()
 
connectivity.startMonitoring {
    Task {
        await syncQueue.processQueue()
    }
}

Conclusion

Offline-first storage transforms a mobile banking app from a thin client that depends on the network into a resilient application that works regardless of connectivity. KlivvrStorageKit provides the building blocks — local storage, sync queues, conflict resolution, and connectivity monitoring — that make this possible. The key insight is that offline support is not a feature to be added later; it is an architectural decision that must be made from the start, because retrofitting offline-first patterns onto a network-dependent app is far more expensive than building them in from the beginning.

Related Articles

business

Building Internal SDKs That Developers Want to Use

Lessons from building KlivvrStorageKit on creating internal SDKs with great developer experience — covering API design, documentation, migration support, and adoption strategies.

6 min read
technical

iOS Keychain Services: A Comprehensive Guide

Master iOS Keychain Services with this comprehensive guide covering item classes, access control, sharing, migration, and common pitfalls in Swift.

8 min read