Skip to the solution
MediumSystem Design16 min read

What is idempotency and why does it matter?

1.1k views

The problem

Question presented to candidate: "We keep hearing that an API or a message handler should be idempotent. What does idempotency actually mean, why do distributed systems need it, and how would you make a non-idempotent operation like 'create an order' safe to retry?"

What a strong answer should cover:

  • The definition: doing an operation many times has the same effect as doing it once (the response may differ, the state must not)
  • Why it matters: timeouts make the outcome unknown, so clients retry; queues deliver at-least-once; without idempotency, retries create duplicates or double charges
  • HTTP semantics: GET, HEAD, OPTIONS, PUT and DELETE are idempotent by definition (RFC 9110); POST and PATCH are not
  • The fix for non-idempotent operations: a client-generated idempotency key, a server-side table that stores key, request fingerprint and saved response, enforced with a unique constraint
  • Details that separate strong answers: reject the same key with a different payload, handle a second request while the first is still in flight, set a retention window, and dedup by message ID in consumers
  • Idempotency plus at-least-once delivery is how systems get "effectively once" behaviour

Code / implementation expected: Optional โ€” sketching the key lookup, the unique insert and the replayed response is a plus; the example simulates a lost response and a duplicate-delivering queue.

  • idempotency

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

16 min read ยท 11 sections

Read the code

Idempotency in miniature: PUT vs POST, an idempotency-key store surviving a lost response, payload mismatch, and a dedup consumer
// Idempotency in miniature: natural vs non-idempotent operations, an idempotency-key
// store that survives a lost response, payload mismatch, and a deduplicating consumer.

console.log("== 1. Same request applied 1x and 3x ==");
// Cart state: quantity of item 7 starts at 1. 0 means the line is absent.
const ops = [
  ["PUT  /cart/7 {qty: 2}", (q) => 2],
  ["POST /cart/7/add {qty: 1}", (q) => q + 1],
  ["DELETE /cart/7", (q) => 0],
];
for (const [name, f] of ops) {
  const once = f(1);
  let thrice = 1;
  for (let i = 0; i < 3; i++) thrice = f(thrice);
  console.log(`${name.padEnd(27)} qty after 1x=${once} after 3x=${thrice} idempotent=${once === thrice}`);
}

// A server that creates orders. The network drops the FIRST response it sends.
function makeServer(useKeys) {
  const orders = [];
  const keys = new Map(); // idempotency key -> { fingerprint, response }
  let responsesSent = 0;
  return {
    orders,
    handle(req) {
      if (useKeys && req.key) {
        const prev = keys.get(req.key);
        if (prev) {
          if (prev.fingerprint !== JSON.stringify(req.body))
            return { status: 422, body: "key reused with a different payload" };
          return { ...prev.response, replayed: true };
        }
      }
      const order = { id: `ord_${orders.length + 1}`, item: req.body.item, qty: req.body.qty };
      orders.push(order);
      const response = { status: 201, body: order.id, replayed: false };
      if (useKeys && req.key) keys.set(req.key, { fingerprint: JSON.stringify(req.body), response });
      responsesSent++;
      if (responsesSent === 1) return null; // response lost on the way back: client times out
      return response;
    },
  };
}

function clientCreate(server, body, key, maxAttempts) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = server.handle({ key, body });
    if (res === null) { console.log(`  attempt ${attempt}: timeout (response lost)`); continue; }
    console.log(`  attempt ${attempt}: ${res.status} ${res.body}${res.replayed ? " (replayed)" : ""}`);
    return res;
  }
}

console.log("\n== 2. Client retries after a lost response, no key ==");
const naive = makeServer(false);
clientCreate(naive, { item: "book", qty: 1 }, null, 3);
console.log(`  orders stored: ${naive.orders.length} -> ${naive.orders.map((o) => o.id).join(", ")}`);

console.log("\n== 3. Same retry with Idempotency-Key: k-42 ==");
const safe = makeServer(true);
clientCreate(safe, { item: "book", qty: 1 }, "k-42", 3);
console.log("  a later retry of the same key:");
clientCreate(safe, { item: "book", qty: 1 }, "k-42", 1);
console.log(`  orders stored: ${safe.orders.length} -> ${safe.orders.map((o) => o.id).join(", ")}`);

console.log("\n== 4. Same key, different payload ==");
clientCreate(safe, { item: "book", qty: 5 }, "k-42", 1);
console.log(`  orders stored: ${safe.orders.length}`);

console.log("\n== 5. At-least-once queue delivers duplicates ==");
const deliveries = ["m1", "m2", "m2", "m3", "m1", "m4", "m3"];
let naiveCount = 0, dedupCount = 0;
const processed = new Set();
const naiveTotal = { cents: 0 }, dedupTotal = { cents: 0 };
for (const id of deliveries) {
  naiveCount++; naiveTotal.cents += 500;
  if (processed.has(id)) continue;
  processed.add(id); dedupCount++; dedupTotal.cents += 500;
}
console.log(`  deliveries=${deliveries.length} unique=${processed.size}`);
console.log(`  without dedup: handled=${naiveCount} credited=$${(naiveTotal.cents / 100).toFixed(2)}`);
console.log(`  with dedup:    handled=${dedupCount} credited=$${(dedupTotal.cents / 100).toFixed(2)}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 10 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How do you handle asynchronism, message queues and backpressure at scale?

Continue