Mobile Design Rounds
GuideAndroidiOS8 min read

Idempotency keys on mobile: retry without sending twice

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: The five-step method · Staff-level guide.

In short: An idempotency key is an ID that the app creates once for one action and sends with every retry. The server saves the first result for that key and returns it again, so the action happens only once.

The question

A phone sends a request. The server gets it and does the work. The reply is lost on the way back. The app sees an error or a timeout.

The app does not know what happened. It has two bad choices. If it gives up, the user may lose a message or a booking. If it sends again, the user may be charged twice.

People get three things wrong here:

  • They think a timeout means the request failed. It only means the app did not hear back. [Opinion]
  • They think retries are safe if the user is careful. A phone can retry by itself, with no user action, after the app restarts. [Opinion]
  • They think the server fixes this alone. It cannot, because the server cannot tell a repeat from a second real action without help from the app. [Opinion]

An operation is idempotent when doing it twice has the same result as doing it once. A GET request is like this already. A POST request that creates something is not. The idempotency key makes a POST safe to repeat.

A lost reply is also normal. Apple's page for waitsForConnectivity says the setting works only while the connection starts. If a connection opens and then drops, the app gets an error (Apple: waitsForConnectivity). [Verified: Apple documentation, 2026-10-14] So a setting cannot remove the lost reply. The design has to handle it.

What an idempotency key is

The idempotency key is a unique ID that travels with the request. The server uses it to see that a request is a repeat. Stripe, a payment company, documents this pattern in its API. It sends the key in an Idempotency-Key header (Stripe: idempotent requests). [Verified: Stripe API reference, 2026-10-14]

An IETF draft for a standard header with this name exists. The draft has expired. It is not a standard (IETF datatracker). [Verified: IETF page, 2026-10-14] I use the name because Stripe uses it, not because it is a standard.

Who creates it, and where it lives

The app creates the key. The server cannot, because the server has not seen the request yet. Stripe says the client makes the key and suggests V4 UUIDs. A UUID is a random unique ID. Stripe allows up to 255 characters (Stripe: idempotent requests). [Verified: Stripe API reference, 2026-10-14]

Both platforms can make one:

  • Android: UUID.randomUUID() returns a type 4 UUID. The docs say a cryptographically strong generator makes it (Android: UUID). [Verified: Android reference, 2026-10-14]
  • iOS: UUID() creates a UUID with RFC 4122 version 4 random bytes (Apple: UUID). [Verified: Apple documentation, 2026-10-14]

The key must be saved with the pending row. Save it in the same database write that records the action. Do this before the first network call. Then a restart cannot lose it.

If the app makes a new key on each try, the key does nothing. Each retry then looks like a new action. [Opinion]

What the server must do

The server has one job. It saves the first result for each key and returns that result on every repeat. For Stripe API v1, the server saves the first reply. It keeps the status code and the body. Verified: [Stripe page, 2026-10-14] The same page lists these details:

  • Retries with the same key return the saved reply, including 500 errors.
  • A request that fails validation is not saved, so you can retry it.
  • A request that collides with one still running is not saved either.
  • The server compares the new parameters with the first request. It returns an error if they differ.

Stripe API v2 works in another way. A retry does not repeat a successful step, and Stripe tries to finish failed work. [Verified: Stripe API reference, 2026-10-14] I describe only v1 here.

Which key is safe

Key ideaSame key on retry?Different key for a new action?My view
New random key on every tapNoYesFails. A retry is a new action.
A timestampOnly if savedAlmostWeak. Two taps in one millisecond can collide.
A counter that restarts on reinstallOnly if savedNoFails. The counter repeats old numbers.
A hash of the request bodyYesNoFails when two real, equal actions happen.
A random UUID saved with the pending rowYesYesBest choice.

All cells in the last column are [Opinion].

A timestamp is not random. A reinstall wipes the app data, so a counter starts again at 1. The server may have saved a result for key 1 already. It would then return an old result for a new action. [Opinion]

Key lifetime

A key should live as long as the action can still be retried. After that, the app can delete it with the row.

The server also keeps keys for a limited time. For API v1, Stripe says keys can be removed once they are at least 24 hours old. A key that is reused after that starts a new request (Stripe: idempotent requests). [Verified: Stripe API reference, 2026-10-14]

So the app must stop retrying before the server forgets the key. Your own backend sets its own time window. [Opinion]

A new action needs a new key. Stripe also errors if you change the parameters under an old key. So a changed cart is a new key. [Opinion, based on the parameter rule above]

