<?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: Ifeoluwa Obayemi</title>
    <description>The latest articles on DEV Community by Ifeoluwa Obayemi (@tech_queen).</description>
    <link>https://dev.to/tech_queen</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%2F1563620%2F0a159656-c23d-412f-83c5-e27376690395.jpg</url>
      <title>DEV Community: Ifeoluwa Obayemi</title>
      <link>https://dev.to/tech_queen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tech_queen"/>
    <language>en</language>
    <item>
      <title>Idempotency: Why "Just Retry It" Breaks More Than It Fixes</title>
      <dc:creator>Ifeoluwa Obayemi</dc:creator>
      <pubDate>Wed, 15 Jul 2026 16:48:01 +0000</pubDate>
      <link>https://dev.to/tech_queen/idempotency-why-just-retry-it-breaks-more-than-it-fixes-27cb</link>
      <guid>https://dev.to/tech_queen/idempotency-why-just-retry-it-breaks-more-than-it-fixes-27cb</guid>
      <description>&lt;p&gt;A payment request goes out. The network hiccups. The client never sees a response, so it does the reasonable thing and retries. Thirty seconds later, a customer gets charged twice for one order, and someone on support is trying to explain to them why.&lt;/p&gt;

&lt;p&gt;Nobody wrote a bug here. The client behaved exactly the way retry logic is supposed to behave. The problem is one layer deeper: the system let a "did this actually happen" question get answered twice, and each answer was "yes, charge them."&lt;/p&gt;

&lt;p&gt;That's idempotency, or rather, the absence of it. It's one of those concepts that's simple to state and easy to get wrong in practice, and once you've been burned by it once, you start seeing the gap everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why retries are unavoidable, not optional&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a distributed system, a timeout doesn't tell you the request failed. It tells you that you didn't get a response in time. The request might have never arrived. It might have arrived, succeeded, and the response got lost on the way back. From the caller's side, those two situations are indistinguishable, and the only reasonable move is to retry.&lt;/p&gt;

&lt;p&gt;That's not a design flaw you can engineer away. Networks drop packets, servers restart mid-request, load balancers time out connections. Any system that talks to another system over a network will eventually face this exact ambiguity. Which means the real question isn't "should we retry." It's "what happens when we do."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What idempotency actually means&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An operation is idempotent if doing it once and doing it five times leave the system in the same state. &lt;code&gt;SET balance = 100&lt;/code&gt; is idempotent. Run it once or five times, the balance ends up at 100 either way. &lt;code&gt;ADD 100 to balance&lt;/code&gt; is not. Run that five times and you've added 500, which is exactly the double-charge scenario from the top of this article, just with extra zeros.&lt;/p&gt;

&lt;p&gt;Notice these two operations can express the same intent ("this balance should reflect a $100 deposit") in a way that's either safe to retry or actively dangerous to retry, depending purely on how you phrased it. That's the part that catches people off guard. Idempotency isn't really about retries as a mechanism. It's a property of how you designed the operation in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this shows up whether you notice it or not&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP.&lt;/strong&gt; The spec has opinions here. &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; are supposed to be idempotent. &lt;code&gt;POST&lt;/code&gt; is not, which is exactly why "just retry the POST" is the instinct that causes duplicate orders, duplicate emails, and duplicate charges. If your API exposes a &lt;code&gt;POST /charges&lt;/code&gt; endpoint with no other protection, every client retry is a coin flip on whether the customer gets billed twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Message queues.&lt;/strong&gt; Most queues (SQS, Kafka, RabbitMQ, pick one) give you at-least-once delivery, not exactly-once, because exactly-once across a network is a much harder guarantee than it sounds like and most systems don't actually need it if they handle duplicates correctly downstream. That "at-least-once" part means your consumer will, eventually, see the same message twice. If processing that message means charging a card or sending an email, your consumer needs to be the one enforcing idempotency, because the queue isn't going to do it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database writes.&lt;/strong&gt; An &lt;code&gt;INSERT&lt;/code&gt; fails if you run it twice against a unique key, which is annoying until you realize that's actually a gift: it turned an ambiguous "did this insert happen" question into a clear error you can catch and treat as "yes, it already happened." An &lt;code&gt;UPSERT&lt;/code&gt; goes further and just makes the operation naturally idempotent from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making an operation idempotent on purpose&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The standard fix is the idempotency key: the client generates a unique ID for a logical attempt (a UUID is fine) and sends it along with the request. The server keeps a record of keys it's already processed. Before doing any real work, it checks: have I seen this key before? If yes, return the same result as last time without processing anything twice. If no, do the work and record the key as part of the same operation.&lt;/p&gt;

&lt;p&gt;That last part, "as part of the same operation," is where most naive implementations quietly break. If you process the charge and then write the idempotency key in a separate step, you've just built a smaller version of the same problem: a crash between those two steps means a retry sees no record of the key and processes the charge again. The fix is to make the write and the key-check atomic, typically by writing both inside the same database transaction, or by using the database's own uniqueness constraints to reject the duplicate outright.&lt;/p&gt;

&lt;p&gt;A rough shape of the check, language aside:&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;begin&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;processed_requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stored_response&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;do_the_actual_work&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;processed_requests&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt;
&lt;span class="k"&gt;commit&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The specifics vary by stack, but the shape doesn't: check and record have to succeed or fail together, or you haven't actually solved anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotchas that show up later&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope and expiration.&lt;/strong&gt; An idempotency key needs a defined lifetime and a defined scope (per customer, per endpoint, whatever fits). Keep keys around forever and your dedupe table grows without bound. Expire them too aggressively and a slow retry (say, a client that waited ten minutes to retry after some other failure) sails right past the check and processes twice anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial success.&lt;/strong&gt; Sometimes the original request actually succeeded, but something else downstream failed, like the confirmation email. A well-meaning retry of "the whole operation" might redo the parts that worked fine the first time. This is usually the sign that a single "operation" needs to be broken into smaller idempotent steps, each individually safe to retry, rather than treated as one big all-or-nothing block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-generated keys done wrong.&lt;/strong&gt; If the client regenerates a new key every time it retries (instead of reusing the same key for the same logical attempt), you've disabled the entire mechanism without any errors to tell you so. This is a surprisingly common bug, usually introduced by whatever auto-retry wrapper the client's HTTP library uses by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual takeaway&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Idempotency isn't a resilience feature you bolt on after the fact. It's a decision that belongs in the API contract, right next to the request and response shapes. Every endpoint that can plausibly cause a side effect (money moving, an email sending, a record getting created) is a candidate for "what happens if this gets called twice," and that question is a lot cheaper to answer at design time than at 3 AM when support is asking why a customer got charged twice for one order.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
