Idempotency in Programming: Why Retries Without It Will Break You

You send a payment request. The network hiccups. You retry. The charge goes through twice. Your user opens a ticket, your on-call engineer wakes up at 3am, and your PM starts asking questions you don’t want to answer. This is not a hypothetical — it happens in production every week to teams that treat retries as “just send it again.” The fix is idempotency, and it’s not complicated once you actually look at it


TL;DR: Quick Takeaways

  • Idempotency means running the same operation N times produces the same result as running it once — no double charges, no duplicate rows.
  • POST is not idempotent by default. GET, PUT, and DELETE are. This distinction will save your production database someday.
  • An idempotency key is a client-generated UUID tied to a request. The server stores it and refuses to process the same key twice.
  • Redis TTL + SQL ON CONFLICT DO NOTHING covers 90% of real-world deduplication needs without overengineering.

What is Idempotency and Why Should You Care?

Here is the most honest definition: an operation is idempotent if you can repeat it ten times and the system ends up in exactly the same state as after the first call. Not “similar state.” Not “mostly the same.” Identical. The concept of idempotency in programming is not academic — it’s the difference between a payment system that works under load and one that silently creates duplicate orders when AWS has a bad afternoon.

Think about pressing an elevator button. You hit “floor 5” once. You hit it five more times because you’re impatient. The elevator still goes to floor 5 once. That’s idempotency. Now think about a “Submit Order” button with no debounce and a slow network. The user clicks three times. Your backend processes three orders. That’s the absence of idempotency, and it’s your bug, not the user’s.

Idempotent Operation Examples: From Simple Math to Database UPDATE

Multiplication by 1 is idempotent. x * 1 * 1 * 1 always returns x. Setting a value is idempotent: SET user.status = 'active' run three times leaves the status as ‘active’ — same result every time. But incrementing is not: UPDATE counter SET value = value + 1 run three times changes state three times. This distinction shows up constantly in database design, and mixing up the two categories is a classic source of subtle production bugs.

-- Idempotent: safe to run multiple times
UPDATE users SET status = 'verified' WHERE id = 42;

-- NOT idempotent: each run changes state
UPDATE accounts SET balance = balance - 100 WHERE id = 42;

-- Idempotent version of the debit:
UPDATE accounts
SET balance = balance - 100
WHERE id = 42
  AND last_transaction_id != 'txn_abc123';

The second UPDATE is the dangerous one. If your payment service retries it on a timeout, the balance drops twice. The idempotent version checks whether this specific transaction was already applied. Small change, enormous difference in failure modes.

Why Junior Devs Often Ignore Side Effects

The reason is simple: locally, nothing breaks. You write a POST endpoint, you test it with one request in Postman, it returns 200, you ship it. The network on your laptop doesn’t time out. The test database doesn’t have race conditions. So the side effect problem — that your endpoint does something irreversible — never surfaces until the code is live and under real traffic. By then, the debugging is someone else’s emergency and the fix is a hotfix at midnight.

“Double Payment” Nightmare: Real-World Problems Without Idempotency

Here is the exact sequence that takes down payment systems: client sends a charge request → backend receives it, starts processing → response takes 30 seconds → client’s HTTP timeout fires at 10 seconds → client retries → backend processes the second request too → user gets charged twice. The backend never failed. The first request succeeded. The client just didn’t know that yet. This pattern causes real financial damage and it’s 100% preventable.

Why Network Failures Cause Duplicate Requests (The “False Negative” Problem)

A timeout is not a failure. That’s the counterintuitive part. When your HTTP client throws a timeout exception, it means “I stopped waiting” — not “the server didn’t process it.” The server may have processed the request completely and the response packet got lost somewhere in the network. From the client’s perspective it looks like a failure. From the server’s perspective it was a success. If you retry on any exception without idempotency checks, you will process side-effectful operations — charges, emails, record inserts — multiple times. Your error rate looks low. Your duplicate rate is silently climbing.

Deep Dive
How Early Exits Can...

Guard Clauses: Writing Logic That Actually Makes Sense Let’s be honest: almost everyone has built "pyramids" of nested if statements. First, you check if the user exists, then if they are active, then if the...

