<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Darshil Somani</title>
    <description>The latest articles on DEV Community by Darshil Somani (@darshilsomani019).</description>
    <link>https://dev.to/darshilsomani019</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069338%2F995f361c-87c3-4cab-9ed7-29296aadeb45.png</url>
      <title>DEV Community: Darshil Somani</title>
      <link>https://dev.to/darshilsomani019</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darshilsomani019"/>
    <language>en</language>
    <item>
      <title>Idempotency Keys: Designing APIs That Survive Retries</title>
      <dc:creator>Darshil Somani</dc:creator>
      <pubDate>Mon, 10 Aug 2026 00:05:19 +0000</pubDate>
      <link>https://dev.to/darshilsomani019/idempotency-keys-designing-apis-that-survive-retries-1g4p</link>
      <guid>https://dev.to/darshilsomani019/idempotency-keys-designing-apis-that-survive-retries-1g4p</guid>
      <description>&lt;p&gt;Every API that sits behind an unreliable network eventually faces the same problem: a client sends a request, the connection drops before the response arrives, and the client has no idea whether the operation happened. Did the payment go through? Did the order get created twice? The client's only safe move is to retry — which means your server needs a story for what happens when the same "create this thing" request arrives more than once.&lt;/p&gt;

&lt;p&gt;That story is idempotency keys, and getting the details right is more subtle than it first looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;The client generates a unique token — typically a UUID — once per logical operation, and attaches it to every retry of that operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /orders
Idempotency-Key: 7c3fd9a2-df01-4b3e-9a55-1e5f9b6b6d55

{"sku": "WIDGET-1", "qty": 2}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server's job is to guarantee that no matter how many times a request with that key arrives, the side effect (charging a card, creating an order, sending an email) happens at most once, and every retry gets back the same response the original request would have produced.&lt;/p&gt;

&lt;p&gt;Note what this is not: it is not deduplicating by request body. Two requests with identical bodies but no key are legitimately two different orders for two widgets. The key is what marks them as "the same attempt," not the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach, and why it breaks
&lt;/h2&gt;

&lt;p&gt;A common first pass is a table like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On each request: check if the key exists, and if so return the cached response; otherwise do the work and insert the result. This looks right and is wrong in a specific way: it has a race condition. Two retries can arrive concurrently (a client that timed out and fired a second attempt while the first was still in flight), both miss the cache check, and both execute the underlying operation. You've now charged the card twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the check-and-do atomic
&lt;/h2&gt;

&lt;p&gt;The fix is to claim the key before doing the work, using the database's own concurrency control rather than an application-level check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in_progress'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;INSERT&lt;/code&gt; returns a row, you won this key — proceed with the operation, then update the row with the real response and flip &lt;code&gt;status&lt;/code&gt; to &lt;code&gt;'completed'&lt;/code&gt;. If it returns nothing, someone else already claimed this key. Now you have three sub-cases to handle explicitly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;status = 'completed'&lt;/strong&gt; — return the stored response verbatim. This is the retry-after-success path, and it's the one people design for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;status = 'in_progress'&lt;/strong&gt; — another request with the same key is still executing right now, most likely a genuinely concurrent retry (client-side timeout that fired a duplicate before the first attempt returned). The correct response here is usually &lt;code&gt;409 Conflict&lt;/code&gt; with a "retry shortly" hint, not silently blocking, because blocking ties up a connection for as long as the original request takes and can cascade under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;status = 'failed'&lt;/strong&gt; — the original attempt errored out before completing. Whether this is safe to retry depends on whether the failure happened before or after the side effect committed, which is exactly why the next section matters.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ordering the side effect and the key update
&lt;/h2&gt;

&lt;p&gt;The dangerous window is between "the side effect happened" and "the key record says it happened." If your payment provider charges the card and then your process crashes before writing &lt;code&gt;status = 'completed'&lt;/code&gt;, the key is stuck at &lt;code&gt;in_progress&lt;/code&gt; (or &lt;code&gt;failed&lt;/code&gt;, if you have a crash handler) forever, and a legitimate retry will either be rejected or — worse, if you designed the failed-state to allow retry — will charge the card again.&lt;/p&gt;

&lt;p&gt;Two practical ways out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same transaction, when possible.&lt;/strong&gt; If the side effect is itself a database write (create an order row), do it in the same transaction as the key update. Either both commit or neither does, and there's no window at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External side effect, idempotent downstream.&lt;/strong&gt; If the side effect is a call to a third party (a payment processor), pass &lt;em&gt;that call&lt;/em&gt; an idempotency key too — most payment APIs (Stripe, Braintree, Razorpay) support this natively. Then your recovery path for a crashed &lt;code&gt;in_progress&lt;/code&gt; row is: re-issue the downstream call with the same downstream key. If it already happened, the processor returns the original result instead of double-charging. Your own key table becomes a cache in front of an idempotent downstream operation, not the sole source of truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key scope and expiry
&lt;/h2&gt;

&lt;p&gt;Keys should be scoped per-endpoint or per-operation-type, not global — a key valid for &lt;code&gt;POST /orders&lt;/code&gt; colliding with an unrelated key namespace for &lt;code&gt;POST /refunds&lt;/code&gt; is a bug waiting to happen. Prefix keys by route, or use a composite primary key of &lt;code&gt;(route, key)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Expiry matters too. Clients reuse UUID generation logic, and a key space that never expires grows forever and risks accidental key reuse across unrelated operations months apart. Twenty-four hours is a common TTL: long enough to cover any realistic retry window (including a client that retries after being offline overnight), short enough to bound table growth. Expire with a background job or a partial index plus periodic delete, not by checking &lt;code&gt;created_at&lt;/code&gt; on every read — that still requires deciding what "expired" means for a key someone is retrying right at the boundary, so most implementations simply reject requests bearing an expired key and let the client mint a new one, which is equivalent to treating it as a fresh operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling key reuse with a different payload
&lt;/h2&gt;

&lt;p&gt;What if a client sends the same idempotency key twice, but with a different request body the second time? This usually indicates a client bug — reusing a key across two logically different operations — and the safe response is to reject it (&lt;code&gt;422&lt;/code&gt; with an explanit error) rather than either silently applying the new payload or silently returning the old response. To detect it cheaply, store a hash of the normalized request body alongside the key and compare on the second arrival; a mismatch is the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;The property you actually want to verify is: N concurrent requests with the same key produce exactly one side effect and N identical successful responses (or, for the couple of concurrent duplicates, one success and N-1 &lt;code&gt;409&lt;/code&gt;s that a client is expected to retry). Write this as an actual concurrency test — fire the same key from multiple threads or async tasks at a test server backed by a real database, not a mock, and assert on the row count in the underlying table. The race condition in the naive approach above is invisible in a single-threaded test and only shows up under real concurrency, which is exactly the condition it exists to handle.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>api</category>
      <category>distributedsystems</category>
      <category>database</category>
    </item>
  </channel>
</rss>
