Offline-First React Native Banking Apps
bankingSeptember 17, 2026

Offline-First React Native Banking Apps

Sync Conflict Resolution That Doesn't Corrupt Balances

Most offline-sync guidance defaults to last-write-wins because most offline-sync guidance is written for todo apps and note editors, where overwriting an old value with a newer one is exactly the right behavior. A balance isn't that kind of data, and the moment a banking app's sync layer treats it like it is, the bug isn't cosmetic — it's a customer's money disagreeing with itself across two devices. This is about the model that actually holds up, not a smarter merge algorithm. 


The mistake is upstream of the merge strategy 

Last-write-wins picks the most recent timestamp and discards the other write. That's a reasonable default when two writes are genuinely alternatives to the same field — a display name changed on two devices while offline, where only one final value can possibly be correct and either is an acceptable choice. It's the wrong model entirely for a balance, and the reason isn't that LWW picks badly — it's that a balance was never a field two devices independently "wrote to" in the first place. 

A transaction offline isn't a new value for the balance field. It's an operation — "debit $40" — that gets queued locally and needs to be applied, in some order, alongside whatever else happened to the account while the device was offline. Two devices going offline and each initiating a transaction aren't in conflict with each other the way two edits to a display name are. They're two independent operations that both need to be evaluated against the account's real state, in some deterministic order, by whichever party actually knows what that real state is — which is never the device. 


Treating the queued transaction as "the new balance" is what makes LWW look like a design choice instead of a bug. The fix isn't a better conflict-resolution algorithm layered on top of that model. It's not using that model at all. 


Queue commands, not state 

The local outbox should hold operations, not balances — the same distinction we've written about for event-sourced ledgers, applied here on the client instead of the server: the balance is never stored as a value to be synced, it's always derived, and what actually gets queued is the sequence of things that happened. 


 1 type QueuedTransaction = { 
 2   idempotencyKey: string;      // client-generated UUID, stable across retries 
 3   accountId: string; 
 4   amount: number; 
 5   type: 'debit' | 'credit'; 
 6   clientTimestamp: number;     // for display and ordering hints only — 
 7                                 // never the source of truth for ordering 
 8   status: 'pending' | 'confirmed' | 'rejected'; 
 9 }; 
10  
11 function enqueueTransaction(tx: Omit) { 
12   const queued: QueuedTransaction = { 
13     ...tx, 
14     idempotencyKey: generateUUID(), 
15     status: 'pending', 
16   }; 
17   localQueue.push(queued); 
18   applyOptimistically(queued); // UI reflects it immediately, marked pending 
19   return queued; 
20 } 

The idempotencyKey is doing exactly the job it does in a payment API — a connectivity drop mid-sync and a retry send the same operation twice, and the server has to treat the second arrival as a replay, not a new debit. That's the same pattern we've covered for preventing duplicate charges at an API boundary; here it protects against a mobile network's flakiness duplicating a sync call instead of a gateway's timeout duplicating a retry, but it's the identical mechanism solving the identical class of problem. 


The optimistic UI has to know it's provisional 

This is the part worth being deliberate about in the interface, not just the data layer: a queued transaction should render as pending, visually distinct from a confirmed one, and the displayed balance should be labeled as provisional until sync confirms it. That single UX decision is what makes the eventual failure case survivable. If the optimistic balance is presented with the same visual authority as a confirmed one, a rejected transaction has nowhere honest to land — the user was shown a number that turned out to be wrong, with no indication it was ever anything less than final. If it was always shown as pending, a rejection is just the pending state resolving to "didn't go through," which is a UX pattern users already understand from every other app that queues actions offline. 


The server is the only party allowed to decide what happened 

When connectivity returns, the queued operations sync — and this is where the actual conflict resolution happens, not on the device. The server applies each operation against the account's real, current state, in the order it receives them (or in a deterministic order it assigns, such as a per-account sequence number issued at acceptance time — never a client-supplied timestamp, because clock skew between two devices is a real and unresolvable problem if the server trusts it for ordering). 


Two offline transactions that are each individually valid can still be jointly invalid — a withdrawal from each of two devices that, combined, overdraw an account neither device knew was close to its limit. That's not a sync conflict in the CRDT sense; there's nothing to merge. It's a business rule, evaluated once, by the party with authoritative state, and the second operation the server processes gets rejected outright rather than silently adjusted, merged, or clamped to whatever the account can actually cover. 


 1 // Server-side, conceptually — the client never re-derives this itself 
 2 function applyQueuedTransaction(tx: QueuedTransaction, account: Account) { 
 3   if (isDuplicate(tx.idempotencyKey)) return getStoredResult(tx.idempotencyKey); 
 4  
 5   if (tx.type === 'debit' && account.balance < tx.amount) { 
 6     return reject(tx, 'INSUFFICIENT_FUNDS'); 
 7   } 
 8   const result = ledgerService.apply(tx); // becomes an event in the ledger, per earlier piece 
 9   storeResult(tx.idempotencyKey, result); 
10   return result; 
11 } 

The client's job on sync response is simple precisely because the hard decision didn't happen there: mark each QueuedTransaction as confirmed or rejected based on what the server actually decided, update the locally-derived balance display to match the server's authoritative value, and surface a rejection to the user explicitly — never resolve it silently by quietly adjusting a number the user already saw. 


What this buys you, and what it costs 

The cost is real and worth naming to whoever's reviewing the architecture: transactions initiated offline aren't final until sync completes, and the UI has to carry that honestly rather than pretending otherwise, which is more interface work than a naive "just update the local balance" implementation. What it buys is the thing last-write-wins can never provide for money: a balance that's always traceable to a sequence of operations a server actually evaluated, with no version of events where two devices' local state disagreed about how much money existed and something silently picked a winner. The device queues intent. It was never supposed to decide outcome — and building the sync layer as if it does is where "just use last-write-wins" quietly turns into a support ticket about a balance nobody can explain.