Offline-first payments in React Native
Queued transfers, idempotency keys and resolving conflicts
Queued transfers, idempotency keys and resolving conflicts
For a banking app, offline-first does not mean executing payments offline. The ledger, limits, fraud checks and the payment scheme all live on the server, and they always have the final say.
What it does mean is three guarantees:
The customer's intent is never lost. Once they confirm a transfer, the app keeps it safely, even if the connection drops or the app is closed.
The intent is delivered exactly once. However many times the request is retried, the bank executes at most one transfer.
The status shown is always honest. The app never says "sent" when it doesn't know, and never says "failed" when the money may have left.
There is also a regulatory boundary. Under PSD2, Strong Customer Authentication ties the authentication to the specific amount and payee, and authenticated sessions have short validity limits. A transfer confirmed in a tunnel can be delivered when the train comes out. A transfer queued overnight should not be sent on an authentication that has expired; the app should ask the customer to confirm again. We design the queue for minutes of poor connectivity, not for days offline.
Every transfer the app handles goes through a small set of states, and the UI shows each one explicitly:
Queued: confirmed by the customer and stored on the device, waiting for a connection.
Sending: a request is in flight.
Pending confirmation: a request was sent, but no answer came back. The outcome is unknown.
Completed: the server confirmed the transfer was accepted.
Rejected: the server refused it for a business reason (insufficient funds, limit exceeded, beneficiary blocked).
Expired: it stayed queued longer than allowed and needs the customer to confirm again.
The state that matters most is pending confirmation. Most double-payment bugs come from treating it as failed.
TanStack Query already has most of what an outbox needs. Mutations pause when the device is offline, can be persisted to storage, and can be resumed when the app starts again. Three pieces of setup make it work in React Native.
First, tell TanStack Query when the device is online, using NetInfo:
1 import NetInfo from '@react-native-community/netinfo';
2 import { onlineManager } from '@tanstack/react-query';
3
4 onlineManager.setEventListener(setOnline =>
5 NetInfo.addEventListener(state => setOnline(!!state.isConnected)),
6 );
Second, register the transfer mutation by key, not inline in a component. Functions can't be serialised to storage, so a mutation restored after an app restart needs a default mutationFn to resume with:
1 queryClient.setMutationDefaults(['transfer'], {
2 mutationFn: (transfer: QueuedTransfer) => api.submitTransfer(transfer),
3 retry: (failureCount, error) => isTransient(error) && failureCount < 5,
4 retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30_000) + Math.random() * 500,
5 });
Third, persist paused mutations to encrypted storage and resume them on start-up:
1 const persister = createSyncStoragePersister({
2 storage: encryptedOutboxStorage, // an encrypted MMKV instance exposing getItem / setItem / removeItem
3 });
4
5 export function App() {
6 return (
7 8 client={queryClient}
9 persistOptions={{ persister, maxAge: QUEUE_MAX_AGE_MS }}
10 onSuccess={() => queryClient.resumePausedMutations()}
11 >
12
13
14 );
15 }
The component that confirms the transfer is then deliberately simple:
1 const submitTransfer = useMutation({
2 mutationKey: ['transfer'],
3 });
4
5 function onConfirm(draft: TransferDraft) {
6 const transfer: QueuedTransfer = {
7 ...draft,
8 idempotencyKey: uuidv4(), // generated once, at confirmation
9 confirmedAt: Date.now(),
10 };
11 submitTransfer.mutate(transfer);
12 }
The most important line is the one that generates the key. It happens once, when the customer confirms, and the key travels with the queued transfer from then on. It is persisted before the first request is sent, so every retry (after a timeout, after a restart, after a day in a tunnel) carries the same key.
An idempotency key is a unique identifier the client attaches to a request, so the server can recognise a repeat and return the original result instead of executing again. Simple as the idea is, a few rules decide whether it actually protects customers:
One key per customer intent, not per request. Generating a new key on each retry turns every retry into a new payment.
Generate it on the device, before the first send. If the server generated it, a lost response would also lose the key.
Send it in a header. We use Idempotency-Key, the name used by the IETF HTTP API working group's draft and by most payment APIs.
Bind it to the request content. If the same key arrives with a different amount or IBAN, that is a client bug, and the server must reject it rather than return the earlier result.
Scope it to the customer. Keys are unique per customer, not globally, so one customer's key can never return another customer's response.
Keep it at least as long as the client might retry. The server's retention period must be longer than the client's maximum queue age.
On the server, idempotency only works if it is enforced by the database rather than by checks in application code. Two concurrent retries checking "does this key exist?" at the same moment will both get "no". We store keys in a table with a unique constraint on (customer_id, idempotency_key), along with a hash of the request, the processing status and the stored response. The endpoint wraps the transfer logic:
1 @PostMapping("/api/transfers")
2 public ResponseEntity submit(
3 @RequestHeader("Idempotency-Key") UUID key,
4 @AuthenticationPrincipal Jwt jwt,
5 @Valid @RequestBody TransferRequest request) {
6
7 return idempotency.execute(jwt.getSubject(), key, request,
8 () -> transferService.submit(jwt.getSubject(), request));
9 }
10 public ResponseEntity execute(String customerId, UUID key,
11 TransferRequest request, Supplier action) {
12
13 String requestHash = hasher.hash(request);
14 try {
15 records.insertInProgress(customerId, key, requestHash); // unique constraint
16 } catch (DuplicateKeyException e) {
17 IdempotencyRecord existing = records.find(customerId, key);
18 if (!existing.requestHash().equals(requestHash)) {
19 return ResponseEntity.unprocessableEntity().build(); // same key, different payment
20 }
21 if (existing.inProgress()) {
22 return ResponseEntity.status(HttpStatus.CONFLICT).build(); // first attempt still running
23 }
24 return ResponseEntity.status(existing.httpStatus()).body(existing.response()); // replay
25 }
26
27 TransferResponse response = action.get();
28 records.complete(customerId, key, HttpStatus.CREATED.value(), response);
29 return ResponseEntity.status(HttpStatus.CREATED).body(response);
30 }
Two details matter in production:
Write the payment and complete the idempotency record in the same database transaction. Then there can never be a payment without its key, or a completed key without its payment.
Store business rejections as results too. If the first attempt was rejected for insufficient funds, a retry with the same key returns the same rejection. Transient failures (a downstream timeout before anything was written) clear the in-progress record so a retry can run again.
The isTransient function in the client decides whether a failure is retried automatically. Getting it wrong either loses payments or annoys customers.
Retry automatically, with the same key: network errors, timeouts, HTTP 5xx, HTTP 429, and HTTP 409 (the first attempt is still in progress). Use exponential backoff with jitter, so thousands of phones reconnecting after a network outage don't all retry in the same second.
Never retry automatically: HTTP 4xx business rejections such as insufficient funds, limits or an invalid IBAN, and HTTP 422 for a key reused with different content. These go straight to the rejected state with a clear message.
Stop and ask the customer: an expired SCA authorisation or an expired queue item. The app moves the transfer to expired and offers to confirm it again, which creates a new key, because it is a new intent.
The case that idempotency exists for is a request that reached the server and was processed, with a response that never came back. Without a key, the app has two bad options: show "failed" and risk the customer paying twice, or show "sent" when it doesn't know. With a key, the answer is simple: the retry is the status check. The app keeps the transfer in pending confirmation and retries with the same key. The server recognises it and returns the stored result: the original transfer ID and its status. No duplicate is created, and the customer sees the real outcome.
For transfers that stay pending longer, for example after the app was closed, we also expose GET /api/transfers/by-key/{idempotencyKey}. The app calls it on start-up for every transfer in pending confirmation before showing the transfer list.
Between confirmation and delivery, things can change. The design principle is that the app never resolves conflicts by overriding the server. The server re-validates everything on arrival, and the app's job is to explain the result clearly. The conflicts we design for:
The balance changed. Another payment or a card transaction used the funds in the meantime. The server rejects the transfer, and the app shows the rejection with the current balance, not the balance cached when the customer confirmed.
Limits or account status changed. A daily limit was reached on another channel, or the account was restricted. The server rejects, and the app explains in plain language and doesn't retry.
The beneficiary changed. The customer deleted or edited the saved payee on the web portal while the transfer was queued. The queued transfer carries the IBAN and name as confirmed, not a reference to the saved payee, so it executes exactly what the customer approved, or is rejected if the server's rules require it.
The customer tried again manually. This is the most common conflict in practice: the customer sees nothing happen and creates a second, identical transfer. Two defences help. The queued and pending confirmation states are shown prominently on the home screen. And when a customer confirms a transfer that matches one still pending, the app asks whether they want to send it again. The second transfer gets its own key, because it may be a real second payment.
The device clock is wrong. Queue ages and expiry are checked against server time on reconnection, never only against the phone's clock.
A payment outbox on a phone holds sensitive data, so it is designed like any other local store in a banking app:
Encrypted at rest. MMKV supports an encryption key. The key itself lives in the iOS Keychain or Android Keystore, never in the JavaScript bundle. For larger outboxes or audit-style history, an encrypted SQLite database works too.
Minimal content. The queue stores what is needed to send the transfer and show its status: amount, currency, IBAN, payee name, reference, idempotency key and timestamps. It never stores credentials or full account data.
Cleared on logout and on device unbinding. Transfers still queued at logout are moved to expired, and the customer is told on next login.
Unit tests can't cover these flows on their own. We add a small set of scenario tests on real devices and emulators, using a proxy that can drop or delay traffic:
Airplane mode on immediately after confirmation, then off: one transfer, completed.
The response is dropped after the server commits: the app shows pending confirmation, then completed, with one transfer in the ledger.
The app is killed while sending, then reopened: the transfer resumes with the same key, and one transfer is executed.
Two retries arrive at the server at the same moment: one 201 and one 409, then a replay.
A queued transfer outlives its SCA validity: the app asks the customer to confirm again.
The backend team runs the server-side cases as integration tests against a real database, because the unique constraint is the guarantee being tested.
Offline-first payments are not about doing banking without the bank. They are about making sure a customer's decision is captured once, delivered once and reported honestly, however poor the connection. With a persisted queue on the device, a key generated at the moment of confirmation, and an endpoint that enforces idempotency in the database, a dropped response in a tunnel stops being a support ticket and becomes a normal part of the flow.