Building Offline-First Banking Experiences

How to architect a mobile banking app that remains functional without network connectivity, covering local data persistence, sync strategies, conflict resolution, and the UX patterns that make offline banking feel seamless.

technical9 min readBy Klivvr Engineering
Share:

A mobile banking app that shows a blank screen when the user walks into an elevator is not just a poor experience — it is a trust violation. The user opened the app to check a balance before making a purchase decision. If the app cannot show them anything because the network dropped for 10 seconds, the user's confidence in the product evaporates. They wonder: is my money even there?

Offline-first architecture addresses this by treating the local database as the primary data source and the network as a synchronization mechanism. The app always renders from local data, always responds to user interactions, and reconciles with the server when connectivity is available. This is not about building a banking app that works entirely without the internet — that is neither practical nor safe for financial operations. It is about ensuring that the app remains useful, informative, and responsive regardless of network conditions.

At Klivvr, we adopted an offline-first architecture from the beginning. This article explains the technical approach, the data synchronization strategy, and the UX patterns that make it work for a financial product.

The Local Data Layer

The foundation of offline-first is a robust local database. We use SQLDelight for our shared Kotlin Multiplatform layer, which generates type-safe Kotlin APIs from SQL statements and compiles to SQLite on both platforms.

// TransactionQueries.sq
CREATE TABLE TransactionEntity (
    id TEXT NOT NULL PRIMARY KEY,
    account_id TEXT NOT NULL,
    amount_minor_units INTEGER NOT NULL,
    currency_code TEXT NOT NULL,
    merchant_name TEXT NOT NULL,
    category TEXT NOT NULL,
    status TEXT NOT NULL,
    timestamp INTEGER NOT NULL,
    sync_state TEXT NOT NULL DEFAULT 'synced',
    last_modified INTEGER NOT NULL
);
 
getTransactionsByAccount:
SELECT *
FROM TransactionEntity
WHERE account_id = ?
ORDER BY timestamp DESC
LIMIT ? OFFSET ?;
 
getLastSyncTimestamp:
SELECT MAX(last_modified)
FROM TransactionEntity
WHERE account_id = ?;
 
upsertTransaction:
INSERT OR REPLACE INTO TransactionEntity(
    id, account_id, amount_minor_units, currency_code,
    merchant_name, category, status, timestamp,
    sync_state, last_modified
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

The sync_state column tracks whether each record is synced (matches the server), pending (created or modified locally, not yet sent to the server), or conflict (local and server versions diverged). The last_modified column enables incremental synchronization.

The repository layer abstracts the database and provides a reactive interface:

// TransactionRepository.kt
class TransactionRepository(
    private val database: KlivvrDatabase,
    private val apiClient: KlivvrApiClient
) {
    // Always reads from local database
    fun observeTransactions(
        accountId: String,
        pageSize: Int
    ): Flow<List<Transaction>> {
        return database.transactionQueries
            .getTransactionsByAccount(accountId, pageSize.toLong(), 0)
            .asFlow()
            .mapToList(Dispatchers.Default)
            .map { entities -> entities.map { it.toDomain() } }
    }
 
    // Sync fetches from network and updates local database
    suspend fun syncTransactions(accountId: String): SyncResult {
        val lastSync = database.transactionQueries
            .getLastSyncTimestamp(accountId)
            .executeAsOneOrNull() ?: 0L
 
        return try {
            val response = apiClient.getTransactions(
                accountId = accountId,
                since = lastSync
            )
 
            database.transaction {
                response.transactions.forEach { tx ->
                    database.transactionQueries.upsertTransaction(
                        id = tx.id,
                        account_id = accountId,
                        amount_minor_units = tx.amount.minorUnits,
                        currency_code = tx.amount.currencyCode,
                        merchant_name = tx.merchantName,
                        category = tx.category.name,
                        status = tx.status.name,
                        timestamp = tx.timestamp,
                        sync_state = "synced",
                        last_modified = tx.lastModified
                    )
                }
            }
 
            SyncResult.Success(updatedCount = response.transactions.size)
        } catch (e: Exception) {
            SyncResult.Failed(error = e, lastSuccessfulSync = lastSync)
        }
    }
}

The UI layer observes the Flow from the repository. When the app launches, it immediately renders whatever data is in the local database — even if it is from the last session, hours or days ago. The sync operation runs in the background and, when it completes, the Flow emits updated data, causing the UI to refresh automatically.

Synchronization Strategy

Our synchronization follows an incremental pull model. Instead of fetching all data on every sync, we request only records modified since the last successful sync timestamp. This minimizes bandwidth and server load.

The sync orchestrator runs at four trigger points:

  1. App launch. As soon as the app starts and the user is authenticated.
  2. Pull-to-refresh. When the user explicitly requests fresh data.
  3. Push notification. When a server-sent notification indicates new data is available.
  4. Connectivity restoration. When the device transitions from offline to online.
// SyncOrchestrator.kt
class SyncOrchestrator(
    private val transactionRepository: TransactionRepository,
    private val accountRepository: AccountRepository,
    private val connectivityMonitor: ConnectivityMonitor,
    private val syncStateManager: SyncStateManager
) {
    private val syncScope = CoroutineScope(
        SupervisorJob() + Dispatchers.Default
    )
 
    fun startPeriodicSync() {
        // Observe connectivity changes
        syncScope.launch {
            connectivityMonitor.isConnected.collect { connected ->
                if (connected) {
                    performFullSync()
                }
            }
        }
    }
 
    suspend fun performFullSync(): SyncReport {
        if (syncStateManager.isSyncInProgress()) {
            return SyncReport.AlreadyInProgress
        }
 
        syncStateManager.markSyncStarted()
 
        return try {
            // Sync accounts first (dependencies for transactions)
            val accountResult = accountRepository.syncAccounts()
 
            // Then sync transactions for each account in parallel
            val accounts = accountRepository.getLocalAccounts()
            val transactionResults = coroutineScope {
                accounts.map { account ->
                    async {
                        transactionRepository.syncTransactions(account.id)
                    }
                }.awaitAll()
            }
 
            val report = SyncReport.Completed(
                accountsUpdated = accountResult.updatedCount,
                transactionsUpdated = transactionResults
                    .filterIsInstance<SyncResult.Success>()
                    .sumOf { it.updatedCount },
                errors = transactionResults
                    .filterIsInstance<SyncResult.Failed>()
                    .map { it.error }
            )
 
            syncStateManager.markSyncCompleted(report)
            report
        } catch (e: Exception) {
            syncStateManager.markSyncFailed(e)
            SyncReport.Failed(e)
        }
    }
}

Handling Offline Writes

Read-heavy operations (viewing balances, browsing transactions) work naturally with offline-first architecture. The challenge is write operations — transfers, payments, and account modifications. A banking app cannot process a real money transfer offline; that requires server-side validation, fund availability checks, and interaction with payment networks.

Our approach categorizes write operations into three tiers:

Tier 1: Queue and sync. Some operations can be queued locally and submitted when connectivity returns. Examples include updating notification preferences, renaming an account, or scheduling a future-dated transfer (where the execution date is days away). These are stored in a local operation queue:

// OperationQueue.kt
data class PendingOperation(
    val id: String,
    val type: OperationType,
    val payload: String, // JSON-serialized operation data
    val createdAt: Instant,
    val retryCount: Int = 0,
    val maxRetries: Int = 3
)
 
class OperationQueue(private val database: KlivvrDatabase) {
    fun enqueue(operation: PendingOperation) {
        database.operationQueries.insert(
            id = operation.id,
            type = operation.type.name,
            payload = operation.payload,
            created_at = operation.createdAt.toEpochMilliseconds(),
            retry_count = 0,
            max_retries = operation.maxRetries.toLong()
        )
    }
 
    suspend fun processQueue(apiClient: KlivvrApiClient) {
        val pending = database.operationQueries
            .getPendingOperations()
            .executeAsList()
 
        for (op in pending) {
            try {
                apiClient.submitOperation(op.type, op.payload)
                database.operationQueries.markCompleted(op.id)
            } catch (e: ConflictException) {
                database.operationQueries.markConflict(op.id, e.message)
            } catch (e: Exception) {
                if (op.retry_count >= op.max_retries) {
                    database.operationQueries.markFailed(op.id, e.message)
                } else {
                    database.operationQueries.incrementRetry(op.id)
                }
            }
        }
    }
}

Tier 2: Optimistic UI with rollback. For some operations, we show immediate UI feedback while the request is in flight. If the request fails, we roll back the UI state. Locking or unlocking a card is a good example: the toggle flips immediately, and if the server request fails, it flips back with an explanation.

// CardLockViewModel.swift
class CardLockViewModel: ObservableObject {
    @Published var isLocked: Bool
    @Published var error: String?
 
    private let cardService: CardService
    private let cardId: String
 
    func toggleLock() {
        let previousState = isLocked
        isLocked.toggle()  // Optimistic update
        error = nil
 
        Task {
            do {
                if isLocked {
                    try await cardService.lockCard(cardId)
                } else {
                    try await cardService.unlockCard(cardId)
                }
            } catch {
                // Rollback on failure
                await MainActor.run {
                    self.isLocked = previousState
                    self.error = "Could not \(previousState ? "unlock" : "lock") your card. Please try again."
                }
            }
        }
    }
}

Tier 3: Require connectivity. For immediate money transfers, we do not pretend the operation can happen offline. If the user is offline and tries to send money, we show a clear message: "You need an internet connection to send money. Your transfer will not be queued." This honesty is preferable to queueing a transfer that might fail hours later due to insufficient funds or a changed exchange rate.

// TransferViewModel.kt
fun initiateTransfer(transfer: TransferRequest) {
    if (!connectivityMonitor.isCurrentlyConnected()) {
        _uiState.update {
            it.copy(
                error = UiError(
                    title = "No Internet Connection",
                    message = "Money transfers require an active internet connection. " +
                        "Please check your connection and try again.",
                    action = UiErrorAction.Retry
                )
            )
        }
        return
    }
 
    // Proceed with transfer...
}

UX Patterns for Offline States

How the app communicates its connectivity state is as important as the technical architecture. We follow these principles:

Show data freshness, not connectivity status. Instead of a generic "You are offline" banner, we show when the data was last updated: "Updated 3 minutes ago" or "Updated 2 hours ago." This gives the user the information they need to decide whether they trust the displayed balance.

// SyncStatusView.swift
struct SyncStatusView: View {
    let lastSyncTime: Date?
    let syncState: SyncState
 
    var body: some View {
        HStack(spacing: 4) {
            switch syncState {
            case .syncing:
                ProgressView()
                    .scaleEffect(0.7)
                Text("Updating...")
                    .font(.caption2)
                    .foregroundColor(.secondary)
 
            case .synced:
                if let lastSync = lastSyncTime {
                    Text("Updated \(lastSync.relativeFormatted())")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                }
 
            case .failed:
                Image(systemName: "exclamationmark.triangle.fill")
                    .font(.caption2)
                    .foregroundColor(.orange)
                Text("Could not refresh — showing last known data")
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
        }
    }
}

Disable unavailable actions gracefully. When offline, buttons for online-only operations (transfers, payments) are visually dimmed with a clear explanation on tap, not hidden entirely. Hiding buttons makes the user think the feature disappeared; dimming them communicates a temporary state.

Animate the transition. When connectivity returns and fresh data arrives, the UI should update smoothly. A sudden jump from "$1,200.00" to "$950.00" (because a transaction cleared while offline) can be jarring. We animate balance changes with a brief counting animation and highlight new transactions with a subtle "new" indicator that fades after a few seconds.

Data Staleness and Security

Offline data has a shelf life, especially in a financial app. Cached data that is weeks old could be misleading — a user might think they have funds that have long since been spent. We implement staleness policies:

  • Balance display. If the last sync was more than 24 hours ago, the balance is displayed with a prominent warning: "This balance may not be current."
  • Transaction list. Stale transaction lists are still displayed but marked as potentially incomplete.
  • Session expiry. Regardless of offline data availability, the authentication session expires after a configurable period (default: 7 days). After expiry, the user must re-authenticate online before any data — even cached data — is displayed.
fun shouldShowStaleWarning(lastSyncTimestamp: Long): Boolean {
    val hoursSinceSync = Duration.between(
        Instant.ofEpochMilli(lastSyncTimestamp),
        Instant.now()
    ).toHours()
    return hoursSinceSync > 24
}
 
fun isSessionValid(sessionExpiry: Long): Boolean {
    return Instant.now().toEpochMilli() < sessionExpiry
}

Conclusion

Offline-first banking is not about enabling full banking operations without the internet. It is about ensuring that the app remains a reliable, informative companion regardless of network conditions. The user standing in a subway with no signal should still be able to see their recent balance, review their transaction history, and plan their spending. The user in a spotty Wi-Fi zone should never see a loading spinner that never resolves. By treating the local database as the source of truth for reads and being honest about what requires connectivity for writes, we build an experience that feels dependable — and in banking, dependability is everything.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

9 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read