← All recipes

Recipe: idempotency, the double tap, and the payment you're not sure happened

Cross-platform: Kotlin Multiplatform, Flutter, React Native.

Problem

Two failures that cost real money. First, the double tap: a user hammers "Reserve" and you create two holds, or worse, two orders. Second, the unknown outcome: you POST /orders, the gateway times out, and you have no idea whether the charge went through. Assume failure and the user pays twice; assume success and you ship a ticket you were never paid for. This is the single most instructive case in the whole lab.

Concept

Two defences, belt and braces. A client-generated idempotency key, stable across retries of one intent, so a retried mutation is a no-op server-side. And an in-flight guard in the state holder, so a second tap while the first is pending does nothing. For payment, one rule above all: never assume. A create that times out maps to PaymentUnknown (not failure); the app keeps the same key and reconciles by polling the order status until the server states a real outcome. No double charge, no false failure.

Implementation ×3

The reconcile decision is a pure function — no timers, exhaustively testable:

KMPdomain/usecase/OrderReconciler.kt, CreateOrderUseCase.kt

// create: a timeout/network drop is NOT a failure — it is unknown
is Outcome.Failure -> if (error is AppError.Timeout || error is AppError.NetworkUnavailable)
    Outcome.Failure(AppError.PaymentUnknown(...)) else result

// one poll → one decision
fun next(poll: Outcome<Order>): Reconciliation = when (poll) {
    is Outcome.Success -> if (poll.value.isSettled) Resolved(poll.value) else Continue
    is Outcome.Failure -> if (poll.error.isTransient) Continue else Abort(poll.error)
}

OrderViewModel.checkout creates with one intentKey, retries PaymentUnknown with the same key, then polls via OrderReconciler until settled. ReservationViewModel.reserve ignores a second call while one is in flight and reuses the intent's key.

Flutterlib/domain/usecases.dart, lib/presentation/cubits.dart

Reconciliation reconcileOrderPoll(Outcome<Order> r) => switch (r) {
      Success(value: final o) => o.isSettled ? Resolved(o) : const Continue(),
      Failure(error: final e) when e is TimeoutError || e is PaymentUnknown => const Continue(),
      Failure(error: final e) => Abort(e),
    };

OrderCubit owns _createReconciling (retry unknown with the same key) then _pollUntilSettled.

React Nativesrc/domain/usecases.ts, src/presentation/stores.ts

export function reconcileOrderPoll(r: Outcome<Order>): Reconciliation {
  if (r.ok) return isSettled(r.value) ? { kind: 'resolved', order: r.value } : { kind: 'continue' };
  const c = r.error.code;
  if (c === 'PaymentUnknown' || c === 'Timeout' || c === 'NetworkUnavailable' || c === 'RateLimited')
    return { kind: 'continue' };
  return { kind: 'abort', error: r.error };
}

The order store guards re-entry, reuses one key, retries unknown, then polls.

Comparison

The logic is identical because it is just data and functions; only the syntax of the sealed result differs (Kotlin when, Dart switch with guards, a TypeScript tagged union). All three keep the "when do we stop polling" decision in a pure function separate from the timers, which is why it can be tested without waiting for anything.

How to see it work

The tests drive the whole payment matrix with virtual time: create returns PaymentUnknown twice then succeeds, polling returns pending then paid — and the app must end at paid having sent the same idempotency key every time. The double-tap test fires two reservations while the first is in flight and asserts exactly one request with one key.

  • KMP — .../commonTest/.../presentation/OrderViewModelTest.kt, ViewModelsTest.kt
  • Flutter — apps/flutter/test/cubits_test.dart
  • React Native — apps/react-native/src/presentation/stores.test.ts

Trade-offs

Polling is not elegant — a push (webhook-driven, or a socket) would settle faster and cost less battery. But polling with idempotency is robust: it needs nothing from the client's network staying up at the wrong moment, and it degrades to "try again in a second" instead of "corrupt state". For a flash sale, robust beats elegant. The cost is a handful of extra GETs per checkout and a bounded retry budget on the unknown-outcome path so it, too, cannot spin forever.