Skip to content

Build1 publisher2 min readPublished

Duplicate taps arrive at the server carrying one idempotency key

A dev.to walkthrough mints the key once per user intent, caches it on the order view model, and leaves the server to recognize repeats, because a disabled Buy button depends on a run loop that may not run in time.

The Engineer · Build desk

Illustration accompanying Duplicate taps arrive at the server carrying one idempotency key

What happened

  • The post opens on a user whose Buy tap gets no immediate feedback on a slow network, who taps several more times, and who ends up with multiple matched orders a few seconds later.
  • Its argument against a client-side guard is mechanical: UI state changes happen on the main thread and wait for the run loop, so several tap events can be handled before the button visibly disables.
  • The proposed shape puts an idempotencyKey UUID inside the OrderDTO alongside symbol, side, price and lot, so the key travels with the order rather than being minted at send time.
  • On the server side, the post stores the key against the order and its state, so three POSTs to /order/submit carrying one Idempotency-Key resolve to a single order.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Deduplication lives in the server's key store. A write endpoint without one still books an order per request, however carefully the app caches its intent.
  • exposure Storage and key lifetime are left to a follow-up post, so the period in which a repeat key is recognized is undefined. A retry that arrives after the store forgets the key becomes a second order.
  • decision iOS teams have a format choice with a dependency attached: Foundation's UUID() gives version 4, and the timestamped version 7 the post prefers for ordering needs a third party library.

Follow a second tap through the code the post prints. `submit()` validates, finds `pendingOrder` already set, and sends that same `OrderDTO` again [6]. The tap becomes a network request either way. What the cache changes is what the request contains. The definition the post borrows from Designing Data Intensive Applications is that "An idempotent operation is one that you can perform multiple times, and it has the same effect as if you performed it only once" [4], and the post states the division of labour as "The client declares the intent, and the server guarantees that the intent is not executed twice" [13].

The assignment happens before the suspension point. In `submit()`, `pendingOrder` is read and written in one synchronous stretch, and only after that does the code await `orderService.buy(order)` [6]. The class carrying it is annotated `@MainActor` and `@Observable`, and `pendingOrder` is `private(set)` [6].

On a repeat arrival the server looks up the key it stored against the order and returns what it already has, `{"order_id": "1234", "status": "pending"}` [12]. Once the order matches, the same key returns a status of matched with a payload [12]. A retry therefore doubles as a status read. The app's error path keeps the pending order so the next attempt reuses it, and only a successful call sets it back to nil [7].

The version of this that does nothing is a UUID generated inside `buy()`, which produces a fresh key on every call [8]. On the part that matters the post is blunt: "The important is not the UUID itself. It is when that UUID is created and how long we keep it" [9]. The key changes when the user forms a new intent, such as after the previous order has completed and they decide to place another [9].

The snippet is a sketch. The signature reads `func submit() async throw`, and the line that succeeds binds a new `order` that shadows the one it just sent [16].

For the run-loop argument to transfer to your app, the main thread has to be busy at the moment of the tap, and busy enough that a second tap event is handled before the disabled state renders [3]. The post's framing sentence is "Idempotency is an end-to-end contract, not just a backend trick" [14]. It carries no measurements: no stall duration, no observed rate of duplicate orders [17].

What to watch

  • The promised follow-up on key storage, key lifetime and simultaneous duplicate requests, and whether it names a unique index or a lock.
  • Whether the walkthrough addresses a pendingOrder that dies with its view model, for example after an app relaunch mid-order.
  • Any measured number at all: a main-thread stall duration or an observed duplicate-order rate would test the run-loop claim.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories