06 · APIs & Web Services

Idempotency

8 min read · 5 questions · 1 exercise

An idempotent operation has the same effect whether it runs once or twice, which is what lets a client retry a request it cannot tell succeeded.

A customer taps Pay as their train enters a tunnel. The request gets out: the server charges the card 49.99, records the payment, and sends back 201 Created. The reply never arrives. Ten seconds later the app stops waiting and reports a timeout.

Now the app has to decide what to do, and it has nothing to decide with. A timeout looks the same whether the request was lost on the way out, the reply was lost on the way back, or the server is still working. Give up, and the customer may have paid for an order the screen says failed. Send it again, and the customer may pay twice. The way out is to make sending it again harmless. An operation is idempotent when doing it twice leaves the system in the same state as doing it once.

Why every layer retries

Retrying is the only sensible response to a timeout, and it happens at every layer, often without your code asking. Some client libraries retry dropped connections on their own, a load balancer may resend a request to another server, an app that queued requests while offline replays them, and a message broker redelivers anything not acknowledged. A server that is not safe to retry gets retried anyway.

Some operations are safe by construction. Run SET balance = 90 twice on an account holding 100 and it holds 90; run SET balance = balance - 10 twice and it holds 80, and the customer is ten short. HTTP defines PUT and DELETE as idempotent and POST as not, but a method only states an intent, and some work cannot be phrased as setting a value: creating a payment, sending an email, booking a seat. That work has to be made idempotent, and the standard tool is a key.

A key, and the reply stored under it

The client makes up a unique string, usually a random UUID, when the customer presses Pay, and sends it with the request, typically in an Idempotency-Key header. Every retry of that payment carries the same string. The key names the operation, not the attempt: a key generated inside the retry loop is a new key on every attempt, which is the same as having none.

The server keeps a table of keys and looks the key up before it does anything. A key it has never seen means new work: do it, and store the reply under the key. A key whose work is done gets the stored reply back, and nothing else happens.

Storing the reply, rather than a flag that says seen, matters more than it looks. The retry is the client’s only chance to learn how the payment went. A server that answers a duplicate with an error has prevented the second charge and left the client not knowing the payment id, or whether there was a payment at all.

So a key’s record holds the key, scoped to the account that sent it so that two customers can never collide; a fingerprint of the request body; a state, in progress or done; the stored status and body; and when it was created. The fingerprint catches a bug that would otherwise pass silently: the same key sent with a different request, such as a cart edited to 59.99 after the 49.99 attempt timed out. Replaying the 49.99 reply would be wrong and charging 59.99 would break the key’s promise, so the honest answer is an error — 422 in the code below.

In the scene, each lane loses its first reply and retries once. A server’s badge counts the requests it received; a charges badge counts the times a card was charged.

Predict first

Above, the server has no key to check. Below, the request carries Idempotency-Key: k-7. In each lane the first reply is lost and the client retries once. When the lap ends, what do the two charges badges read?

  1. That reads the timeout as proof that the first attempt failed. It did not: the card was charged and only the reply was lost, so above, the retry is a second charge.

  2. That would be true if the key only made the second reply match the first. The lower server checks k-7 before charging, finds it done, and sends back the stored 201 without reaching charges again.

  3. Both servers receive 2 requests. Above, nothing links them, so each one charges. Below, the retry finds k-7 with its stored 201, so the work happens once and the answer is sent twice.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

The same retry, with and without a key

a request or reply · amber = timeout, rust = extra charge, teal = stored reply

NO KEYWITH A KEY
client
server
charges
server
charges
keys

One lost reply and one retry, with no key and then with one — use Next step to go one at a time.

Both servers received two requests and lost a reply. The lower one differs only in having written down what it did, and in checking before doing it again: the request still arrives twice, and the work happens once.

Two retries at once

The scene retries after the first attempt has finished. A client with a two-second timeout, talking to a server that takes three seconds to charge a card, retries while the first attempt is still running, and now two requests carrying k-7 are in flight at once.