Exactly-Once vs At-Least-Once: What Your PM Thinks vs What Actually Happens

Your PM thinks the system delivers exactly once. Every message sent, every payment processed, exactly one time. In distributed systems, exactly-once delivery is extremely hard and often impossible at the infrastructure level. What you actually get from most message queues and HTTP retry logic is at-least-once: the operation will happen, but it might happen more than once. The engineering answer to this gap is not to fix the infrastructure — it is to make your consumers idempotent so that duplicate delivery doesn’t produce duplicate outcomes.

Delivery Guarantee Who promises it Reality in prod Your job
Exactly-once PM, Jira tickets Requires distributed transactions or dedup layer Build the dedup layer
At-least-once Kafka, RabbitMQ, HTTP retries Default behavior Make consumers idempotent
At-most-once Fire-and-forget UDP, some queues Messages can be lost Only for non-critical events

Putting it into Practice: How Idempotency Works in REST

HTTP methods have defined idempotency semantics baked into the spec. GET retrieves data without changing it — idempotent. PUT replaces a resource with a given payload — call it ten times with the same body, you get the same result. DELETE removes a resource — calling DELETE on an already-deleted resource returns 404, but the state of the system hasn’t changed further. POST creates or triggers something — and it is the one method that carries no idempotency guarantee by default.

Why POST is Your Biggest Enemy in a Flaky Network

POST is dangerous because it’s overloaded. It creates records, triggers payments, sends emails, fires webhooks, starts background jobs. None of these actions are reversible. And POST has no built-in deduplication. Your retry logic doesn’t know whether the first POST succeeded. Your HTTP client doesn’t know. The load balancer doesn’t know. The only entity that can track “did I already process this?” is your application layer — and most junior-written backends don’t implement that check at all.

Practical Fix: Turning POST into an Idempotent Operation

The fix is an idempotency key: the client generates a UUID before sending the request and attaches it as a header — typically Idempotency-Key: <uuid>. The server stores that key the moment it starts processing. If a second request arrives with the same key, the server returns the stored response instead of processing again. No double charge. No duplicate row. The client gets the same answer it would have gotten the first time.

-- Server side: store the key before processing
INSERT INTO idempotency_keys (key, created_at, response_body)
VALUES ('uuid-abc-123', NOW(), NULL)
ON CONFLICT (key) DO NOTHING;

-- If insert succeeded: process the operation, then update
UPDATE idempotency_keys
SET response_body = '{"status":"charged","amount":99}'
WHERE key = 'uuid-abc-123';

-- If insert failed (conflict): key exists, return stored response
SELECT response_body FROM idempotency_keys WHERE key = 'uuid-abc-123';

The ON CONFLICT DO NOTHING is doing the heavy lifting here. The database constraint prevents two concurrent requests with the same key from both succeeding the insert. One wins, one gets a conflict. The one that wins processes the operation. The one that loses reads the stored response. Race condition handled at the database level, not in application code where it’s easy to get wrong.

Idempotency Key: How Request Deduplication Works

Designing a deduplication layer is not about clever algorithms. It’s about where you store the key and how long you keep it. Get those two decisions right and the rest is plumbing. The key itself is a UUID v4 generated client-side before the request is sent — not after, not server-side. The client owns the key because the client is the one who needs to retry.

Idempotency Key Implementation: Database vs. Redis

Database storage is durable and works for financial operations where you need an audit trail. The downside is latency: every request hits the DB twice — once to check/insert the key, once to do the actual work. Redis storage is fast — sub-millisecond lookups — and naturally supports TTL expiration. The downside is that Redis is not your source of truth. If Redis goes down or gets flushed, you lose your deduplication history. For payments, use the database. For API rate limiting or short-lived dedup windows, use Redis with a TTL of 24–48 hours.

// Redis-based deduplication (Node.js example)
async function processWithDedup(idempotencyKey, operation) {
  const existing = await redis.get(`idem:${idempotencyKey}`);
  if (existing) {
    return JSON.parse(existing); // return cached response
  }

  // Set a lock before processing
  const locked = await redis.set(
    `idem:${idempotencyKey}`,
    'processing',
    'NX',   // only set if not exists
    'EX',   // expire after
    86400   // 24 hours in seconds
  );

  if (!locked) {
    throw new Error('Duplicate request in flight');
  }

  const result = await operation();
  await redis.set(`idem:${idempotencyKey}`, JSON.stringify(result), 'EX', 86400);
  return result;
}

