DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Integrating OpenAI/GPT Models Into Your Web and Mobile Apps
  • HTTP API: Key Skills for Smooth Integration and Operation (Part 1)
  • How To Integrate Chatbot With an Android App
  • Guide for Voice Search Integration to Your Flutter Streaming App

Trending

  • How Multi-Agent Systems can replace most of Manual ML Validation decisions - The Karpathy Loop Approach
  • Policy-as-Code for AI Systems: Enforcing Governance at the Infrastructure Layer
  • Anthropic Builds Biology Lab to Test What Claude Can Do in the Real World
  • Building a Practical Cloud-Native Golden Path: A Guide to Kubernetes-Based Service Delivery, Self-Service, and Developer-Friendly Defaults
  1. DZone
  2. Software Design and Architecture
  3. Integration
  4. The Request Timed Out, But the Payment Succeeded: Building Retry-Safe Mobile APIs

The Request Timed Out, But the Payment Succeeded: Building Retry-Safe Mobile APIs

Build retry-safe mobile APIs that prevent duplicate transactions during network failures, timeouts, and automatic retries.

By 
Uthej Mopathi user avatar
Uthej Mopathi
DZone Core CORE ·
Sep. 28, 26 · Analysis
Likes (1)
Comment
Save
Tweet
Share
124 Views

Join the DZone community and get the full member experience.

Join For Free

Mobile networks are notoriously unreliable. A common scenario is when a user taps "Pay Now" in an app: the payment request reaches the server and is processed, but the network response never reaches the phone. The client assumes the request failed and retries, leading to the charge running twice. This is precisely the kind of bug that idempotency solves. 

Idempotency means that repeating the same operation has no additional effect, and the second attempt should recognize it's a duplicate and do nothing new. In practice, mobile engineers must treat payment or order APIs as idempotent by attaching unique operation identifiers to requests and deduplicating them on the backend. With this approach, even if the network drops a response or the user double-taps a button, the user is charged only once.

Achieving this involves coordination between the app and the server. On the client side, every payment or mutation request is given a persistent unique ID. For example, the app might generate a new UUID when the user submits a payment, save that operation in a local "outbox" or queue, and include the ID in the HTTP request:

Swift
 
let opID = UUID().uuidString
var request = URLRequest(url: URL(string: "/api/payments")!)
request.httpMethod = "POST"
request.setValue(opID, forHTTPHeaderField: "Idempotency-Key")
request.httpBody = /* JSON payload of the payment */


This custom header (Idempotency-Key) carries the operation's identity to the server. (Modern APIs often expect this header to ensure that POST is treated safely.) The client's logic must be persistent; before sending, it writes the operation (ID and payload) into local storage (e.g., SQLite or SharedPreferences) so that it can recover and retry if the app closes or the network is down. This pattern, sometimes called the outbox pattern, means the user's intent is recorded immediately. A background dispatcher can then drain the queue on network availability or app restart; it tries each pending request. If a send fails (timeout, no internet, 5xx error), the entry remains in the queue for a later retry. This ensures at-least-once delivery and the server will eventually see the request, even if the app crashes or the network is flaky.

But at-least-once alone would cause duplicates, so the server must be ready. When the backend receives the request with its Idempotency-Key, it first checks a deduplication store (for example, a database table keyed by this ID).

Java
 
String key = request.getHeader("Idempotency-Key");
PaymentResponse prev = idempotencyStore.lookup(key);
if (prev != null) {
    // We have processed this request before - return the original response
    return prev;
}
// No record of this key; proceed with processing
PaymentResponse result = processPayment(request.getBody());
// Store the result before returning it
idempotencyStore.insert(key, result);
return result;


If the key already exists, the server simply returns the stored result without charging again. This ensures that the second (or third) time the client re-sends, the user doesn't get double-charged. Stripe's API, for instance, works exactly this way: it saves the outcome of the first request for a given idempotency key, and any retry with the same key returns the same result. In effect, the combination of at-least-once delivery (the client keeps retrying) plus idempotent handling on the server yields an effectively-once outcome.