When I would choose which

  • The action costs money or cannot be undone. Use a saved UUID key and make the server save the first result. The booking-app case is this one.
  • The action creates a visible item. Use an ID that the app makes for the item, as the chat app does with clientId. The server returns the original result for the same ID.
  • The action is a batch of events. Use a batch ID and an event ID. The analytics-sdk case does this.
  • The action is a read. Use no key. A read is idempotent already.
  • The action replaces a whole value. A key may be unneeded. Setting a name to "Ana" twice has the same result as once. [Opinion]

Mistakes I see

These are my reading of designs, not claims about any company.

  • The key is made in memory and lost when the process dies. The retry after restart gets a new key.
  • The key is made inside the retry loop, so each try has a new key.
  • The same key is reused for a new action, such as a changed cart.
  • The app has a key, but the server does not save results. The key then does nothing.
  • The key is sent on a GET request. Stripe says this has no effect (Stripe: idempotent requests). [Verified: Stripe API reference, 2026-10-14]
  • The key holds personal data. Stripe tells you to avoid this. [Verified: Stripe API reference, 2026-10-14]
  • The app retries a permanent error, such as a bad request, again and again. The key does not fix a request that the server rejects. [Opinion]

On Android

WorkManager keeps work across app restarts and device reboots, and it stores the work in an internal SQLite database (Android: persistent work). [Verified: Android documentation, 2026-10-14]

The worker can return Result.retry(). Then WorkManager runs it again after a backoff delay. The first delay can be no less than 10 seconds (Android: define your work). [Verified: Android documentation, 2026-10-14]

This is why the key matters. WorkManager can run the same worker more than once. The worker must read the saved key from the row, not make a new one. [Opinion]

On iOS

iOS has no single call that does what WorkManager does for this job. The app needs its own saved pending row, in Core Data, SwiftData or a file. The offline-first guide covers the Apple pages for these. [Opinion]

The key goes in the row before the request starts. A URLSession request then carries it in the Idempotency-Key header. If the app is stopped, the next launch reads the row and sends the same key. [Opinion]

Remember the earlier fact. waitsForConnectivity does not help once a connection opens and drops (Apple: waitsForConnectivity). [Verified: Apple documentation, 2026-10-14] The key covers that gap.

See it in a case

  • Chat app: the clientId makes a send safe to retry. The build guide shows the worker and the schema.
  • Booking app: one key for each payment attempt, saved before the first send. The build guide has the code for both platforms.
  • Analytics SDK: the batch ID and event ID let the server skip repeats. The build guide shows the queue.

To see a lost reply, open the retry explorer. Change the retry rules and the server replies. Watch what happens when a reply is lost.

Words used here

  • Idempotent: doing it twice has the same result as doing it once.
  • Idempotency key: the ID that lets the server see a repeat.
  • Acknowledgement: the reply that says the server got the request.
  • UUID: a random unique ID.
  • Outbox: a saved list of changes that the server has not confirmed.
  • WorkManager: the Android tool that runs saved work again after a restart.
  • Exponential backoff: waiting longer after each failure.

Sources

Words on this page (8)
Exponential backoff
Waiting longer after each failure. For example, wait 1 second, then 2, then 4.
Hash
A short code made from the bytes of a file. Apps compare the codes to see if two files are the same.
Idempotency key
A unique ID that the app sends with a request. It lets the server see a repeat request and not do the work twice.
Idempotent
Doing it twice has the same result as doing it once. This makes a retry safe.
Offline-first
A design where the app works without a network. It saves each change on the phone first and sends it later.
SwiftData
Apple's library for saving data on an iPhone. It has the same role as Room on Android.
UUID
A random unique ID. Two devices almost never make the same one.
WorkManager
Android's tool for background work that must run later. The work survives when the app closes or the phone restarts.

Read the whole glossary

Verification notes (last reviewed 14 Oct 2026)

written on 2026-10-14. I fetched the raw text of the Stripe idempotent requests page, the Android persistent work page, the define-work page and the Android UUID reference. I fetched the Apple JSON for UUID and waitsForConnectivity, and the IETF datatracker page. The UUID JSON from Apple has only an abstract and the initializer line, no discussion text. I did not verify any Stripe v2 detail beyond the one sentence on the page. I did not verify the Android Room or Apple Core Data and SwiftData pages for this guide; the iOS pending-row advice is my opinion. I did not verify how any app backup restores a counter after reinstall, so the counter row is my reasoning. The table, the choice list and the mistakes are my reading. I made no company claims and no frequency claims. The cases were read in the repository, not re-checked against live pages. The retry explorer link was not opened in a browser.

Keep reading

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.