Designing Idempotent APIs for Mobile Clients
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
| Constraint | Detail |
|---|---|
| Key storage | Idempotency key state must be stored for at least 24 hours |
| Latency | Deduplication check must add less than 10ms to request latency |
| Concurrency | Two identical requests arriving within milliseconds must be handled correctly |
| Storage cost | Cannot store full response bodies indefinitely |
| Client diversity | Android, 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):
| Scenario | Handling |
|---|---|
| Request A acquires lock, Request B arrives | B gets 409, retries after backoff |
| Request A completes, Request B retries | B gets A's cached response |
| Request A fails, lock is deleted, Request B retries | B 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
| Decision | Upside | Downside |
|---|---|---|
| Client-generated keys | No extra round trip, works offline | Client bugs can reuse keys incorrectly |
| Full response caching | Exact response replay | Storage cost for large response bodies |
| 24-hour TTL | Covers offline scenarios | Late retries after TTL may cause duplicates |
| 409 for concurrent requests | Prevents double processing | Client must handle an additional status code |
| Redis for key storage | Fast lookups, built-in TTL | Key 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
- Designing Rate Limiting for Mobile APIs: Rate limiting strategies for APIs consumed by mobile clients, covering token bucket algorithms, client identification, degradation modes,...
- Designing APIs With Mobile Constraints in Mind: How to design backend APIs that account for mobile-specific constraints: bandwidth, latency, battery, intermittent connectivity, and long...
- Designing an Experimentation Platform for Mobile Apps: System design for a mobile experimentation platform covering assignment, exposure tracking, metric collection, statistical analysis, and ...
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
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.