The NX flag on the Redis SET is the critical part — it makes the set atomic and conditional. Two concurrent requests with the same key will race on this line, and only one will succeed. The other gets a null back and throws. No double processing. The 24-hour TTL means the key expires automatically — you don’t need a cleanup job.

Technical Reference
Mojo vs Python

Mojo vs Python: True Superset or Just a Wrapper? The marketing team at Modular Inc is laying it on thick: Mojo is supposedly a true Python superset, it’s 35,000x faster, and it’s going to kill...

Using Unique Constraints to Stop Duplicates at the Gate

The most underused idempotency tool in most codebases is a database unique constraint on a business key. If your orders table has a unique constraint on (user_id, cart_id, created_date), then a duplicate insert physically cannot succeed — the database rejects it. No application code needed. No Redis. No key management. Just a constraint that matches your business logic. This works for cases where the “idempotency key” is naturally derivable from the business context, not a random UUID.

Distributed Systems: Microservices and Message Queues

Once you’re past a single service, the problem gets harder. Message brokers like Kafka and RabbitMQ deliver messages at-least-once by default. A consumer crash mid-processing means the broker re-delivers. Network partitions mean the acknowledgment gets lost and the message is redelivered even though it was processed. Every consumer in a message-driven architecture needs to handle duplicates — that’s not a bug in your setup, it’s the expected operating condition.

“Idempotent Consumer” Pattern: Dealing with Duplicate Events

The idempotent consumer pattern is straightforward: before processing a message, check if you’ve seen its ID before. If yes, skip it and acknowledge. If no, process it and record the ID. The record store is usually a database table or Redis set. The message ID is typically provided by the broker — Kafka has offsets, RabbitMQ has delivery tags, custom events should carry a UUID in the payload. Don’t rely on message content to detect duplicates — two legitimate events can have identical payloads but different IDs.

Webhook Idempotency: Why Stripe and PayPal Require You to Be Smart

Stripe’s webhook documentation explicitly states that the same event can be delivered more than once. They provide an event ID in every webhook payload specifically so you can deduplicate. PayPal does the same. If your webhook handler inserts a payment record without checking the event ID, you will eventually process a duplicate and your accounting will be wrong. The fix is one extra column — stripe_event_id with a unique constraint — and one SELECT before INSERT. Teams that skip this create subtle financial bugs that take months to notice in reconciliation.

2026 Reality: Idempotency in AI and LLM API Calls

LLM API calls added a new dimension to this problem. A single GPT-4o or Claude Sonnet call generating 4,000 tokens costs real money — often $0.10–$0.30 per call depending on the model and provider. If your retry logic fires a second identical request because the first one timed out at 29 seconds, you’re paying twice for the same output. At scale — thousands of daily AI calls — that’s a significant and entirely avoidable cost.

The Anthropic API supports an idempotency-key header on message creation requests. Pass a consistent UUID for a given logical operation and the API will return the cached response on retry instead of running the model again. OpenAI has similar semantics on their batch API. The pattern is identical to payment API deduplication — generate the key before the first call, persist it with the request context, reuse it on retry.

// LLM call with idempotency key
const response = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.ANTHROPIC_API_KEY,
    'anthropic-version': '2023-06-01',
    'idempotency-key': idempotencyKey  // UUID tied to this specific job
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 4096,
    messages: [{ role: 'user', content: prompt }]
  })
});

Store the idempotency key alongside your job record before making the call. If the process crashes and restarts, you pick up the same key and the API returns the same response — no second token charge. For high-volume AI pipelines, this one pattern can cut retry costs by 40–60% on days when upstream APIs are flaky.

Junior vs Mid Checklist: Before You Push to Prod

Run this against every endpoint or consumer that writes data, sends messages, or charges money. If you can’t check a box, you have work to do before merging.

Worth Reading
Async Patterns&Race Conditions

