Idempotency + Retries That Survive Reality

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:

  1. The request never reached the server
  2. The server processed it, but the response never reached the client
  3. 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:

  1. Look up (user, endpoint, idempotencyKey) in an idempotency store
  2. If found → replay the stored outcome
  3. 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

  1. New key per retry — Defeats the entire design
  2. Client-only dedupe — Breaks under version drift, replays, multi-device
  3. No payload consistency check — Key reuse can hide different operations
  4. Retrying everything — Load spikes + bad UX even if idempotent
  5. 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:

  1. Who stores the idempotency key, and when?
  2. What happens if the same key arrives twice with different payloads?

Read more