Idempotency + Retries That Survive Reality
The Mobile/Server Contract for "Effectively-Once" Actions
Mobile networks fail. Users double-tap. Apps get backgrounded. Radios switch towers. Requests time out even when the server completed the work.
When your client "just retries," you can accidentally apply the same action twice. That's how you get duplicate charges, duplicate orders, duplicate form submissions—and "weird" customer support tickets that are actually replay bugs.
This isn't rare. It's the default environment.
The goal isn't "never retry." It's retry safely. And the key concept is:
Idempotency turns retries from a risk into a contract.
"Exactly-once" delivery is a distributed-systems promise that's expensive and fragile. For mobile apps, the realistic target is effectively-once: repeated delivery attempts produce one side effect. Idempotency is how you get there.
This post is a practical playbook: what the backend must enforce, how the client must participate, and how to avoid the foot-guns.
A Timeout Doesn't Mean "It Failed"
A mobile client can see a timeout in at least three realities:
- The request never reached the server
- The server processed it, but the response never reached the client
- The server is still processing it (slow path)
If you blindly retry a "create order" call, you can create two orders. If you refuse to retry, users lose actions and trust.
"Retry or not" can't be a guess. It must be an API contract.
The Core Mechanism: Idempotency-Key
For any action that must not duplicate (charge, submit, create), the client sends an idempotency key representing the user's intent:
POST /v1/orders
Idempotency-Key: 7F2B8A7E-1C73-4C31-9DAA-2F4B2E6C6A9B
Content-Type: application/json
Authorization: Bearer <token>
The key must be:
- Unique per intent (not per retry)
- Stable across retries
- Persisted until you get a definitive outcome
- Scoped (at least by user + endpoint; often also by tenant/account)
The contract is simple: same intent + same key → same result, not a second side effect.
Who's Responsible?
People say "idempotency is the backend's responsibility." They're half right.
What the backend must guarantee (non-negotiable):
- Safety across app versions
- Protection against buggy clients
- Protection against malicious replay
- Consistent outcomes even if the client never receives the response
If the backend doesn't enforce idempotency, the system is fundamentally unsafe.
What the client must provide:
- Stable intent identifiers (same key for retries)
- Sane retry behavior (backoff, jitter, appropriate error handling)
- UI-level deduplication (double-tap prevention)
Here's the framing: Backend guarantees safety; client guarantees correctness of intent.
Without both:
- Backend is idempotent + client generates new key per retry → duplicate actions
- Client dedupes taps + backend isn't idempotent → two devices can still duplicate
What the Server Must Do
When the server receives a request with an idempotency key:
- Look up
(user, endpoint, idempotencyKey)in an idempotency store - If found → replay the stored outcome
- If not found → process the request, store the outcome, return it
What to store:
- Key, user/tenant scope, endpoint scope
- Created resource identifier (or full response payload)
- Status code, timestamp, TTL/expiry
Payload consistency check (don't skip this):
If the same key arrives with a different payload, reject it (e.g., 409 Conflict). Otherwise, bugs or attackers can smuggle a different operation under the same key.
Stable response replay:
If the first request created order 123, every retry must return order 123. That's what makes retries safe.
What the Client Must Do
1. Generate the key once per intent
The idempotency key belongs to the intent, not the transport attempt.
struct SubmitOrderIntent {
let idempotencyKey: String
let order: Order
var status: IntentStatus
init(order: Order) {
self.idempotencyKey = UUID().uuidString
self.order = order
self.status = .pending
}
}
When the user taps "Submit": create an intent, generate the key, persist it, reuse it for all retries until completion.
2. Persist the key across retries and restarts
Mobile is crashy. Offline is normal. Backgrounding is constant.
If you don't persist the key, you'll regenerate a new one after a restart and lose the safety property.
final class IntentStore {
private let storage: SecureStorage
func save(_ intent: SubmitOrderIntent) {
storage.set(intent, forKey: "intent:\(intent.idempotencyKey)")
}
func pendingIntents() -> [SubmitOrderIntent] {
storage.all().filter { $0.status == .pending }
}
func markCompleted(_ intent: SubmitOrderIntent) {
var updated = intent
updated.status = .completed
storage.set(updated, forKey: "intent:\(intent.idempotencyKey)")
}
}
Use encrypted storage (Keychain) for sensitive flows.
3. Dedupe at the UI level
If a user double-taps and you create two intents with two keys, the backend can't know they were "the same tap."
@MainActor
final class SubmitOrderViewModel: ObservableObject {
@Published private(set) var isSubmitting = false
private var activeIntent: SubmitOrderIntent?
func submit(order: Order) {
guard !isSubmitting else { return } // Block double-tap
isSubmitting = true
activeIntent = SubmitOrderIntent(order: order)
intentStore.save(activeIntent!)
Task { await executeWithRetry() }
}
}
Lock the UI into a "submitting" state. Only one intent active per action until resolved.
4. Retry only the right failures
Generally safe to retry (with idempotency):
- Network unreachable, timeouts, transient transport errors
- Many 5xx errors (depending on endpoint semantics)
Usually not safe to auto-retry:
- 4xx validation errors (fix input first)
- 401/403 (refresh auth, then decide)
- Business-rule failures (user action needed)
Idempotency protects against duplicate side effects, but you still want to avoid retry storms and bad UX.
Backoff + Jitter
When connectivity returns, thousands of devices may retry simultaneously. Without jitter, you create a thundering herd.
struct RetryPolicy {
let maxAttempts: Int
let baseDelay: TimeInterval
let maxDelay: TimeInterval
let jitterFraction: Double
func delay(forAttempt attempt: Int) -> TimeInterval {
let exponential = baseDelay * pow(2.0, Double(attempt - 1))
let capped = min(exponential, maxDelay)
let jitter = capped * Double.random(in: -jitterFraction...jitterFraction)
return capped + jitter
}
}
// Usage: 1s, 2s, 4s, 8s... capped at 30s, ±30% jitter
let policy = RetryPolicy(
maxAttempts: 5,
baseDelay: 1.0,
maxDelay: 30.0,
jitterFraction: 0.3
)
This is system reliability, not politeness.
Observability
If you ship this without instrumentation, you're guessing.
Track:
- Idempotency hit rate (replays)
- Duplicate-prevented count
- Retries per endpoint and per error class
- Time-to-success distribution
- Failures by category (transport vs auth vs validation)
Seeing idempotency hits in production isn't a problem—it's confirmation that reality is messy and your system is resilient.
Common Foot-Guns
- New key per retry — Defeats the entire design
- Client-only dedupe — Breaks under version drift, replays, multi-device
- No payload consistency check — Key reuse can hide different operations
- Retrying everything — Load spikes + bad UX even if idempotent
- No TTL strategy — Either useless (too short) or unbounded growth (too long)
Closing
Retries are inevitable on mobile. The only question is whether they cause duplication bugs.
Idempotency is the contract that turns retry chaos into deterministic behavior—effectively-once outcomes under real-world network conditions.
If you can answer these two questions, your system is probably solid:
- Who stores the idempotency key, and when?
- What happens if the same key arrives twice with different payloads?