If you’ve ever built an API that touches money, inventory, emails, or basically anything “real,” you’ve already met the villain of this story:
Retries.
Retries happen everywhere—mobile networks, flaky Wi‑Fi, load balancers, gateway timeouts, client SDKs, message queues, cron jobs. And retries are the reason your “simple” endpoint can accidentally:
- charge a customer twice
- create duplicate orders
- send the same OTP/email five times
- decrement inventory into the negatives
- create spooky “ghost” records nobody can explain
Idempotency is the concept that prevents all of that.
What idempotency actually means (in API terms)
An operation is idempotent if doing it one time or multiple times has the same effect on the server state.
Same effect, not necessarily the exact same response bytes.
Concrete example
If this request creates an order:
POST /orders
and your client retries because the network drops after sending it, your server might create:
- Order #501 the first time
- Order #502 the second time (duplicate)
- Order #503 the third time (…help)
That’s not idempotent.
Idempotency ensures that all those retries result in only one order being created.
Why retries are so dangerous (the “it worked but I didn’t hear back” problem)
The most common real-world failure looks like this:
- Client sends a request.
- Server processes it successfully.
- Response is lost (timeout, connection reset, gateway error).
- Client assumes it failed and retries.
- Server processes the request again.
- You now have duplicates.
This is especially common with:
- mobile apps
- payment processing
- server-to-server calls across regions
- queues with at-least-once delivery
- API gateways that automatically retry on certain status codes
So even if you think “clients shouldn’t retry POST,” they do. And even if you tell them not to, the infrastructure might do it anyway.
Idempotent vs Safe (important distinction)
People mix these up:
- Safe: doesn’t change server state (e.g.,
GET) - Idempotent: can change server state, but repeating it has the same effect
Examples:
| Method | Safe? | Idempotent? | Notes |
|---|---|---|---|
| GET | ✅ | ✅ | Should not mutate state |
| PUT | ❌ | ✅ | Sets resource to a known value |
| DELETE | ❌ | ✅ | Deleting twice is still “deleted” |
| POST | ❌ | ❌ (usually) | Often creates new things each time |
HTTP semantics suggest PUT/DELETE are idempotent, but your implementation can still mess that up (e.g., sending emails inside a PUT handler).
Where idempotency matters most
Idempotency isn’t “nice to have.” It’s production survival when your API does any of this:
💳 Payments and billing
Retries must not double-charge.
🛒 Orders and inventory
Retries must not create duplicate orders or double-reserve stock.
✉️ Emails / SMS / notifications
Retries must not spam users.
🧾 Webhooks
Webhook senders will retry (often aggressively). If you don’t dedupe, your downstream state gets corrupted.
📦 Async processing (queues)
Many messaging systems guarantee at-least-once delivery, not exactly-once. That means duplicates are a feature.
The two common flavors of idempotency
1) “State-setting” idempotency (naturally idempotent)
These are operations where the client says: “Make the state exactly this.”
Example:PUT /users/123
{
"email": "a@b.com",
"marketingOptIn": true
}
Calling this 10 times should still result in the same user state.
This is the easiest kind, and it’s why PUT is often preferred over POST for updates.
2) “Create-once” idempotency (requires design)
Creation is the tricky one. Most POST endpoints are “create a new thing,” so repeating them creates duplicates.
To make creation idempotent, you need a way to say:
“This is the same logical request as before; don’t do it twice.”
That’s where idempotency keys come in.
The Idempotency Key pattern (the workhorse solution)
The idea
The client generates a unique key for a request—usually a UUID—and includes it with the API call:
Idempotency-Key: 6f1b5a2a-...
The server stores that key (plus some metadata) the first time it processes the request. If the same key comes in again, the server returns the same outcome instead of performing the action again.
Where to put the key
Common options:
- HTTP header:
Idempotency-Key(popular and clean) - Request body:
{ "idempotencyKey": "..." } - Query param: less ideal
Headers are usually best because they don’t pollute your domain payload.
A realistic example: “Create Payment” (the classic)
Request
POST /payments
Headers:
Idempotency-Key: 3f2c2b1b-9ce2-4a6f-8ce4-7a92f1e8d8cb
Body:
{
"amount": 4999,
"currency": "USD",
"customerId": "cus_123",
"source": "card_abc"
}
Server behavior
- First time: create payment
pay_001, charge card, return success - Retry with same key: do not charge again, return the original
pay_001result
This single design choice prevents a very expensive class of bugs.
Implementation blueprint (how to build it properly)
Here’s the simplest robust mental model:
Store a record keyed by (clientId, idempotencyKey)
You want to scope keys per client/merchant/user to avoid collisions across tenants.
Idempotency record typically stores:
client_ididempotency_key- request fingerprint (optional but recommended)
- status:
IN_PROGRESS | COMPLETED | FAILED - response snapshot (status code + body) or at least the created resource ID
- timestamps + TTL/expiration
High-level flow
- Receive request with idempotency key.
- In a transaction:
- Attempt to insert
(clientId, key)into a table with a unique constraint. - If insert succeeds → you “own” this key, process request.
- If insert fails (already exists) → fetch existing record and:
- if
COMPLETED→ return stored response (or redirect to resource) - if
IN_PROGRESS→ return409 Conflictor202 Accepted(depends on your design)
- if
- Attempt to insert
- Update record to
COMPLETEDand store response.
Why the unique constraint matters
It’s the difference between “this works in dev” and “this survives concurrency at scale.”
Two identical retries can hit two servers at the exact same millisecond. Without a database-enforced uniqueness rule, both may create duplicates.
Request fingerprinting (optional but very worth it)
If a client mistakenly reuses the same idempotency key with a different payload, you want to detect it.
Example:
- First request:
amount=4999 - Second request (bug):
amount=9999with same key
If you treat it as the same operation, you might return a payment for 4999 when the client thinks they paid 9999. That’s…bad.
So store a hash/fingerprint of the request:
- method + path
- normalized JSON body
- important headers (like currency)
Then on replay, compare fingerprints:
- if mismatch → return
409 Conflictwith a clear error like:- “Idempotency key reused with different request payload.”
Common mistakes that break idempotency (even when you “have keys”)
Mistake 1: side effects outside the idempotent guard
Example: your handler:
- writes the order once ✅
- but sends a confirmation email before recording the idempotency outcome ❌
On retry, you might not create the order again, but you could spam the email again.
Fix: treat side effects as part of the same idempotent workflow:
- store and dedupe “email sent” events
- or trigger email from a downstream consumer keyed by order ID (so it naturally dedupes)
Mistake 2: “best effort” dedupe without atomicity
If you:
- “check if key exists”
- then “insert key”
…without a transaction/unique constraint, you still get race conditions.
You need atomicity:
- unique index
- insert-first approach
- proper locking
Mistake 3: assuming PUT is automatically idempotent
Your PUT /profile might update the DB (fine) but also:
- append an audit log
- rotate a token
- send an analytics event with a unique ID
Those are side effects that can make repeats not truly idempotent.
This doesn’t mean “never do those,” it means:
- design them to be idempotent too (dedupe by operation ID)
- or keep them out of retry paths
What status codes should you return on retry?
There’s no single perfect rule, but here are solid patterns:
If the prior request is completed
Return the same success status you returned originally:
201 Createdwith the original resource200 OKwith the original response body
If the prior request is still processing
Options:
202 Acceptedwith an operation status endpoint409 Conflictwith “request in progress, retry later”200 OKwith current status if your response model supports it
Choose one and document it clearly.
Idempotency without an idempotency key (other patterns)
Sometimes you can make create operations idempotent using natural keys:
Example: client-generated resource ID
Client chooses an order ID:PUT /orders/{clientOrderId}
Then retries naturally target the same resource.
This can be extremely clean, especially for:
- orders
- invoices
- user-submitted documents
It also makes “create vs update” semantics less messy.
How long should you keep idempotency records?
Depends on your retry window and business domain.
Typical approaches:
- Short TTL (minutes-hours): good for “network retry protection”
- Long TTL (days): good for webhooks or financial operations
- Forever: expensive, but sometimes justified for compliance-grade payment systems
Practical compromise:
- Keep full response snapshot for 24–72 hours
- Keep a minimal record (key → resource id) longer
Testing idempotency (the part most teams skip)
If you don’t test retries, you don’t have idempotency. Here are high-signal tests:
1) Retry after timeout simulation
- Send request
- Kill connection before reading response
- Retry with same key
- Assert: only one DB record, one side effect, same resource ID
2) Concurrency test
- Fire the same request with same key from 20 threads/processes
- Assert: exactly one create happens
3) Payload mismatch test
- Same key + different body
- Assert: 409 (or your chosen error), no state corruption
Observability: you want to see duplicates and replays
Good production metrics:
- count of idempotency replays (by endpoint)
- % of requests missing idempotency key (for endpoints that require it)
- “in progress” collisions
- dedupe hit rate on webhooks
And log fields:
idempotency_keyidempotency_status: created|replayed|conflict|in_progressresource_id
These make debugging incidents 100x easier.
The takeaway
Idempotency is one of those boring-sounding words that secretly prevents the most painful production incidents.
If your API can cause side effects—money, inventory, messages, creation—you should assume:
- requests will be retried
- duplicates will happen
- you won’t always know it happened until customers tell you
Designing for idempotency means:
- your system stays correct under retries
- your users don’t pay for network failures
- your on-call life gets way less dramatic