Suppose the server looks the key up, finds nothing, charges, and only then records the key. Both requests look before either records, so both charge. This is check-then-act, the classic race, and it runs straight past the table that was meant to stop it. The fix is to claim the key before the work, in one step that fails if the key already exists — in a database, a unique constraint on the key and an insert that reports whether it won, such as PostgreSQL’s INSERT … ON CONFLICT DO NOTHING. One request wins. The other finds the key in progress and gets 409 Conflict, and its next retry, once the first has finished, gets the stored reply. Write the payment row and the key’s reply in one transaction, too, so that neither can exist without the other.

Expiry, and queues

Keys cannot be kept forever, so they expire, and the window has to outlast every retry that could still arrive: a retry that lands after its key has gone looks exactly like a new payment. Work it out from the retry policy. With a 10-second timeout and waits of 1, 2, 4 and 8 seconds between four retries, the last retry leaves 4 × 10 + 15 = 55 seconds after the first attempt, so minutes would do. An app that queues payments offline and replays them tomorrow needs days. Size the window for the slowest path a retry can take, not the usual one.

Queues are the other source of duplicates, and there they are routine: at-least-once delivery hands a consumer the same message again whenever a worker dies before acknowledging it. A consumer is a server receiving retries, and the message id is its key. A consumer that credits an account and then records the id as a second step can crash between the two, and the redelivery credits it again. Write both in one transaction and the redelivery either finds the id and does nothing, or finds no id because nothing happened.

That only works when the effect lives in the same database as the record. A card charge or an email happens in someone else’s system, and no transaction spans both. There, pass the key along: many payment providers accept an idempotency key of their own, so the system that does the work is also the one that refuses to do it twice.

The lookup, the fingerprint, the claim before the work and the stored reply fit in one function:

type Reply = { status: number; body: unknown };
type KeyRecord =
  | { state: 'in-progress'; fingerprint: string }
  | { state: 'done'; fingerprint: string; reply: Reply };

const keys = new Map<string, KeyRecord>();

async function withIdempotency(
  key: string,
  request: unknown,
  handle: () => Promise<Reply>,
): Promise<Reply> {
  const fingerprint = JSON.stringify(request);
  const seen = keys.get(key);
  if (seen && seen.fingerprint !== fingerprint) {
    return { status: 422, body: 'this key was sent with a different request' };
  }
  if (seen?.state === 'done') return seen.reply;
  if (seen) return { status: 409, body: 'the first attempt is still running' };

  keys.set(key, { state: 'in-progress', fingerprint });
  const reply = await handle();
  keys.set(key, { state: 'done', fingerprint, reply });
  return reply;
}

There is no await between keys.get and the first keys.set, which is what makes the claim atomic here: a Node process runs one request’s synchronous code to completion before another’s can start, so two retries cannot both find an empty slot. Across several servers the Map becomes a table and the claim becomes the insert above. JSON.stringify stands in for a hash of the body, and a real record would also carry its creation time.

Try it

Give the key back when the work fails

If handle throws, say because the payments database was unreachable before the card was touched, the key stays in progress for good. Every retry of that payment then gets 409, and the payment can never complete. Change withIdempotency so that a failed attempt releases its key, and a retry after the failure runs the work again. The error should still reach the caller.

Hint

Wrap the work in try/catch. The thing to undo in the catch is the in-progress record. Then ask what a throw actually tells you about whether the card was charged.

Solution
async function withIdempotency(
  key: string,
  request: unknown,
  handle: () => Promise<Reply>,
): Promise<Reply> {
  const fingerprint = JSON.stringify(request);
  const seen = keys.get(key);
  if (seen && seen.fingerprint !== fingerprint) {
    return { status: 422, body: 'this key was sent with a different request' };
  }
  if (seen?.state === 'done') return seen.reply;
  if (seen) return { status: 409, body: 'the first attempt is still running' };

  keys.set(key, { state: 'in-progress', fingerprint });
  try {
    const reply = await handle();
    keys.set(key, { state: 'done', fingerprint, reply });
    return reply;
  } catch (error) {
    keys.delete(key); // nothing was stored, so a retry may run the work again
    throw error;
  }
}

Deleting the record is right only when a throw means the work did not happen. If handle charged the card and then failed to write the payment row, giving the key back lets the retry charge again, and a timeout from the card network is this page’s opening problem one level down. That is the case for passing the key on to the payment provider as well: it then recognizes a second charge attempt and returns the first one’s result, even after your own record has been released.

To see it work, fail the first attempt and retry:

let attempts = 0;
const charge = async (): Promise<Reply> => {
  attempts += 1;
  if (attempts === 1) throw new Error('payments database unavailable');
  return { status: 201, body: { paymentId: 'pay_1' } };
};

const payment = { amountCents: 4999 };
await withIdempotency('k-7', payment, charge).catch(() => undefined);
const retry = await withIdempotency('k-7', payment, charge);
console.log(retry.status, attempts); // 201 2 (before the change: 409 1)

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

A mobile app wraps POST /payments in a retry loop and calls crypto.randomUUID() for the Idempotency-Key at the top of each attempt. The first reply is lost and the loop retries once. What does the server make of the two requests?

  1. That needs the retry to carry the key the first attempt stored. A fresh UUID per attempt is a key the server has never seen, so there is no stored reply to find.

  2. It is the other way round: the bodies are identical and the keys differ. A 422 is for a known key that arrives with a different request.

  3. A 409 is for a key that is still in progress. The first attempt had finished, since its reply was lost after the work, and in any case the retry carries a different key.

  4. The key has to name the payment, not the attempt. Generated inside the loop, it changes every time, which is the same as sending none. Create it once, when the customer presses Pay, and reuse it on every retry.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

Your server looks the key up with a SELECT, and only if it is absent charges the card and then INSERTs the key with the reply. A charge takes 3 seconds. A client whose timeout is 2 seconds sends its retry, with the same key, while the first attempt is still charging. How many charges are made?

  1. The first request writes its key only when its 3-second charge is done, so the retry, at 2 seconds, looks and finds nothing. Both charge. Claim the key as in progress before charging, with an insert that fails on a duplicate, and the retry gets 409 instead.

  2. The key is written after the charge finishes, at 3 seconds, and the retry looked at 2. A key recorded after the work protects only the retries that arrive after the work.

  3. Each statement is atomic, but the SELECT and the INSERT are separate statements with a 3-second charge between them, and the other request runs its SELECT in that gap. Atomic statements do not make a check-then-act sequence atomic.

  4. A 409 needs a record saying the first attempt is in progress, and this design writes nothing until the work is done. Inserting the key as in progress before charging is exactly what would produce it.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

Keys expire 24 hours after they are stored. A nightly job sends 500 refunds, each keyed by its refund id, and crashes after 300 have succeeded. Someone reruns it the next evening, 30 hours after the first run, with the same keys. How many customers are refunded twice?

  1. Only the 300 that succeeded the first night can be paid twice. The other 200 were never paid, so the rerun pays them for the first time, which is what it is for.

  2. The 300 successful refunds stored their keys 30 hours ago, and those records expired at the 24-hour mark, 6 hours before the rerun. A key the server has forgotten is a new request, so each of the 300 is paid again. The window has to outlast the slowest path a retry can take, and a rerun the next day is one.

  3. The 200 that never ran are new work, and the rerun pays each of them once, which is correct. The duplicates are among the 300 that had already been paid.

  4. Identical keys only help while the server still remembers them. After 24 hours the records are gone, and the rerun's keys look brand new to it.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

A consumer handles deposit messages. In one database transaction it credits the account and inserts the message id into a processed table with a unique key. It commits, then crashes before acknowledging, and the broker redelivers the message. What happens to the account?

  1. The missing ack makes the broker deliver again, but a delivery is not a credit. The consumer finds the message id in processed and skips the work.

  2. The crash came after the commit, and a committed transaction survives a crash; that is the durability in ACID. Only uncommitted work is rolled back.

  3. The credit and the id committed together, so the redelivered message finds its id and is acknowledged without doing anything. Had the crash come before the commit, neither would exist, and the redelivery would do the work for the first time.

  4. A message is dead-lettered after repeated failures, and this one has been redelivered once. Its second delivery succeeds, as a no-op.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Key takeaways

  • A timeout cannot tell a lost request from a lost reply, so any request a client might retry will sooner or later arrive twice.

  • An idempotency key names one operation, not one attempt: the client picks it once, and the server stores the reply under it so a retry gets the original answer.

  • Claim the key as in progress, atomically, before doing the work; a key recorded afterwards is raced past by a retry that arrives during it.

  • Keep keys longer than the slowest retry, and in a queue consumer, record the message id in the same transaction as its effect.