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.
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
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.
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.
Encryption at Rest: Protecting Data on iOS Devices
Implement encryption at rest for iOS applications using Apple's CryptoKit, AES-GCM, and the Secure Enclave, with practical Swift code examples.