Async Patterns and Race Conditions: The Engineering of Chaos In modern software engineering, async patterns are often treated as a "performance button," but they are closer to a minefield. The core issue is the gap...

Check Junior miss Mid-level fix
POST endpoints accept idempotency key No header, no dedup Idempotency-Key header + DB/Redis store
DB inserts use ON CONFLICT or unique constraints Raw INSERT, silent duplicate rows ON CONFLICT DO NOTHING / DO UPDATE
Kafka/RabbitMQ consumers check event ID Process every delivery unconditionally Check processed_events table before handling
Retry logic is bounded and not blind Retry on any exception, unlimited times Exponential backoff, max 3 retries, idempotent ops only
Webhook handlers deduplicate by event ID Insert on every delivery Unique constraint on external event ID
LLM calls persist idempotency key before request New UUID on every retry = double billing UUID stored with job, reused on retry

FAQ

Is POST idempotent, and can you actually make it safe to retry?

POST is not idempotent by the HTTP spec — each call is treated as a new operation with potentially new side effects. However, you can make a POST endpoint behave idempotently by implementing an idempotency key mechanism: the client sends a unique key with the request, the server stores it before processing, and any subsequent request with the same key returns the stored response instead of re-executing the logic. This is exactly how Stripe, PayPal, and Twilio handle their payment and messaging APIs. The method stays POST, but the behavior becomes safe to retry.

How long should an idempotency key live before it expires?

The standard range is 24 hours to 7 days depending on the operation type. For payment APIs, Stripe uses 24 hours. For background job deduplication, 24–48 hours covers most retry windows. The key should live at least as long as your longest retry cycle — if your client retries for up to 6 hours with exponential backoff, your key TTL must exceed 6 hours with buffer. Shorter than the retry window and you’ll process duplicates on late retries. Much longer and you’re storing stale keys with no benefit.

What is the difference between idempotency and read-only operations?

Read-only operations (GET, HEAD) are safe — they don’t change server state at all. Idempotent operations may change state, but repeating them doesn’t change it further. DELETE is idempotent but not read-only: the first call removes the resource, subsequent calls find nothing to remove — state doesn’t change after the first execution. PUT is idempotent: replacing a resource with the same data N times leaves the same resource. The distinction matters for caching, retry design, and API contract — you can safely auto-retry idempotent operations, but retrying non-idempotent ones without a dedup layer is the exact bug this article is about.

How does the idempotent consumer pattern work in Kafka?

Every Kafka message has an offset — a unique position in the partition log. Your consumer tracks which offsets it has successfully processed, typically in a database table or in Kafka’s own consumer group offset store. Before processing a message, the consumer checks whether this offset (or a business-level event ID in the payload) has already been handled. If yes, it acknowledges the message and skips processing. If no, it processes, records the ID, and acknowledges. Kafka itself also supports idempotent producers — enabled via enable.idempotence=true — which prevents duplicate writes at the broker level during retries caused by producer network failures.

Can race conditions break idempotency key checks?

Yes, and this is where implementation details matter. If your check-then-insert logic runs as two separate queries — SELECT to check existence, then INSERT if missing — two concurrent requests can both pass the SELECT check before either runs the INSERT. Both then insert, and you process the operation twice. The fix is to make the check and insert atomic: use INSERT ... ON CONFLICT DO NOTHING in SQL (which is atomic at the database level), or use Redis SET NX (set if not exists), which is also atomic. Application-level locking on top of non-atomic DB operations is not sufficient and will eventually fail under concurrent load.

Does idempotency in programming apply to database migrations?

Absolutely, and it’s one of the most overlooked applications. A migration script that runs ALTER TABLE ADD COLUMN without checking if the column exists will crash on re-run. Idempotent migrations use guards: ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, DO $$ BEGIN IF NOT EXISTS (...) THEN ... END IF; END $$. This matters in CI/CD pipelines where a failed deployment may trigger a retry that re-runs migrations. If your migrations aren’t idempotent, a partial failure followed by a retry will corrupt schema state or throw hard-to-diagnose errors mid-deployment.

 

Written by:

Source Category: Logic & Patterns