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 dont 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 its 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 NOTHINGcovers 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 — its 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 youre impatient. The elevator still goes to floor 5 once. Thats 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. Thats the absence of idempotency, and its your bug, not the users.
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 doesnt time out. The test database doesnt 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 elses 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 → clients 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 didnt know that yet. This pattern causes real financial damage and its 100% preventable.
Why Network Failures Cause Duplicate Requests (The False Negative Problem)
A timeout is not a failure. Thats the counterintuitive part. When your HTTP client throws a timeout exception, it means I stopped waiting — not the server didnt process it. The server may have processed the request completely and the response packet got lost somewhere in the network. From the clients perspective it looks like a failure. From the servers 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.
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 doesnt produce duplicate outcomes.
Side Effects Are Not the Enemy — Uncontrolled Side Effects Are You've seen this bug. A function works flawlessly in staging, passes all tests, ships to production — and then, three weeks later, silently returns...
[read more →]| 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 hasnt 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 its 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 doesnt know whether the first POST succeeded. Your HTTP client doesnt know. The load balancer doesnt know. The only entity that can track did I already process this? is your application layer — and most junior-written backends dont 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 its easy to get wrong.
Idempotency Key: How Request Deduplication Works
Designing a deduplication layer is not about clever algorithms. Its 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 dont need a cleanup job.
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...
[read more →]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 youre 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 — thats not a bug in your setup, its the expected operating condition.
Idempotent Consumer Pattern: Dealing with Duplicate Events
The idempotent consumer pattern is straightforward: before processing a message, check if youve 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. Dont 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
Stripes 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, youre paying twice for the same output. At scale — thousands of daily AI calls — thats 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 cant check a box, you have work to do before merging.
Your Codebase Has a String Problem — And It's Costing You at Scale A string can hold anything — a name, a UUID, a JSON blob, a typo, a SQL injection payload. That's exactly the...
[read more →]| 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 youll process duplicates on late retries. Much longer and youre storing stale keys with no benefit.
What is the difference between idempotency and read-only operations?
Read-only operations (GET, HEAD) are safe — they dont change server state at all. Idempotent operations may change state, but repeating them doesnt change it further. DELETE is idempotent but not read-only: the first call removes the resource, subsequent calls find nothing to remove — state doesnt 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 Kafkas 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 its 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 arent idempotent, a partial failure followed by a retry will corrupt schema state or throw hard-to-diagnose errors mid-deployment.
Written by: