Idempotency in Distributed Systems: Why It Matters and How to Implement It
Idempotency: 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.
109 articles on system design at codelit.io/blog.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for Idempotency in Distributed Systems in seconds.
Try it in Codelit →
Comments