Overview
An operation is idempotent if performing it multiple times has the same effect as performing it once. In distributed systems, retries are unavoidable: a client times out and does not know if the server processed the request, a message broker redelivers a message, or a user double-clicks. Idempotency makes these duplicates harmless.
Some operations are naturally idempotent (set status = shipped, PUT a resource, DELETE by ID). Others, like charging a card or incrementing a balance, need an idempotency key: a unique ID for the logical operation stored with its result, so a repeated request returns the stored result instead of acting again.
Pressing the button for floor 5 once or ten times has the same result: the elevator goes to floor 5 once. A vending machine that drops a snack every press is not idempotent.
When to use it
- Payments and any money movement.
- APIs that clients retry after timeouts.
- Message consumers with at-least-once delivery.
- Webhooks, which are often delivered more than once.
Where it shows up in interviews
Recognize it when: client timed out; did the payment happen?
- Design a payment API
- Design an order placement service
Recognize it when: messages may be delivered twice.
- Design a notification system
- Design an event-driven order pipeline
Where it is used in real software
Clients send an Idempotency-Key header; Stripe stores the first response for 24 hours and replays it for retries.
Many AWS APIs accept a ClientToken so retried create calls do not create duplicate resources.
Sequence numbers per producer and partition let brokers drop duplicate sends.
Key terms
- Idempotent
- f(f(x)) = f(x): repeating has no additional effect.
- Idempotency key
- Client-generated unique ID for one logical operation.
- Deduplication table
- Stores processed keys and their results.
- At-least-once
- Delivery that may duplicate; requires idempotent processing.
How it works, step by step
- 1Client generates a key
A UUID per logical operation, reused on retries.
- 2Server checks the key
If seen and complete, return the stored response.
- 3Reserve the key atomically
Insert with a unique constraint; concurrent duplicates fail or wait.
- 4Perform the operation and store the result
In the same transaction as the business write when possible.
- 5Expire old keys
Keep them for longer than the maximum retry window.
A retried payment with and without idempotency
Client sends POST /charges, the response is lost, client retries
| Attempt | Without key | With Idempotency-Key: k1 |
|---|---|---|
| 1 | Card charged $50, response lost | Card charged $50, result stored under k1, response lost |
| 2 (retry) | Card charged $50 again | Key k1 found: return stored result, no charge |
| Total charged | $100 (bug) | $50 (correct) |
NOWAttempt: 1 | Without key: Card charged $50, response lost | With Idempotency-Key: k1: Card charged $50, result stored under k1, response lost
Retries are only safe when the server can recognize the repeated operation.
Implementation
// Express-style handler with an idempotency table:// CREATE TABLE idempotency_keys (key TEXT PRIMARY KEY, status TEXT, response JSONB, created_at TIMESTAMPTZ DEFAULT now());export async function createCharge(req: Request, res: Response) { const key = req.header("Idempotency-Key"); if (!key) return res.status(400).json({ error: "Idempotency-Key header required" }); const inserted = await db.query( "INSERT INTO idempotency_keys (key, status) VALUES ($1, 'processing') ON CONFLICT (key) DO NOTHING RETURNING key", [key], ); if (inserted.rowCount === 0) { const existing = await db.query("SELECT status, response FROM idempotency_keys WHERE key = $1", [key]); if (existing.rows[0].status === "processing") return res.status(409).json({ error: "Request in progress" }); return res.status(200).json(existing.rows[0].response); // replay original result } const charge = await payments.charge(req.body.customerId, req.body.amountCents, { idempotencyKey: key }); await db.query("UPDATE idempotency_keys SET status = 'done', response = $2 WHERE key = $1", [key, charge]); res.status(201).json(charge);}Complexity and performance
One extra read/write.
Often 24 hours to 7 days.
Trade-offs
Storing keys adds a write per request but prevents costly duplicates.
Keys must be scoped per client or account to avoid collisions and abuse.
Variants and related techniques
Design operations as set-to-value rather than increment.
Only apply if version matches (optimistic concurrency).
Record processed message IDs in the same transaction as the side effect.
Common mistakes
- Generating a new key on each retry.
Fix: The key must be created once per logical operation and reused.
- Checking and writing non-atomically.
Fix: Use a unique constraint or atomic insert to reserve the key.
- Same key with a different payload.
Fix: Store a request hash and reject mismatches.
Interview questions
How do you make a payment API safe to retry?
Require a client-generated idempotency key, reserve it atomically before processing, store the result with the key, and return the stored result for any repeat. Pass the same key to downstream payment providers.
How do message consumers handle duplicate delivery?
Store processed message IDs (or business keys) in the same transaction as the side effect, and skip messages already recorded; or make the side effect naturally idempotent, like an upsert.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| Classify 10 operations as idempotent or not | Easy | Semantics. |
| Implement idempotent order creation | Medium | Atomic key reservation. |