Guides / Reliability / Idempotency

Making retries safe with idempotency keys

Updated March 4, 2026 · 7 min read

Network calls fail in ways that are hard to tell apart. A timeout might mean the request never arrived, or that it succeeded and the response was lost on the way back. This guide explains how idempotency keys let clients retry without risking duplicate side effects.

AE2SB2DE3FG3GB3HE4
Scenario: E minor · E G BKeys A–H · 1–8 · Space
Figure 1. Request timeline for a retried operation. Each lane is a client and each marker is an attempt.

Overview

An operation is idempotent if performing it several times has the same effect as performing it once. Reads are naturally idempotent. Writes usually are not: submitting the same order twice creates two orders. Idempotency keys close that gap by letting the client attach a unique token to a request so the server can recognise a repeat and return the original result instead of doing the work again.

Why retries are risky

Consider a client that sends a request and hits a timeout. From its point of view there are three possibilities, and it cannot distinguish between them:

  • The request never reached the server, so nothing happened.
  • The request was processed, but the response was dropped.
  • The request is still being processed and will finish shortly.

Retrying is correct in the first case, harmful in the second, and racy in the third. Without a way to correlate attempts, the client has to choose between possible duplicates and possible data loss.

Note

Requests using GET, PUT and DELETE are already defined as idempotent by HTTP semantics. Idempotency keys matter mainly for POST and other non-idempotent operations.

Using idempotency keys

The client generates a unique value, such as a random UUID, once per logical operation and sends it in an Idempotency-Key header. Every retry of that operation must reuse the same key. A new operation gets a new key.

async function createOrder(order) {
  const key = crypto.randomUUID();

  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      const res = await fetch("/v1/orders", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify(order),
      });
      if (res.status < 500) return res.json();
    } catch {
      // Network error: fall through and retry with the same key.
    }
    await sleep(2 ** attempt * 250);
  }
  throw new Error("Order request failed after retries");
}

Only retry when the outcome is unknown: network errors and 5xx responses. A 4xx response means the request itself was rejected, and sending it again unchanged will not help.

Server-side storage

On the server, the key maps to a small record that tracks the state of the operation. Store a fingerprint of the request body alongside it, so a reused key with different parameters can be rejected rather than silently returning an unrelated response.

const existing = await store.get(key);

if (existing?.state === "completed") {
  return replay(existing.response);
}
if (existing?.state === "in_progress") {
  return conflict({ retryAfter: 2 });
}

await store.put(key, { state: "in_progress", fingerprint });
const response = await handler(request);
await store.put(key, { state: "completed", response });
return response;
StateMeaningClient sees
in_progressThe first attempt is still running.409 with a Retry-After header
completedThe operation finished.The stored response, replayed
failedIt errored before any side effects.Safe to retry under the same key

Expiry and cleanup

Keys do not need to live forever. Retries happen within seconds or minutes, so a retention window of 24 hours covers nearly every real scenario while keeping the table small. Document the window so clients know how long a key stays valid, and return a clear error if a key is reused after it has expired.

Common pitfalls

  • Generating a new key per attempt. This defeats the purpose. The key identifies the operation, not the request.
  • Checking and writing in separate steps. Two concurrent requests can both see no record. Use an atomic insert or a unique constraint on the key.
  • Storing the key but not the response. A replay must return the original outcome, not re-run the handler.
  • Scoping keys globally.Namespace them per account so one customer's key can never collide with another's.

Testing your implementation

Write tests that force each failure mode rather than relying on production traffic to find them. At a minimum, cover a dropped response followed by a retry, two simultaneous requests with the same key, and a reused key with a different body. Fault-injection proxies make the first two straightforward to simulate in an integration environment.

Was this page helpful?