The server's idempotency store can be implemented with a simple database table that records each key and the operation's result. For instance, a processed_payments table might use the idempotency key as a primary key or unique constraint. The service then does an atomic INSERT ... ON CONFLICT DO NOTHING (PostgreSQL syntax) or equivalent. If the insert succeeds, the code proceeds with the payment and stores the result; if it fails because the key already exists, it knows this is a duplicate and can fetch the prior result. Wrapping the insert and the business operation in one database transaction avoids a race condition, as either both the key and payment record are written, or neither is. In SQL terms:

SQL
 
BEGIN;
INSERT INTO payments(idempotency_key, user_id, amount)
VALUES (:key, :userId, :amount)
ON CONFLICT (idempotency_key) DO NOTHING;
-- Check how many rows were inserted:
IF (INSERT was successful) THEN
    -- This is the first time seeing this key; perform the payment
    CALL process_payment(...);
    -- The payment service may record a transaction ID, etc. 
    COMMIT;
ELSE
    -- Key already existed: rollback any partial work
    ROLLBACK;
    -- Retrieve and return the original payment result
END IF;


Even if two identical requests arrive concurrently, the unique constraint ensures only one succeeds in its insert. The other can detect the conflict and simply return the saved response. The system design sandbox guide describes this approach as "The database enforces uniqueness and no separate check needed. This works well when the idempotency record belongs in the same database as the business data, since you can wrap both in a single transaction."

On the mobile side, it's also wise to guard against duplicates before the request is even sent. A simple in-memory or on-disk set of "seen" IDs can help reject retry loops after a crash or double tap. In Swift:

Swift
 
final class OperationDeduplicator {
    private var seen: Set<String> = []
    func shouldProcess(_ id: String) -> Bool {
        return seen.insert(id).inserted
    }
}


This OperationDeduplicator returns true only the first time an ID appears. Persisting this set across app launches (for example in Core Data or a file) makes the app resilient to a crash after the payment is sent but before the response arrives. On relaunch, the app knows it already handled that operation and won't enqueue it again.

It's important to integrate these pieces smoothly. A typical mobile flow might look something like this: the user submits a payment form, the app immediately generates a new opID (a UUID) and creates an operation record { id: opID, payload: {amount, items, ...} }. This record is saved locally. Then a background task picks it up, attaches opID as the Idempotency-Key header, and sends it. If the network call times out, the record stays queued. When the app regains connectivity or restarts, the dispatcher tries again. Because the same opID is used each time, the server knows to treat all retries as one. Only after the server successfully processes the payment does the app remove the operation from its queue. This pattern ensures retries and crashes do not cause duplicate side effects.

Some systems even use more granular controls. For example, if the backend involves multiple microservices, one service might call others, and each service should propagate the same idempotency key or a related correlation ID so that the entire transaction remains idempotent. Distributed tracing can help debug how a request flowed through the system. Ultimately, the goal is to capture the entire user action from UI tap through backend processing and make sure it's only applied once globally. This often means also having the backend return the same HTTP status and response body on every retry, so the client never gets an unexpected error.

Developers should simulate network failures and verify that retries do not cause double effects. Most important is to observe real production behavior, as logs or traces with the operation ID can tie multiple client attempts to a single transaction. If everything is correct, the system achieves effectively-once behavior where the payment occurs exactly once no matter how many times the client tries. As systemdesignsandbox summarizes, "at-least-once delivery + idempotent consumer = effective exactly-once". In practice, this means mobile apps can assume failures are not fatal and they can safely retry with the same key, knowing the server will protect against duplicates.

Conclusion

In summary, retry-safe mobile operations require treating each user action as an idempotent transaction. The client must persist a unique operation key and reuse it across retries, while the server must detect previously processed keys and prevent duplicate side effects. Combining durable client operations with server-side idempotency allows payments and other critical transactions to survive timeouts, crashes, and unreliable networks without being executed twice.

mobile app Requests Integration

Opinions expressed by DZone contributors are their own.

Related

  • Integrating OpenAI/GPT Models Into Your Web and Mobile Apps
  • HTTP API: Key Skills for Smooth Integration and Operation (Part 1)
  • How To Integrate Chatbot With an Android App
  • Guide for Voice Search Integration to Your Flutter Streaming App

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook