skip to content

Why can a client safely retry an HTTP PUT or DELETE after a timeout, but not a bare POST? Explain what actually goes wrong.

level: middleimportance: must knowfreq 65%

answer

  1. timeout = 3 indistinguishable worlds (lost request / lost response / still running)
  2. retry decision depends on 'is reapplying harmless', not on knowing what happened
  3. PUT/DELETE describe destination state -> converge
  4. POST mints a new identifier per call -> duplicate
  5. fixes: client-chosen id + PUT, or POST with a deduplication key

basics

~20 s

A timeout hides whether the server applied the request. PUT and DELETE describe an end state, so reapplying converges to the same result. POST creates something new each time, so a retry after a lost response creates a duplicate.

solid answer

~60 s

A timeout is **ambiguous**: the request may never have arrived, may have been applied with the response lost on the way back, or may still be running. The client cannot distinguish these. That ambiguity is harmless for **PUT** and **DELETE** because both describe a *destination state*, not a delta. `PUT /users/7` with a full representation says "make it look like this" - applying it twice converges to the same state. `DELETE /orders/42` says "be absent". Reapplying either lands where the first attempt would have. **POST** has no such property. `POST /orders` means "process this and typically create something", so if the first attempt committed and the response was lost, the retry creates a second order. The client sees one failure and the user sees two charges. This is the single most common source of duplicate records in production APIs. The fixes are to make the operation state-describing under a client-chosen URL (turning it into a PUT), or to give POST an explicit deduplication key so the server can recognise the replay.

code

http · 15 lines
http
POST /orders HTTP/1.1
Content-Type: application/json

{"sku":"X-100","qty":2}

(timeout - retry creates a SECOND order)

---

PUT /orders/6f1c2a7e-6b1e-4e2c-9f0a-6f1c2a7e6b1e HTTP/1.1
Content-Type: application/json

{"sku":"X-100","qty":2}

(timeout - retry overwrites the SAME order)

go deeper

for a junior

Explain that a timeout hides whether the write happened, and that repeating a PUT or DELETE lands in the same state while repeating a POST creates a second record.

for a middle

Articulate the three indistinguishable outcomes, why destination-state semantics converge, and name both remedies including client-generated ids with PUT.

for a senior

Cover the operational surface: gateway and client-library retry defaults, where a deduplication key must be committed relative to the effect, and how duplicates surface in payments or notifications.

for a principal

Frame retry safety as a system-wide invariant that every intermediary depends on, and reason about where deduplication should live - client, edge, or service - and its storage and retention cost.

## The ambiguity at the heart of it When a client times out, or the connection drops before the response arrives, it knows exactly one thing: it did not get a response. Three worlds are consistent with that observation: 1. The request never reached the server. 2. The request reached the server, was fully applied, and the **response** was lost. 3. The request is still executing. No amount of client-side cleverness distinguishes these. So the retry decision cannot be based on knowing what happened - it must be based on whether *reapplying* is harmless. That is precisely what idempotency tells you. ## Why PUT and DELETE survive it Both methods express a **destination state** rather than a change: - `PUT /users/7` with the complete representation asserts "after this, the resource at /users/7 is exactly this". The outcome does not depend on what was there before, so applying it twice, or five times, produces the same state as applying it once. In world 2 above, the retry rewrites the same bytes; in world 1 it applies for the first time. Both converge. - `DELETE /orders/42` asserts "after this, /orders/42 does not exist". The second attempt cannot delete it more. It may return 404, but the state is identical. The key insight is **convergence**: state-describing operations are self-correcting under repetition. ## Why POST does not `POST /orders` names a *collection* and asks the server to process the payload - conventionally, to create a new subordinate resource with a **server-assigned identifier**. Each execution mints a new id. In world 2 the first execution created `ord_881`; the retry creates `ord_882`. Nothing in the protocol lets the server recognise the second request as a replay of the first, because they are byte-identical requests to a URL whose meaning is "add another one". The damage is proportional to the side effect. Duplicate rows are annoying; duplicate payment captures, duplicate outbound emails or duplicate shipping labels are incidents. PATCH is in the same category unless the specific patch document is state-describing. ## Two ways out **Turn it into a PUT.** Let the client choose the identifier - typically a UUID it generates - and write `PUT /orders/{client-uuid}`. Now the URL names the intended resource, the operation is state-describing, and a retry overwrites the same resource rather than creating a sibling. This is the structurally cleanest fix and it needs no extra machinery: the retry safety falls out of the method's own semantics. **Give POST a deduplication key.** Where a client-chosen URL is impractical - because the server must assign ids, or the operation is an action rather than a resource - carry a caller-generated unique key on the request (the widely used convention is an `Idempotency-Key` header) and have the server record it. On a repeat, the server returns the stored outcome of the first execution instead of executing again. Correctness details matter here: the key must be recorded in the same transaction as the effect, scoped per caller, retained long enough to outlive realistic retry windows, and a request that arrives while the first is still in flight should get 409 rather than a second execution. ## What this changes about client design Once you internalise the ambiguity, the rule for any HTTP client is: retry on timeouts and connection failures **only** for methods whose semantics make repetition harmless, or where you have supplied a deduplication key. This is why HTTP client libraries, proxies and service meshes default to retrying GET, PUT and DELETE but leave POST alone unless configured - and why turning on "retry everything" in a gateway is a reliable way to manufacture duplicates. ## Interview framing Start with the ambiguity of a timeout - that is the actual insight. Then show that PUT and DELETE converge because they describe an end state, and POST does not because it mints identity per call. Finish with the two remedies, and note which one you would prefer and why.

  • If the client generates the resource id, what stops it from overwriting an existing order belonging to someone else?
    Two things. Use a high-entropy identifier such as a UUIDv4 or v7 so collisions are not a practical concern, and enforce ownership in the handler so a PUT can only create or replace a resource the caller is authorised for. You can also send `If-None-Match: *` to make the PUT create-only, so an existing resource yields 412 Precondition Failed rather than silent replacement.
  • Your gateway has a retry policy. Should it retry POST?
    Not by default. Retrying POST on a timeout duplicates any request whose response was lost, and the gateway has no way to tell that case apart from a request that never arrived. Enable POST retries only for routes documented as idempotent - typically those backed by a server-side deduplication key - and keep the default to GET, PUT and DELETE.

PUT is 'set the thermostat to 21 degrees' - saying it twice changes nothing. POST is 'add a log to the fire' - saying it twice gets you two logs.

saying these in an interview costs you the question

  • Believing a timeout means the request did not reach the server
  • Claiming POST retries are fine because the server 'will detect the duplicate' with no mechanism in place
  • Saying PUT is safe to retry because it is read-only - PUT is idempotent, not safe
  • Assuming PATCH retries are as safe as PUT retries
  • Enabling blanket retry-all in a gateway or client library without route-level analysis

context