The Ambiguity You Cannot Remove
A client sends a request. Thirty seconds pass with no response. The client has no way to distinguish between three situations: the request never arrived, the request arrived and failed, or the request arrived and succeeded but the response was lost on the way back.
This ambiguity is not a bug in anyone's code. It is a property of communicating over an unreliable network, and no amount of engineering removes it. A timeout genuinely does not tell you whether the work happened.
The client must therefore choose. Retry, and risk performing the operation twice. Do not retry, and risk it never happening. For a payment, a shipment, or an email, both choices are bad.
Idempotency dissolves the dilemma rather than solving it. If performing the operation twice produces the same result as performing it once, the client can retry freely and the ambiguity stops mattering.
What Idempotency Actually Requires
The definition worth holding: an operation is idempotent if applying it N times leaves the system in the same state as applying it once.
Two clarifications that matter in practice.
First, state, not response. Returning a different HTTP status on the retry is fine, and often desirable, as long as the underlying data ends up the same. A second call that returns the original resource is idempotent even though the status code differs.
Second, this is about the effect, not the method name. HTTP defines PUT and DELETE as idempotent, but that is a specification requirement, not something the framework enforces. A PUT handler that appends to a list is not idempotent regardless of the verb. The guarantee has to be built.
Where Each Method Stands
| Method | Idempotent by Spec | What Usually Breaks It |
|---|---|---|
GET |
Yes, and safe | Side effects hidden in a read, such as incrementing a view counter. |
PUT |
Yes | Handlers that append or increment rather than replace. |
DELETE |
Yes | Returning 404 on the second call, which breaks client retry logic. |
PATCH |
No | Relative operations. "Add 10 to balance" applied twice adds 20. |
POST |
No | Creation with a server-generated identifier, by design. |
Three Ways to Get There
Strategy 1: Let the Client Choose the Identifier
The simplest approach and the most overlooked. If the client generates the resource identifier — a UUID — and sends it as part of the request, creation becomes an upsert against a known key. A retry finds the row already present and returns it.
This removes the ambiguity at its root rather than compensating for it, and it has a useful side benefit: the client can construct the resource URL before the request completes, which simplifies optimistic UI. The cost is giving up server-assigned sequential identifiers, which for most resources is not a real loss.
Strategy 2: Idempotency Keys
When the client cannot own the identifier, it sends a unique key per logical operation, typically in a header. The server records the key alongside the outcome and, on seeing it again, replays the stored result instead of re-executing.
This is the pattern used by payment APIs, and getting it right requires attention to four details that are easy to miss:
- The key must be stored in the same transaction as the effect. If you record the key first and then do the work, a crash in between leaves a key claiming success for work that never happened. If you do the work first, a crash leaves an unrecorded effect that a retry duplicates. One transaction, both writes, or the guarantee does not hold.
- Concurrent retries need a real lock. Two requests with the same key can arrive simultaneously. A check-then-act pattern has a race window. Use a unique constraint on the key column and let the database serialise it: the second insert fails, and you handle that failure by returning the first result.
- Bind the key to the request body. Store a hash of the payload with the key. If the same key arrives with a different body, that is a client bug — reject it rather than silently returning an unrelated result.
- Decide the retention window and publish it. Keys cannot be kept forever. Twenty-four hours is a common choice. Clients must know, because a retry after expiry will execute again.
Strategy 3: Make the Operation Naturally Convergent
Sometimes you can restructure the operation so repetition is inherently harmless.
Prefer absolute assignment over relative change: "set status to shipped" is idempotent, "advance status" is not. Prefer set membership over counters: adding a user to a set twice yields one member, while incrementing a count twice yields two. Prefer state machines with explicit transitions, where moving to a state you are already in is a no-op.
This strategy is the most robust when it applies, because it needs no bookkeeping at all. It does not always apply, but it is worth checking first.
Idempotency in Data Loading
The same reasoning governs bulk loads, and the failure is more common there than in APIs because loads are often run by hand.
A script that inserts 500 batches and fails at batch 347 has committed 346. Rerunning duplicates them or hits primary key conflicts. Neither is acceptable, and the state is now unclear, which is worse than either.
Three techniques make loads rerunnable, and they should be decided before the first run rather than after the first failure:
- Truncate and reload as a unit. The simplest form of convergence. The staging table always reflects exactly one source extract, so rerunning is always safe.
- Upsert on a natural key. Rerunning updates rather than duplicating. This requires that a genuine business key exists and is enforced by a unique constraint.
- Tag rows with a load identifier. Every row records which load produced it, which makes selective rollback possible and lets you answer "where did this row come from" months later.
When generating load scripts, the shape of the SQL matters here. A script of individual inserts with no conflict handling is not rerunnable; batched inserts into a staging table that gets truncated first is. Generating dialect-correct scripts with the Develop Box Online Converters Excel-to-SQL tool, targeting a staging table rather than the production one, gives you the truncate-and-reload property essentially for free.
What the Client Must Do
Server-side idempotency only pays off if clients retry correctly. Three requirements:
- Reuse the same key on retry. Generating a fresh key per attempt defeats the entire mechanism. The key identifies the logical operation, not the HTTP attempt.
- Back off exponentially, with jitter. Fixed-interval retries from many clients synchronise into waves that keep a recovering service down. Jitter is not optional at scale.
- Retry only what should be retried. Network errors, timeouts, 429, and 5xx are retryable. A 400 or 422 will fail identically every time; retrying it wastes capacity and obscures the real error.
Frequently Asked Questions
Should a repeated request return 200 or 201?
Either is defensible as long as it is documented and consistent. Returning 200 with the existing resource signals "already done" clearly. Returning the original 201 makes the retry transparent, which is simpler for clients. What matters is that the response body is the same resource and that clients are not required to distinguish the two cases.
How long should idempotency keys be retained?
Long enough to outlast any realistic retry window, which in practice means at least as long as your longest client backoff schedule plus a margin. Twenty-four hours is a common published value. The important part is documenting it, because a client retrying after expiry will cause a duplicate and needs to know that is possible.
Does a unique constraint alone make an endpoint idempotent?
It prevents the duplicate row, which is most of the benefit, but only if you handle the resulting violation properly. Returning a 500 on the constraint error means the client sees a failure for an operation that actually succeeded, and it will keep retrying. Catch the violation and return the existing resource. The constraint provides correctness; the error handling provides the idempotent interface.
Conclusion: Design for the Retry
Retries are not an edge case to be handled reluctantly. In any system that crosses a network, they are guaranteed, and the only question is whether your design anticipated them.
Let clients supply identifiers where you can, use idempotency keys stored transactionally with the effect where you cannot, and prefer absolute operations over relative ones throughout. The same discipline applies to data loads: decide how a rerun behaves before the first run, not after the first failure.
