Skip to main content
Idempotencylesson 2 of 3 · 2 min read

Idempotency Keys

Name the operation, not the attempt

Give every logical operation a name, and you have the idempotency key pattern.

Your client generates a unique identifier and sends it with the request, conventionally in a header. That key belongs to the operation, not to the attempt: the first try and its retry carry the same key, and that is exactly what lets your server connect them.

Mint a fresh key for a deliberate second order, though, because your user really does want two.

Follow four steps on the server: check, reserve, execute, record. Look the key up, and if a stored result exists, return it without doing any work at all. If not, record the key, run the operation, then store the response against that key.

Copy the design one payment company popularised, keeping keys for 24 hours, long enough to cover any sane retry horizon.

Make the check-and-reserve step atomic, the part people get wrong. A set-if-absent, or a uniqueness constraint. Two concurrent duplicates will otherwise both pass the check and both execute, and the one that loses the race should either wait for the winner's result or refuse outright.

Where the key must live

Watch for the subtler consistency trap. If your key lives in a cache while the operation commits to your database, a crash between the two leaves them disagreeing: the cache says done, the database says nothing happened, and the legitimate retry gets swallowed.

Put the key in the same database as the operation for anything involving money, inside the same transaction, on a unique index. Your reservation and your work then commit or roll back together.

Budget the overhead honestly: one extra lookup and one extra write per request. For anything that moves money or changes state that matters, that is not a cost worth debating.

the shape of it
ClientPayment APIKey storeunique key + resultCard network1. POST + Idem-Key2. seen this key?3a. hit: saved reply3b. miss: charge4. same response
step 1 of 4
A retry with the same key gets the stored response back; the card network is only ever called once.
check, reserve, execute, record
Java
Charge charge(String idempotencyKey, long amountPence) {
  // For money, the key lives in the same database as the work, in
  // the same transaction. Put it in a cache instead and a crash
  // between the two leaves them disagreeing, and the honest retry
  // gets swallowed.
  tx.begin();
  try {
    // Unique index on the key column does the reserving. Two
    // concurrent duplicates cannot both pass this.
    db.exec("INSERT INTO charge_requests (key, state) VALUES (?, 'running')",
            idempotencyKey);
  } catch (DuplicateKeyException e) {
    tx.rollback();
    return awaitResult(idempotencyKey);     // the first attempt owns it
  }

  Charge c = gateway.charge(amountPence);
  db.exec("UPDATE charge_requests SET state = 'done', response = ? WHERE key = ?",
          c.toJson(), idempotencyKey);
  tx.commit();                              // reservation and work commit together
  return c;
}

Worked example

After the double-charge weekend, Grace gets two days to fix the charge endpoint. She skips the cache entirely and adds a charge_requests table in the same relational database, with a unique index on idempotency_key and a column holding the serialized response. The handler inserts the key and the charge row in one transaction; on a unique violation it reads and returns the stored response instead. The mobile team ships a client that generates a UUID per checkout and reuses it across retries. Load tests fire the same request 50 times concurrently: one charge row, 49 replayed responses, every time. In the first full month duplicate charges go from 1,400 in a bad weekend to zero, and the whole change is about 60 lines.