Networks fail in the worst possible way for correctness: a client sends a request, the server processes it successfully, and then the response gets lost on the way back — the client sees a timeout and, reasonably, retries. Without idempotency, that retry just charged the customer twice.
Idempotency means an operation produces the same result no matter how many times it's performed — calling an idempotent endpoint once or five times with the same input has the same effect as calling it once. This matters enormously for any operation with side effects (payments, order creation, sending notifications) where a client retry after an ambiguous failure (timeout, connection drop) shouldn't risk duplicating that side effect.
Why Idempotency Matters (and When to Skip It)
Client retries are unavoidable in distributed systems — networks are unreliable, and "did my request actually succeed?" is a genuinely ambiguous question after a timeout. Without idempotency guarantees, the safe options are "risk a duplicate side effect" or "risk losing the operation entirely" — neither acceptable for anything with real consequences, like a payment or an irreversible state change.
Skip explicit idempotency handling for naturally idempotent operations (a GET request, a PUT that sets a resource to an exact state) — these are already safe to retry by their nature, and adding idempotency key machinery on top is unnecessary complexity for operations that don't need it.
Getting Started with Idempotency
Client sends an idempotency key with a mutating request:
POST /api/payments
Idempotency-Key: 8f3e2c1a-9b4d-4e5f-a6c7-1d2e3f4a5b6c
Content-Type: application/json
{"amount": 5000, "currency": "usd"}
Server checks for a prior response with that key before processing:
async function handlePaymentRequest(req: Request) {
const idempotencyKey = req.headers.get("Idempotency-Key");
const existing = await db.idempotencyKeys.findOne({ key: idempotencyKey });
if (existing) {
return existing.response; // return the original result, don't reprocess
}
const result = await processPayment(req.body);
await db.idempotencyKeys.insert({ key: idempotencyKey, response: result });
return result;
}
Core Idempotency Concepts Every Developer Should Know
The idempotency key must be generated by the client, not the server, and reused for retries of the same logical operation — typically a UUID generated once per user action, sent on every retry attempt of that same action. If the server generated the key, retries after a lost response would have no way to reference the original attempt.
The stored response (not just a "processed" flag) needs to be returned on retry, so a retrying client gets the actual original result — a payment confirmation, an order ID — not just an acknowledgment that something happened previously without the details needed to proceed.
Race conditions between concurrent retries need explicit handling. If two retries with the same idempotency key arrive nearly simultaneously (common with aggressive client retry logic), a naive check-then-insert pattern has a race window where both could pass the "does this key exist" check before either has written a response. Fix this with a database-level unique constraint on the key and handling the resulting conflict gracefully, not just an application-level check.
Idempotency key storage needs a reasonable expiration, since keeping every key forever is unbounded storage growth for a guarantee that's typically only relevant within a bounded retry window (minutes to a day, depending on the operation).
Common Idempotency Mistakes and How to Fix Them
Mistake 1: only using a "has this been processed" boolean flag instead of storing the actual response, leaving retrying clients without the original result they need. Fix: store and return the full original response, not just a processed marker.
Mistake 2: relying on an application-level check-then-insert without a database uniqueness constraint, leaving a race condition window for near-simultaneous retries. Fix: enforce uniqueness at the database level and handle the constraint violation as the signal that this is a retry, not a fresh check.
Mistake 3: not documenting which endpoints require idempotency keys and which don't, leaving API consumers uncertain about safe retry behavior. Fix: clearly document idempotency support per endpoint, including expected key format and any expiration window.
When Should You Require Idempotency Keys Instead of Relying on Natural Idempotency?
Require explicit idempotency keys for any endpoint with a side effect that would be harmful if duplicated — payments, order creation, sending communications, provisioning resources. Rely on natural idempotency (no extra key needed) for operations that are inherently safe to repeat — reads, and writes that set an exact final state rather than incrementing or creating something new each time.
Idempotency in Production
Enforce idempotency key uniqueness at the database level, not just in application logic, since that's the only reliable defense against race conditions between near-simultaneous retries. Also set a sensible expiration on stored idempotency records, and document per-endpoint idempotency behavior clearly so API consumers know which operations are safe to retry blindly.
If your payment or order-creation endpoints don't currently support idempotency keys, that's a concrete gap worth closing before a network hiccup turns into a duplicate charge incident.