Designing Idempotent APIs for Mobile Clients

Dhruval Dhameliya·January 7, 2026·7 min read

How to design APIs that handle duplicate requests safely, covering idempotency keys, server-side deduplication, and failure scenarios specific to mobile networks.

Mobile networks drop connections mid-request. The client does not know if the server received the request, processed it, or failed. Without idempotency, retrying that request can charge a user twice, create duplicate orders, or send duplicate messages. This post covers how to design APIs that make retries safe.

See also: Designing Retry and Backoff Strategies for Mobile Networks.

Context

Mobile clients operate on unreliable networks where request outcomes are frequently ambiguous. A POST request to create an order might succeed on the server, but the response never reaches the client due to a network timeout. The client retries, and now there are two orders. Idempotent API design eliminates this class of bugs.

Problem

Design an API layer that:

  • Allows clients to safely retry any mutating request
  • Detects and deduplicates duplicate requests server-side
  • Returns the original response for duplicate requests
  • Handles edge cases around key expiration, concurrent requests, and partial failures

Constraints

ConstraintDetail
Key storageIdempotency key state must be stored for at least 24 hours
LatencyDeduplication check must add less than 10ms to request latency
ConcurrencyTwo identical requests arriving within milliseconds must be handled correctly
Storage costCannot store full response bodies indefinitely
Client diversityAndroid, iOS, and web clients with different retry behaviors

Design

Idempotency Key Protocol

The client generates a unique key per logical operation and sends it in a request header:

class ApiClient(private val httpClient: OkHttpClient) {
 
    fun createOrder(order: OrderRequest): Response {
        val idempotencyKey = UUID.randomUUID().toString()
        return executeWithRetry(idempotencyKey) {
            httpClient.newCall(
                Request.Builder()
                    .url("$BASE_URL/orders")
                    .header("Idempotency-Key", idempotencyKey)
                    .post(order.toJsonBody())
                    .build()
            ).execute()
        }
    }
 
    private fun executeWithRetry(
        key: String,
        maxRetries: Int = 3,
        block: () -> Response
    ): Response {
        repeat(maxRetries) { attempt ->
            try {
                val response = block()
                if (response.isSuccessful || response.code != 500) {
                    return response
                }
            } catch (e: IOException) {
                if (attempt == maxRetries - 1) throw e
            }
            Thread.sleep(exponentialBackoff(attempt))
        }
        throw RetryExhaustedException()
    }
}

The key remains the same across retries of the same logical operation. A new operation generates a new key.

Server-Side Deduplication

Request with Idempotency-Key
    |
    v
Check key in Redis
    |
    +-- Key not found -> Acquire lock -> Process request -> Store result -> Return response
    |
    +-- Key found, result stored -> Return stored response
    |
    +-- Key found, processing in progress -> Return 409 Conflict (retry later)

Server-side implementation (pseudocode):

handle_request(request):
    key = request.header("Idempotency-Key")
    if key is null:
        return 400 "Idempotency-Key header required"

    existing = redis.get("idempotency:{key}")

    if existing is not null:
        if existing.status == "processing":
            return 409 "Request is being processed, retry later"
        return existing.response  // Return cached response

    // Acquire lock with TTL
    acquired = redis.set("idempotency:{key}",
                         {status: "processing"},
                         NX, EX=300)  // 5 min lock TTL
    if not acquired:
        return 409 "Concurrent request detected"

    try:
        response = process_request(request)
        redis.set("idempotency:{key}",
                  {status: "completed", response: response},
                  EX=86400)  // 24 hour TTL
        return response
    catch error:
        redis.del("idempotency:{key}")  // Allow retry
        return 500 error

Key Design Decisions

Who generates the key? The client. Server-generated keys require an extra round trip and do not solve the original problem (the request to get the key can itself be lost).

What is stored? The full HTTP response (status code, headers, body). The client expects the exact same response on retry.

How long is the key valid? 24 hours. Long enough to cover extended offline periods, short enough to bound storage costs.

What about GET requests? GET requests are inherently idempotent. No key needed.

Related: Handling Partial Failures in Distributed Mobile Systems.

Handling Concurrent Duplicate Requests

Two identical requests arriving simultaneously (e.g., user double-taps a button):

ScenarioHandling
Request A acquires lock, Request B arrivesB gets 409, retries after backoff
Request A completes, Request B retriesB gets A's cached response
Request A fails, lock is deleted, Request B retriesB processes normally

Client-Side Key Management

class IdempotencyKeyManager(private val prefs: SharedPreferences) {
 
    fun getOrCreateKey(operationId: String): String {
        val existing = prefs.getString("idem_key_$operationId", null)
        if (existing != null) return existing
 
        val key = UUID.randomUUID().toString()
        prefs.edit().putString("idem_key_$operationId", key).apply()
        return key
    }
 
    fun clearKey(operationId: String) {
        prefs.edit().remove("idem_key_$operationId").apply()
    }
}

The key is persisted to survive process death. If the app crashes mid-request, the retry after restart uses the same key.

Trade-offs

DecisionUpsideDownside
Client-generated keysNo extra round trip, works offlineClient bugs can reuse keys incorrectly
Full response cachingExact response replayStorage cost for large response bodies
24-hour TTLCovers offline scenariosLate retries after TTL may cause duplicates
409 for concurrent requestsPrevents double processingClient must handle an additional status code
Redis for key storageFast lookups, built-in TTLKey data lost on Redis failure

Failure Modes

  • Redis unavailable: Two options. (a) Fail open: process without deduplication, accept potential duplicates. (b) Fail closed: return 503, client retries later. Choose based on the cost of duplicates vs. unavailability.
  • Server crashes after processing but before storing result: The lock TTL expires, the next retry processes again. For financial operations, use a database transaction that atomically writes the result and the idempotency record.
  • Client reuses key for different operations: Server returns the cached response from the first operation. Validate that the request body hash matches the stored request to detect this.
  • Key TTL expires before client retries: The operation processes again. For critical operations, extend TTL or use permanent storage with periodic cleanup.

Scaling Considerations

  • Redis sharding: Shard by idempotency key hash. Each shard handles a subset of keys independently.
  • Response body size: For large responses, store only a hash and status code. Return a flag indicating "this was a duplicate" and let the client fetch the resource by ID.
  • Multi-region: If the API is deployed in multiple regions, the idempotency store must be globally consistent or region-aware. A regional Redis with replication lag can allow duplicates during failover.

Observability

  • Track: duplicate request rate, lock contention rate (409 responses), key TTL expiration before retry, Redis latency for idempotency checks.
  • Alert on: duplicate rate exceeding 5% (indicates client retry storms), lock contention rate exceeding 1% (indicates UI double-tap issues), Redis latency exceeding 50ms.

Key Takeaways

  • Every mutating API endpoint should accept an idempotency key. Retrofitting idempotency is significantly harder than building it in.
  • The client generates the key and persists it to survive process death. Same logical operation, same key.
  • Store the full response server-side. A duplicate request must return the exact original response.
  • Handle concurrent duplicates explicitly with locking and 409 responses.
  • For financial operations, use database-level atomicity, not just Redis. Redis data loss can result in double charges.

Further Reading

Final Thoughts

Idempotency is not a feature. It is a correctness requirement for any API consumed over unreliable networks. The cost of implementing it is a Redis lookup per request. The cost of not implementing it is duplicate transactions, corrupted state, and eroded user trust.

Recommended