PAUL CHONGSenior Software Engineer

Idempotency

2026-08-07 · 6 min read

A network request can fail in ways that leave you uncertain about what actually happened. The connection dropped after you sent the request but before you received a response — did the server process it or not? Retrying without protection risks executing the operation twice: double-charging a card, double-booking a seat, posting the same message twice.

An idempotent operation produces the same result no matter how many times it's executed. Idempotency keys are how you make non-idempotent writes safe to retry.

The problem with retries

Consider a user submitting a post. The client sends the request, the network drops, and the client never receives a response.

Client → POST /api/posts → Server (processes, saves post)
Client ← [connection dropped]
Client: did it work? retry?
  → POST /api/posts → Server (processes again, saves duplicate)

The server created two posts. The user sees a duplicate — or worse, if it was a payment, they were charged twice.

Idempotency keys

The solution is to bind a unique key to the user's action and send it with every attempt. The server uses the key to detect duplicates and return the original result instead of re-executing.

Client → POST /api/posts  (Idempotency-Key: abc-123) → Server (processes, stores result under abc-123)
Client ← [connection dropped]
Client → POST /api/posts  (Idempotency-Key: abc-123) → Server (key seen before, returns stored result)
Client ← 200 OK (original result, no duplicate created)

The key is typically sent as a header or in the request body:

await fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({ body, mediaIds }),
});

Generate the key at submit time, not send time

The key must be generated when the user takes the action — not when the network request is constructed.

// Wrong: key generated at send time
async function sendWithRetry(body) {
  for (let i = 0; i < 3; i++) {
    await fetch('/api/posts', {
      headers: { 'Idempotency-Key': crypto.randomUUID() }, // new key each attempt
      body,
    });
  }
}

// Right: key generated at submit time, reused across retries
function handleSubmit(body) {
  const idempotencyKey = crypto.randomUUID(); // bound to this user action

  dispatch(submitPost({ body, idempotencyKey }));
  // outbox or retry logic reuses the same key on every attempt
}

If you generate a new key on each retry, the server sees each attempt as a new, distinct request and creates a duplicate. The key must be bound to the user's intent, not to the network transmission.

Storing the key in an outbox

An idempotency key stored in a local variable is lost the moment the function returns. If the page refreshes or the connection drops and reconnects, the key is gone — and any retry will generate a new one, defeating the purpose.

An outbox is a persistent queue of pending writes that haven't been confirmed by the server yet. It's just an array or object stored somewhere durable — localStorage, IndexedDB, or a state store — that the client reads from when sending or retrying. Each entry holds the full action and its idempotency key. On success, the entry is removed. On failure, it stays so the next retry can pick it up with the same key.

// The outbox is just an array in localStorage
function getOutbox() {
  return JSON.parse(localStorage.getItem('outbox') ?? '[]');
}

function saveOutbox(entries) {
  localStorage.setItem('outbox', JSON.stringify(entries));
}

// On submit: add to outbox with a stable key
function handleSubmit({ body, mediaIds }) {
  const pendingPost = {
    id: crypto.randomUUID(),  // stable idempotency key for this action
    body,
    mediaIds,
    status: 'pending',
  };

  saveOutbox([...getOutbox(), pendingPost]);
  attemptSend(pendingPost);
}

// On send (and any retry): key comes from the outbox entry, never regenerated
async function attemptSend(pendingPost) {
  try {
    await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Idempotency-Key': pendingPost.id },
      body: JSON.stringify({ body: pendingPost.body, mediaIds: pendingPost.mediaIds }),
    });
    // Success: remove from outbox
    saveOutbox(getOutbox().filter(e => e.id !== pendingPost.id));
  } catch {
    // Failed: leave in outbox, retry later with the same key
  }
}

// On reconnect or page load: flush any pending entries
window.addEventListener('online', () => {
  getOutbox().forEach(attemptSend);
});

Because the key is written to localStorage before the first send attempt, it survives page refreshes, reconnects, and any number of retries. The server always sees the same key and deduplicates accordingly.

The outbox pattern is a form of eventual consistency — the client and server are temporarily out of sync from the moment the user submits until the request lands. During that window the post exists locally but not on the server. Eventually they converge. This is the same tradeoff as optimistic updates, but the motivation is different: optimistic updates are about feeling fast, the outbox is about durability — ensuring the write survives even if the network doesn't.

What the server does

The server stores the result of each idempotency key for long enough to cover any reasonable retry window (typically 24 hours):

key: "abc-123" → { status: 201, body: { id: 99, ... } }

On a duplicate request:

  1. Look up the key — found
  2. Return the stored result without re-executing the handler
  3. The client receives an identical response to the original

The check must happen inside a transaction with the write, or under a distributed lock — otherwise two simultaneous requests with the same key can both pass the check before either has written the result.

When you need it

Idempotency keys apply to any write that could be retried:

  • Post / message creation — a retry creates a duplicate visible to other users
  • Reactions and shares — a double-fire can increment a counter twice
  • Cart mutations — adding an item twice results in quantity 2
  • Booking and checkout — the most dangerous case; a duplicate charge or reservation is expensive to reverse

Not needed for GET requests — reads are already idempotent by definition. Fetching the same data twice has no side effects.

Not needed for DELETE in most cases — deleting an already-deleted resource typically returns 404, which is an acceptable outcome. If your server returns an error on a second delete and that breaks the client, an idempotency key can smooth it over, but usually it's not worth it.

Idempotency vs deduplication

These are related but distinct:

  • Idempotency is a server-side guarantee: given the same key, always return the same result.
  • Deduplication (as in Client-Side Caching) is a client-side optimization: coalesce concurrent identical requests into one.

Deduplication prevents sending duplicate requests in the first place. Idempotency handles the case where a duplicate gets through anyway — because of a retry, a network middlebox, or a race between two clients.