Idempotency in Distributed Systems: Why It Matters and How to Implement It
In distributed systems, requests fail and get retried. If your API isn't idempotent, retries create duplicates — double charges, duplicate orders, spam emails.
Idempotency means: calling the same operation multiple times produces the same result as calling it once.
The Problem#
Client → POST /charge { amount: $100 } → Server processes → 200 OK
↑ network timeout, client never sees response
Client retries → POST /charge { amount: $100 } → Server processes AGAIN → $200 charged!
Without idempotency, the customer pays twice.
Idempotency Keys#
The standard pattern (used by Stripe):
POST /charge
Idempotency-Key: abc-123-def
{ amount: 100, currency: "usd" }
Server logic:
1. Check: does key "abc-123-def" exist in idempotency store?
2. If yes → return the stored response (no reprocessing)
3. If no → process the charge → store result with key → return response
Implementation:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INT,
response_body JSONB,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);
async function handleCharge(req: Request) {
const key = req.headers["idempotency-key"];
if (!key) return { status: 400, body: "Idempotency-Key required" };
// Check for existing result
const existing = await db.idempotencyKeys.findUnique({ where: { key } });
if (existing) {
return { status: existing.responseStatus, body: existing.responseBody };
}
// Process the charge
const result = await processCharge(req.body);
// Store for future retries
await db.idempotencyKeys.create({
data: { key, requestHash: hash(req.body), responseStatus: 200, responseBody: result }
});
return { status: 200, body: result };
}
Naturally Idempotent Operations#
Some operations are idempotent by design:
| Operation | Idempotent? | Why |
|---|---|---|
GET /users/123 | Yes | Reading doesn't change state |
PUT /users/123 { name: "Alice" } | Yes | Sets to same value regardless of repeats |
DELETE /users/123 | Yes | Deleting twice = same result (gone) |
POST /charges { amount: 100 } | No | Each call creates a new charge |
PATCH /users/123 { balance: +10 } | No | Each call adds $10 |
POST and relative updates need explicit idempotency handling.
Database-Level Idempotency#
Unique Constraints#
-- Prevent duplicate orders
CREATE UNIQUE INDEX idx_unique_order ON orders (user_id, idempotency_key);
INSERT INTO orders (user_id, idempotency_key, amount)
VALUES ('user_123', 'order-abc', 100)
ON CONFLICT (user_id, idempotency_key) DO NOTHING;
Conditional Updates#
-- Only process if not already processed
UPDATE payments
SET status = 'completed', processed_at = NOW()
WHERE id = 'pay_123' AND status = 'pending';
-- Returns 0 rows affected if already completed
Message Queue Deduplication#
Kafka#
Kafka supports exactly-once with transactional producers:
producer.initTransactions();
producer.beginTransaction();
producer.send(record);
producer.commitTransaction();
Consumer-side dedup:
async function handleMessage(msg: KafkaMessage) {
const messageId = msg.headers["message-id"];
const processed = await redis.setnx(`processed:${messageId}`, "1");
if (!processed) return; // Already handled
await redis.expire(`processed:${messageId}`, 86400);
await processMessage(msg);
}
SQS FIFO#
SQS FIFO queues deduplicate automatically using MessageDeduplicationId:
await sqs.sendMessage({
QueueUrl: "https://sqs...fifo",
MessageBody: JSON.stringify(order),
MessageGroupId: "orders",
MessageDeduplicationId: order.idempotencyKey, // SQS deduplicates for 5 min
});
Real-World Examples#
Stripe#
Every mutating API call accepts Idempotency-Key. Results cached for 24 hours. Same key + same parameters = same response.
AWS#
S3 PutObject is naturally idempotent (same key = overwrite). DynamoDB conditional writes (ConditionExpression) prevent duplicate processing.
Shopify#
Webhooks include X-Shopify-Webhook-Id. Receivers track processed IDs to avoid duplicate handling.
Patterns Summary#
| Pattern | When | Implementation |
|---|---|---|
| Idempotency key | Client-facing APIs | Store key → response mapping |
| Unique constraint | Database writes | ON CONFLICT DO NOTHING |
| Conditional update | State transitions | WHERE status = 'pending' |
| Message dedup | Queue consumers | Redis SETNX or built-in (SQS FIFO) |
| Natural idempotency | GET, PUT, DELETE | No extra work needed |
Best Practices#
- Require idempotency keys for all POST endpoints that create resources or charge money
- Use UUIDs as idempotency keys (client-generated)
- Expire keys after 24-48 hours
- Validate request body matches — same key with different body should error
- Return the original response — don't just return "already processed"
- Make it the default — add idempotency middleware to your API framework
Design idempotent APIs at codelit.io — generate architecture diagrams with security and compliance audits.
Explore more system design guides at codelit.io/blog.
Try these templates
Continue learning
Go deeper on distributed systems
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Database Internals
Alex Petrov · 2019
Explains B-trees, LSM trees and consensus protocols so you can predict how a datastore will behave.
4.6 (560)#2 in Management Information SystemsRelease It!
Michael T. Nygard · 2018
Post-mortems of real outages, then the stability patterns (circuit breaker, bulkhead) that stop them.
4.6 (501)#4 in Client-Server Networking SystemsMicroservices Patterns
Chris Richardson · 2018
Sagas as running Java code: compensating transactions, outbox, queries across services.
4.6 (705)Principles of Web API Design
James Higginbotham · 2021
A job-story-to-endpoint process you can run with product people, not just a list of REST rules.
4.5 (55)Kindle edition