Retries are inevitable. Networks flake, mobile clients disconnect, and load balancers time out after work has already started. If your write APIs are not idempotent, those retries create duplicate charges, duplicate records, and painful support tickets.
The problem in one diagram
Client API Database
|-- POST /orders -->| |
| |-- insert order ---->|
| X timeout | |
|-- POST /orders -->| |
| |-- insert order ---->| ← duplicateWithout an idempotency contract, the second request is a new business event. Clients must retry under uncertainty; servers must treat that retry as the same operation.
Idempotency key contract
Ask clients to send a unique key per logical operation:
POST /v1/orders HTTP/1.1
Idempotency-Key: 8f2c1a6e-9b44-4d21-a1c0-0d91f3e2a7b1
Content-Type: application/json
{"sku":"trail-pro","qty":1}Server behavior:
- Hash the key scoped to the authenticated principal.
- Persist key + request fingerprint + response.
- On replay with the same key and body, return the original response.
- On replay with the same key and a different body, reject with
409 Conflict.
Info: Scope keys by user or API key. A global key namespace invites accidental collisions across tenants.
Document the header, key format (UUID v4 is fine), TTL, and conflict semantics. SDKs should generate a key once per user action—not once per HTTP attempt.
Minimal implementation sketch
type IdempotencyRecord = {
key: string;
actorId: string;
requestHash: string;
statusCode: number;
responseBody: unknown;
createdAt: string;
};
async function handleCreateOrder(req: Request) {
const key = req.headers.get("Idempotency-Key");
if (!key) throw new HttpError(400, "Missing Idempotency-Key");
const actorId = requireUserId(req);
const body = await req.json();
const requestHash = sha256(JSON.stringify(body));
const existing = await store.get({ actorId, key });
if (existing) {
if (existing.requestHash !== requestHash) {
throw new HttpError(409, "Idempotency key reuse with different payload");
}
return Response.json(existing.responseBody, {
status: existing.statusCode,
});
}
const order = await createOrder(body);
await store.put({
key,
actorId,
requestHash,
statusCode: 201,
responseBody: order,
createdAt: new Date().toISOString(),
});
return Response.json(order, { status: 201 });
}The sketch is incomplete on concurrency. Same-key in-flight requests need a lock, unique constraint, or compare-and-set so only one writer creates the order; the loser waits for the stored result.
Handling in-flight duplicates
A practical pattern:
- Insert an idempotency row in
processingunder a unique(actor_id, key)constraint. - If the insert loses the race, wait briefly and re-read; return the completed response when ready.
- If the first worker fails mid-flight, mark failed or hold until TTL—never silently accept a second create.
Canonicalize the body before hashing (stable key order) so equivalent JSON does not falsely trip 409. Exclude volatile fields (trace IDs, client timestamps) from the fingerprint.
Side effects and exactly-once illusions
Idempotency at the API edge does not give you exactly-once processing everywhere. Downstream systems still need:
- dedupe tables or unique constraints
- outbox + consumer checkpoints
- careful webhook delivery semantics
Warning: Returning
201twice with the same body is not enough if a payment capture happens outside the idempotent transaction boundary.
Put money movement, email, and third-party calls behind the same transactional boundary as the idempotency record—or an outbox consumers also dedupe.
Storage and retention
| Concern | Recommendation |
|---|---|
| Store | Redis with DB fallback, or transactional SQL row |
| TTL | 24–72 hours for most product APIs |
| Uniqueness | Unique index on (actor_id, idempotency_key) |
| Payload size | Cap stored response bodies |
Prefer SQL for money paths so key reservation and the business write share one transaction; Redis can cache in front. After TTL, keys may be reused—treat them as short-lived retry handles. Lasting identity belongs in fields like external_reference with their own unique constraints.
Response semantics clients should rely on
| Situation | Status | Notes |
|---|---|---|
| First success | 201 / 200 | Store for the key’s TTL |
| Replay, same body | Same as original | Same resource id; do not invent a new one |
| Replay, different body | 409 | Key/payload mismatch |
| Missing key (required APIs) | 400 | Enforce on money-adjacent routes |
| Concurrent processing | Wait or 409 | Prefer wait-and-return when latency allows |
When GET is not enough
Safe methods are not a substitute for write idempotency. Clients still need a way to recover intent after uncertainty. Prefer:
- client-generated idempotency keys for POSTs
- natural unique constraints (
external_reference) - upsert semantics when the domain allows it
Common mistakes
- Generating a new key on every retry
- Storing the key without a request fingerprint
- Recording success after the side effect, not with it
- Global key namespaces across tenants
- No concurrent-request test under load
- Infinite retention of large response bodies
Rollout checklist
- Docs cover header, TTL, and
409behavior - Unique constraint on
(actor, key)in durable storage - Stable request fingerprinting
- In-flight lock (or equivalent) for concurrent duplicates
- Side effects inside the same transaction/outbox boundary
- Load test: parallel retries → one business effect
- SDKs generate keys per action, not per attempt
- Metrics for replays, conflicts, and lock timeouts
- Runbook for stuck
processingrecords
Optional-but-logged first is fine; then enforce missing keys on high-risk routes.
Testing checklist
- Retry same key + body → identical response
- Retry same key + different body →
409 - Concurrent duplicate requests → single side effect
- Expired key → treated as a new request
- Unauthorized replay attempts → rejected
Automate concurrent retries with two workers; sequential Postman clicks miss the race.
FAQ
Every POST needs a key?
Require keys for creates that allocate money, inventory, or irreversible external side effects. Low-risk analytics posts can stay best-effort if you accept duplicates.
First request commits the row but dies before saving the key?
Order of operations is wrong. Reserve the key (or rely on a unique business constraint) in the same transaction as the write.
Can the order id be the key?
Only if the client already knows it (client-generated resource ids). Otherwise a timeout leaves nothing to retry under.
How long should keys live?
Long enough for mobile offline retries—often 24–72 hours—without keeping PII-laden bodies forever.
Continue learning
- Pair this with queue outbox patterns before adding async fulfillment
Idempotency is a product reliability feature. Design it intentionally, document it publicly, and test it under concurrency—not only under happy-path Postman clicks.
