Skip to content
Software Atlas

Idempotency & Retries

Why every network call can end in “I don't know”, why exactly-once delivery is impossible, and how idempotency keys, dedup tables and careful retries give you exactly-once effects anyway.

⏱ ~50 min read13 sections7 interactive widgetsGo · JavaScript · PostgreSQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceOver a network you can't always know whether a request worked, so you have to retry. Retrying is only safe if doing something twice has the same effect as doing it once. That property is called idempotency, and you usually have to build it yourself.

You tap Pay €50. The spinner turns for ten seconds, then: "Something went wrong. Please try again." Do you tap again? Maybe the payment never went through. Or maybe it did, and only the "success" message got lost on the way back to your phone. You can't tell, and neither can the app.

Watch both endings. First as most code is written, then with one small addition:

The lost reply
One tap, one lost response, one retry. Step through it, then switch on the key.
📱 Your phone🖥 Shop server💳 Card charges
Cart: jacket, €50
idle
€0 charged
You're buying a €50 jacket on your phone. Press Next or Play.
1 / 8

The fix in the second run is an Idempotency key: a random ID the app creates once per purchase and sends with every attempt. The server writes down which keys it has finished. When a key comes back, the server doesn't do the work again; it just sends the saved answer.

What this topic covers

  1. Why retries are unavoidable: a request has three outcomes, not two (§02).
  2. What makes an operation safe to repeat, and how to design operations that are safe by nature (§03).
  3. Why Exactly-once delivery is impossible, and what real systems do instead (§04).
  4. Idempotency keys end to end, with a working Postgres implementation in Go and JavaScript (§05–§07).
  5. The same idea for message consumers that receive duplicates (§08).
  6. Retrying well: which errors to retry, how long to wait, and how retries can take down a whole system (§09).
  7. Running it in production (§10), then pitfalls, a quiz and a cheat sheet.
Foundations§02

Success, failure, and “I don't know”

In one sentenceA function call in your own program either returns or throws. A call over a network has a third outcome: "I don't know." Every Timeout lands you there.

A program talking to a server over the network is in exactly that position. The trip has several legs (the request travels, the server works, the reply travels back), and any of them can fail without telling you which. Try each failure:

Where did it break?
Pick a failure. Compare what the phone sees with what really happened.
📱 phone→network→🖥 server→💳 charge→✗ network→📱 phone
What the phone sees
⌛ No answer after 10 s.
Exactly the same in all five cases.
What really happened
Card charged ✓
Everything worked. The “success” reply was dropped by the network.
If the app gives up
You paid, but the app says it failed. You'll probably pay again by hand.
If the app retries (no key)
Charged twice. 💸
Neither choice is right in every case, and the phone can't tell the cases apart. The way out is to make the retry safe in all five.

Look at the left box: it never changes. In every case, all the phone knows is "no answer within 10 seconds". When part of a system fails while the rest keeps going, and the caller can't see which part, that's called a Partial failure. It's the defining problem of any Distributed system: a program spread over several machines that talk over a network.

Three outcomes, not two

OutcomeWhat the caller knowsWhat to do
Success (e.g. 201 Created)It happened.Carry on.
Failure (e.g. 400 Bad Request)It did not happen, and trying again won't change that.Show an error; fix the request.
Unknown (timeout, connection reset, 502/504 from a Proxy)It may or may not have happened. It may even still be happening.Retry, but only if a repeat is harmless.

That last row is the whole topic. "It may still be happening" matters: in the just slow case, the first request is still running on the server when you retry. So your retry and the original can run at the same time. Keep that in mind; it comes back in §05.

"Just check first, then retry"?

A natural idea: before retrying, ask the server "did my payment go through?". Two problems:

  1. Which payment? If you paid €50 to the same shop yesterday, "a €50 payment" doesn't identify this one. You need a unique name for this purchase, sent with the original request. Hold that thought: that unique name is the idempotency key from §01.
  2. The check races the original. In the just slow case, your check runs while the first request is still in progress. The check says "no payment yet", you retry, and then the original finishes too. Two charges.

So checking first doesn't remove the problem. The retry itself has to be safe.

You will get duplicates, even if you never write a retry

It's tempting to think "my code doesn't retry, so I don't get duplicates". Here's who retries on your behalf:

  • People. They tap again, refresh, open a second tab.
  • Apps and SDKs. Many HTTP client libraries retry failed requests automatically.
  • Proxies and load balancers. They may resend a request to another server if the first one fails (Load Balancing §09).
  • Go's own HTTP client. It quietly resends some requests on a fresh connection when a reused one turns out to be dead (HTTP §08).
  • Message queues. They redeliver any message whose consumer didn't confirm it in time (§08).
  • Schedulers. A job that crashed halfway gets restarted from the top.
Foundations§03

What “idempotent” really means

In one sentenceAn operation is Idempotent if doing it twice leaves the world exactly as doing it once. In math terms: f(f(x)) = f(x).

Everyday examples:

  • "Turn the light on" is idempotent: pressing it again changes nothing. "Toggle the light" is not: the second press undoes the first.
  • An elevator call button: pressing it five times still brings one elevator.
  • Rounding a number: round(round(2.7)) = round(2.7) = 3.

In software, the "world" is your data plus anything else you change: rows, files, emails sent, money moved. Those changes are called side effects. Try a few operations:

Press it twice
Pick an operation, run it several times, and compare the state after each run.
SET balance = 50
start: balance = 100
after run 1
not run
after run 2
not run
after run 3
not run
after run 4
not run
Run it at least twice to get a verdict.

Two things idempotency does not mean

It doesn't mean the same reply. DELETE /cart/items/3 answers 204 the first time and 404 Not Found the second. That's fine: the cart is in the same state either way. What we care about is the effect on the data. (A good idempotency-key server goes further and does return the same reply every time; that's §05.)

It doesn't mean "no side effects at all." Writing a log line or bumping a metric on every request is fine. Nobody is harmed by two log lines. What matters is the effects someone would notice: money, messages, stock counts.

Three ways to make an operation idempotent by design

Before reaching for idempotency keys, see if you can make the operation safe by nature. It's simpler and has nothing to clean up later.

1. Say the result, not the change. "Set the quantity to 3" can be repeated; "add 1 to the quantity" can't. Many APIs can be reshaped this way: PUT /cart/items/7 {"qty": 3} instead of POST /cart/items/7/increment.

2. Let the caller pick the ID. If the client generates the order's ID (a random UUID) and sends it along, the server can tell a repeat from a new order. With a UNIQUE constraint on the ID, a repeated insert is simply skipped. An ID that comes from the caller or from the real world, rather than from the database, is a Natural key.

✕ Don't
-- Every run adds another €50. A retry = €100.
UPDATE wallets
SET balance_cents = balance_cents + 5000
WHERE id = 7;
✓ Do
-- The top-up has its own ID, chosen by the caller.
-- A retry inserts nothing: "INSERT 0 0".
INSERT INTO topups (id, wallet_id, cents)
VALUES ('topup-8f2c41', 7, 5000)
ON CONFLICT (id) DO NOTHING;
-- The balance is then SUM(cents) over topups,
-- or is updated only when the insert really happened.

ON CONFLICT (id) DO NOTHING is PostgreSQL for "if a row with this ID already exists, skip this insert instead of failing". Postgres reports INSERT 0 1 when a row was inserted and INSERT 0 0 when it was skipped. The cousin ON CONFLICT … DO UPDATE is an Upsert.

3. Only act if the data is still in the state you expect. A Conditional write carries its own precondition:

UPDATE orders SET status = 'shipped'
WHERE id = 81 AND status = 'paid';   -- 1st run: UPDATE 1.  Repeat: UPDATE 0.

The WHERE status = 'paid' makes it a one-way step: once the order is shipped, the condition is false and the repeat does nothing. The same trick with a version number (WHERE version = 6) is Optimistic locking. (Going further: HTTP has the same idea. An If-Match header carrying an ETag means "only do this if the resource is still at this version".)

HTTP methods, briefly

HTTP's spec labels GET, HEAD, PUT, DELETE and OPTIONS idempotent, and POST (and usually PATCH) not. HTTP §08 covers the details. Two things worth adding:

  • The label is a promise your handler must keep, not something HTTP enforces. A PUT handler that appends to a list, or sends an email every time, breaks the promise, and every proxy that retries PUTs will punish you for it.
  • POST is where the money is: creating orders, charging cards, sending messages. You can't make "create a new thing" idempotent by rephrasing it. That's what idempotency keys are for.
Practitioner§04

At-most-once, at-least-once, and the exactly-once myth

The ruleExactly-once delivery over a network is impossible. Exactly-once effect is easy to get: deliver at least once, and make the receiver ignore repeats.

Any system that sends messages (an app calling an API, a service publishing events, a queue handing out jobs) makes one of three promises:

  • At-most-once delivery: send once, never resend. Nothing is ever duplicated, but a lost message is gone for good. Fine for a "user is typing…" indicator; terrible for payments.
  • At-least-once delivery: keep resending until the receiver confirms. Nothing is lost, but the receiver may get the same message several times.
  • Exactly-once: every message arrives once, no more, no less. Everyone wants it. Over an unreliable network, nobody can promise it.

Here's how each behaves when the confirmation gets lost:

Three delivery promises, same lost reply
at-most-once: send, never resend
sender
send
no reply…
give up
receiver
do work
✗ reply lost
at-least-once: resend until acknowledged
sender
send
no reply…
resend
ok
receiver
do work
✗ lost
do work AGAIN
“exactly-once” = at-least-once + dedup
sender
send #m1
no reply…
resend #m1
ok
receiver
do work, note m1
✗ lost
m1 seen → skip
The receiver crashes after doing the work but before its “done” reaches the sender. At-most-once never resends, so if the message itself had been lost, the work would simply never happen. At-least-once resends, so the work runs twice unless the receiver remembers it. Adding dedup makes the repeat harmless.

Why exactly-once delivery can't exist

The receiver confirms with an acknowledgement (ack). But the ack is itself a message, and it can be lost too. So the sender can't tell "message lost" from "ack lost". Could more messages fix that? A famous thought experiment says no:

The Two Generals problem
Why no protocol can make both sides sure a message arrived.
⛺ General A🏞 enemy valley⛺ General B
Plan: attack at dawn.
Messengers can be captured here.
Knows nothing yet.
Two armies sit on two hills with the enemy in the valley between. If both attack together they win; if only one attacks, it's wiped out. The only way to talk is to send a messenger through the valley, and any messenger might be captured.
1 / 7

This is the Two Generals problem. It doesn't depend on how clever the protocol is: whoever sends the last message in any exchange can't know it arrived. The only way to be sure is to never lose messages, and real networks lose them.

So a sender has to pick: resend when unsure (at-least-once: risk duplicates) or don't (at-most-once: risk loss). For anything that matters, you pick at-least-once and deal with duplicates on the receiving side.

"Exactly-once" = at-least-once + dedup

When a product says "exactly-once", read it as "exactly-once effect" (some say "effectively once"). The recipe is always the same:

  1. The sender gives every message a unique ID and resends until it gets an ack.
  2. The receiver remembers which IDs it has already handled, and skips repeats. That's Deduplication.
  3. Step 2 has to be atomic with the work itself: "do the work" and "remember the ID" must succeed or fail together. If they can come apart, a crash in between either loses the work or repeats it (§06 and §08 show how).

"Doesn't TCP already handle this?"

TCP does resend lost packets and drop duplicate ones, but only inside one connection. When a connection dies (Wi-Fi to 4G, a server restart) and your app opens a new one and sends the request again, TCP sees brand-new data. It has no idea it's the same payment.

This is a general principle, the end-to-end argument: lower layers can make duplicates rarer, but only the endpoint that performs the effect knows what "the same request" means. So that endpoint is where deduplication has to live.

Practitioner§05

Idempotency keys, end to end

In one sentenceThe client gives each intent a unique name. The server remembers name → result. A repeat of a finished name gets the saved result; nothing runs twice.

§03's tricks work when you can reshape the operation. For "create a payment", you can't: every legitimate payment is new. So the client adds a name to the request. It's usually sent as an HTTP header:

the request, as it goes over the wire
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{"amount_cents": 5000, "currency": "eur"}

That header is the Idempotency key. The value is a random UUID, so no two intents ever share one by accident. Payment APIs such as Stripe's made this design popular, and many other APIs copy it.

What the server does with it

The server keeps a table: one row per key, holding a Request fingerprint of the request, a status, and the saved response. Each incoming request falls into one of five cases:

The key is…The server…Reply
missing (on an endpoint that requires one)refuses400 Bad Request
newsaves the row as in progress, does the work, saves the responsethe real result, e.g. 201
finished, same requestdoes nothing, sends the saved responsethe same 201, byte for byte
still in progress (the first attempt is running)refuses, or waits for the first to finish409 Conflict: try again shortly
finished or in progress, but with a different request bodyrefuses422 Unprocessable Content

The third row is a replayed response: the client can't tell it from the original, which is what you want. The fourth row handles §02's just slow case, where the retry arrives while the original is still running. The fifth catches a client bug: the same key reused for a different payment.

Try all five. Each request takes about a second on this server, so you have time to double-tap:

Idempotency-key server
Each request takes about a second to process. Lose replies, retry, double-tap, and watch both tables.
app's current key: k-001amount
idempotency_keys table0 row(s)
keyrequeststatussaved reply
empty
charges (real money)
none
Try this: press Pay, but lose the reply, then Pay (or retry). Then switch to the naive server and do the same. Then try Double-tap, and changing the amount without a new checkout.

Rules for the client

  • Create the key when the intent begins, not per attempt. For a checkout, that's when the payment form opens or the order is created, and every retry of that payment reuses it. A new key per attempt is the same as no key at all.
  • Survive restarts. If the app crashes and restarts, it should retry with the same key. So save the key alongside the pending action (in the app's storage, or in your database row for outgoing calls).
  • Use random keys. A UUID v4, or any long random string. Don't build the key from the request's contents ("user 7, €5"): two real €5 coffees on the same day would collide.
  • A new intent gets a new key, even if it looks identical to the last one.

Rules for the server

Scope keys to the caller. Make the table's key (account_id, key), not just key. Two customers who happen to send the same string must not see each other's results. Without the scope, someone who guesses or steals a key could read another user's saved response.

Fingerprint the request. Store a hash of the parts that matter (method, path, body). A Hash function such as SHA-256 turns any input into a short, fixed-size value that changes completely if one byte changes. If a later request has the same key but a different fingerprint, answer 422. Never silently replay the old answer: the client asked for something else.

Decide which outcomes to save. Save final results: success, and business answers like "card declined", which would be the same on a retry. Don't save outcomes that didn't really happen: a request that failed validation before claiming the key, a 409 for an in-flight duplicate, or a crash. Those should be retryable. (Stripe, for example, documents that it saves the first result once the endpoint has started executing, even a 500, but saves nothing for requests that fail validation or collide with a concurrent one.)

Keep keys long enough, then delete them. A key only has to outlive every retry the client could make, so keep it for longer than your clients' total retry time, with plenty of margin. Stripe documents removing keys after they're at least 24 hours old. A key that comes back after it has been deleted is treated as new, so don't make the window too short.

Practitioner§06

Building it: Postgres, Go and JavaScript

The ruleClaim the key, do the work, and save the response in one database transaction. Either all three happen, or none do.

This section builds the server from §05 for the common case: the work is writes to your own database (create a charge row, move a balance). If the work calls another company's API, you need §07's extra machinery. Start here either way.

Two tables. One holds keys, one is the effect we're protecting:

schema.sql
CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  request_hash  text        NOT NULL,       -- fingerprint of the request body
  response_code int,                        -- filled in when the work is done
  response_body text,                       -- text, not jsonb (see below)
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)             -- one row per key, per account
);

CREATE TABLE charges (
  id           bigserial PRIMARY KEY,
  account_id   bigint NOT NULL,
  amount_cents int    NOT NULL
);

PRIMARY KEY (account_id, key) makes the pair unique: it acts as a UNIQUE constraint over both columns together, and it's the scoping rule from §05 written as a constraint. The database will refuse a second row for the same account and key, even if both inserts arrive in the same microsecond.

The handler's core

The flow, in plain words: start a Database transaction; try to insert the key; if the insert was skipped, it's a repeat, so read and return the saved response; otherwise do the work, save the response and commit. The Go version uses database/sql (*sql.DB, BeginTx, QueryRowContext), introduced in Transactions §04.

pay.go
type PayRequest struct {
	AmountCents int `json:"amount_cents"`
}

type Response struct {
	Code int
	Body []byte
}

// pay charges the account at most once per (accountID, key).
// Repeats get the first response back, byte for byte.
func pay(ctx context.Context, db *sql.DB, accountID int64, key string, req PayRequest) (Response, error) {
	reqJSON, err := json.Marshal(req)
	if err != nil {
		return Response{}, err
	}
	sum := sha256.Sum256(reqJSON)
	hash := hex.EncodeToString(sum[:]) // fingerprint of the request body

	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return Response{}, err
	}
	defer tx.Rollback() // does nothing if we committed

	// 1. Claim the key. A concurrent duplicate waits here until we commit.
	var claimed bool
	err = tx.QueryRowContext(ctx, `
		INSERT INTO idempotency_keys (account_id, key, request_hash)
		VALUES ($1, $2, $3)
		ON CONFLICT (account_id, key) DO NOTHING
		RETURNING true`, accountID, key, hash).Scan(&claimed)

	if errors.Is(err, sql.ErrNoRows) { // 2. Seen before: replay.  ← the point
		var storedHash string
		var r Response
		err = tx.QueryRowContext(ctx, `
			SELECT request_hash, response_code, response_body
			FROM idempotency_keys WHERE account_id = $1 AND key = $2`,
			accountID, key).Scan(&storedHash, &r.Code, &r.Body)
		if err != nil {
			return Response{}, err
		}
		if storedHash != hash {
			return Response{Code: 422, Body: []byte(`{"error":"key reused with a different request"}`)}, nil
		}
		return r, nil
	}
	if err != nil {
		return Response{}, err
	}

	// 3. First time: do the work and save the response, in the SAME transaction.
	var chargeID int64
	err = tx.QueryRowContext(ctx,
		`INSERT INTO charges (account_id, amount_cents) VALUES ($1, $2) RETURNING id`,
		accountID, req.AmountCents).Scan(&chargeID)
	if err != nil {
		return Response{}, err
	}
	body := fmt.Appendf(nil, `{"charge_id":%d,"amount_cents":%d}`, chargeID, req.AmountCents)
	_, err = tx.ExecContext(ctx, `
		UPDATE idempotency_keys SET response_code = 201, response_body = $3
		WHERE account_id = $1 AND key = $2`, accountID, key, body)
	if err != nil {
		return Response{}, err
	}
	if err := tx.Commit(); err != nil { // charge + saved response, or neither
		return Response{}, err
	}
	return Response{Code: 201, Body: body}, nil
}

It works: the real run

We ran both versions against PostgreSQL 16: one payment, a retry, a retry with a changed amount, then five requests with the same new key at the same moment (goroutines in Go, Promise.all in JS). The Go output:

first try:             201 {"charge_id":1,"amount_cents":5000}
retry, same key:       201 {"charge_id":1,"amount_cents":5000}
same key, new amount:  422 {"error":"key reused with a different request"}
concurrent #5:         201 {"charge_id":2,"amount_cents":700}
concurrent #1:         201 {"charge_id":2,"amount_cents":700}
concurrent #4:         201 {"charge_id":2,"amount_cents":700}
concurrent #3:         201 {"charge_id":2,"amount_cents":700}
concurrent #2:         201 {"charge_id":2,"amount_cents":700}
charges in the table: 2

Eight requests (seven 201s and one 422), two charges. The JS version prints the same lines (its concurrent ones come out in order #1–#5).

Why the concurrent case just works

There's no lock in our code, yet five simultaneous requests produced one charge. The unique index did it:

Two requests, same key, same moment
request A
BEGIN
INSERT key ✓
charge · save reply
COMMIT
request B
BEGIN
INSERT key… waits for A
0 rows
SELECT reply
201
B's INSERT can't know whether it conflicts until A's transaction ends, so Postgres makes it wait. When A commits, B's insert is skipped and B reads A's saved response.
  1. Request A inserts the key row. A hasn't committed yet, so the row is invisible to everyone else, but the index already knows about it.
  2. Request B tries to insert the same key. Postgres can't decide yet whether B conflicts (A might still roll back), so B's insert waits for A's transaction to finish.
  3. A commits. Now B's insert definitely conflicts, so DO NOTHING skips it and returns no rows.
  4. B runs its SELECT and sees A's saved response. B replays it.

If A had rolled back instead (it crashed, or the charge failed), B's insert would go through and B would do the work itself. Nobody is left stuck behind a dead request.

Wiring it to HTTP

The handler around pay reads the header, validates before claiming the key (so a bad request isn't saved), and takes the account from the login, not the body:

func payHandler(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" || len(key) > 255 {
			http.Error(w, "an Idempotency-Key header is required", http.StatusBadRequest)
			return
		}
		var req PayRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountCents <= 0 {
			http.Error(w, "invalid body", http.StatusBadRequest) // nothing claimed: retryable after a fix
			return
		}
		accountID := accountFrom(r) // from the login session or API token, never from the body
		resp, err := pay(r.Context(), db, accountID, key, req)
		if err != nil {
			// The transaction rolled back, so nothing was charged or saved: a retry will redo it.
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(resp.Code)
		w.Write(resp.Body)
	}
}

In Go, passing r.Context() means that if the client hangs up mid-request, the context is cancelled, the transaction rolls back, and nothing is saved, so the client's retry starts fresh. That's correct: an unsaved key is always safe to process again.

Staff-level§07

Side effects you can't roll back: recovery points

In one sentenceA database ROLLBACK can't un-charge a card or un-send an email. So split the request into phases, save how far you got after each one, and make every call that leaves your database idempotent too, by passing your key along.

§06 put everything in one transaction. That only works while every step is a write to your own database. Real payment flows look more like this: save the request, call a payment provider over the network (another company's API, outside your database), save the result, queue a receipt email, reply.

If the server crashes right after the provider charged the card but before your database recorded it, a ROLLBACK erases your record of the charge, and the card stays charged. The next retry has no idea and charges again.

Phases and recovery points

The fix, popularised by Stripe's engineers, is to treat the request as a small state machine (a fixed list of states, with only certain moves allowed between them). Each row in the keys table remembers a Recovery point: started → charged → done. A retry reads the point and continues from there. It doesn't start over.

Each phase is either one local transaction, or one external call that is itself idempotent. Never two external calls in one phase, because a crash between them leaves you unsure which one happened.

Because the key row is now committed early (it has to be, so a retry can find it), we also need a way to say "someone is working on this key right now". That's a Lease: a locked_until timestamp. If the process holding it crashes, the lease simply expires and the next retry takes over. Each lease also gets a random Lease token (lease_id), so a process can later check that the lease is still its own. You'll see why that matters below.

CREATE TABLE payment_keys (
  account_id         bigint NOT NULL,
  key                text   NOT NULL,
  request_hash       text   NOT NULL,
  recovery_point     text   NOT NULL DEFAULT 'started',  -- started → charged → done
  locked_until       timestamptz,                         -- when the lease expires
  lease_id           text,                                -- who holds the lease
  provider_charge_id text,
  response_code      int,
  response_body      text,
  PRIMARY KEY (account_id, key)
);

Pick what goes wrong (a crash, or a worker that is merely slow) and step through the original request and the client's retry:

Recovery points: crash anywhere, resume on retry
Pick what goes wrong, then step through the original request and the client's retry.
Code
1func pay(key string) Response {
2 row := claimOrRead(key)
3 if row.Hash != hash { return 422 }
4 if row.Point == "done" {
5 return row.Saved // replay
6 }
7 lease := takeLease(key) // or 409
8 if row.Point == "started" {
9 id := provider.Charge(amt,
10 IdemKey(key)) // OUR key
11 fenced(lease, "charged", id)
12 }
13 // here Point == "charged"
14 tx := db.Begin()
15 fenced(tx, lease, "done", resp)
16 tx.Insert(outbox, "receipt")
17 tx.Commit()
18 return resp
19}
payment_keys rowour database
point = started
lease: P1 · token a7f, 30 s
Payment provideroutside our DB
empty
outboxemails to send
no rows
Client
POST /pay · key k1
A new key. The row is inserted with point started, and process P1 takes a 30-second lease (a lock that expires by itself if P1 dies) with a random token, a7f, that only P1 knows.
1 / 8

The real thing in Go

Here is the full flow. The fake provider in our test program behaves like Stripe: it remembers idempotency keys and returns the existing charge for a repeat.

pay_external.go
var (
	errInFlight  = errors.New("409: a request with this key is still in progress")
	errLostLease = errors.New("our lease expired and another worker took over; stopping")
)

type execer interface { // *sql.DB and *sql.Tx both have this method
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

// fenced runs a write that must only apply while we still hold the lease.
// Every such query ends in "AND lease_id = <ours>"; 0 rows means we lost it.
func fenced(ctx context.Context, db execer, query string, args ...any) error {
	res, err := db.ExecContext(ctx, query, args...)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return errLostLease
	}
	return nil
}

func payExternal(ctx context.Context, db *sql.DB, prov *Provider, acct int64, key, hash string, cents int) (int, string, error) {
	// Phase 0: claim the key if it's new, then read it.
	_, err := db.ExecContext(ctx, `INSERT INTO payment_keys (account_id, key, request_hash)
		VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, acct, key, hash)
	if err != nil {
		return 0, "", err
	}
	var point, storedHash string
	var code sql.NullInt64
	var body sql.NullString
	err = db.QueryRowContext(ctx, `SELECT recovery_point, request_hash, response_code, response_body
		FROM payment_keys WHERE account_id = $1 AND key = $2`, acct, key).
		Scan(&point, &storedHash, &code, &body)
	if err != nil {
		return 0, "", err
	}
	if storedHash != hash { // checked BEFORE the lease, so a bad request can't block a good one
		return 422, `{"error":"key reused with a different request"}`, nil
	}
	if point == "done" { // finished: replay, no lease needed
		return int(code.Int64), body.String, nil
	}

	// Take the lease, with a fresh token that only we know.
	lease := rand.Text() // crypto/rand, Go 1.24+
	var chargeID sql.NullString
	err = db.QueryRowContext(ctx, `UPDATE payment_keys
		SET lease_id = $3, locked_until = now() + interval '30 seconds'
		WHERE account_id = $1 AND key = $2 AND recovery_point <> 'done'
		  AND (locked_until IS NULL OR locked_until < now())  -- free, or the holder's lease ran out
		RETURNING recovery_point, provider_charge_id`, acct, key, lease).Scan(&point, &chargeID)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, "", errInFlight // someone holds a live lease (or just finished: a retry will replay)
	}
	if err != nil {
		return 0, "", err
	}
	// We hold the lease now. Finish our writes even if the client hangs up (that cancels
	// r.Context()); otherwise a lease release could silently fail and block retries for 30 s.
	ctx = context.WithoutCancel(ctx)

	for {
		switch point {
		case "started": // Phase 1: the external call. Pass OUR key downstream.  ← the point
			pctx, cancel := context.WithTimeout(ctx, 10*time.Second) // well under the 30 s lease
			id, err := prov.Charge(pctx, fmt.Sprintf("%d:%s", acct, key), cents)
			cancel()
			if errors.Is(err, ErrCardDeclined) { // a final business answer: save it like a success
				resp := `{"error":"card declined"}`
				err = fenced(ctx, db, `UPDATE payment_keys SET recovery_point = 'done',
					response_code = 402, response_body = $4, locked_until = NULL, lease_id = NULL
					WHERE account_id = $1 AND key = $2 AND lease_id = $3`, acct, key, lease, resp)
				if err != nil {
					return 0, "", err
				}
				return 402, resp, nil
			}
			if err != nil { // transient (timeout, 5xx): free the lease so a retry can go at once
				_, rerr := db.ExecContext(ctx, `UPDATE payment_keys SET locked_until = NULL
					WHERE account_id = $1 AND key = $2 AND lease_id = $3`, acct, key, lease)
				return 0, "", errors.Join(err, rerr) // report both if the release failed too
			}
			err = fenced(ctx, db, `UPDATE payment_keys SET recovery_point = 'charged', provider_charge_id = $4
				WHERE account_id = $1 AND key = $2 AND lease_id = $3`, acct, key, lease, id)
			if err != nil {
				return 0, "", err // errLostLease: someone else owns this key now, so touch nothing
			}
			point, chargeID = "charged", sql.NullString{String: id, Valid: true}

		case "charged": // Phase 2: local side effects + response, in ONE transaction.
			resp := fmt.Sprintf(`{"charge":"%s","amount_cents":%d}`, chargeID.String, cents)
			tx, err := db.BeginTx(ctx, nil)
			if err != nil {
				return 0, "", err
			}
			err = fenced(ctx, tx, `UPDATE payment_keys SET recovery_point = 'done',
				response_code = 201, response_body = $4, locked_until = NULL, lease_id = NULL
				WHERE account_id = $1 AND key = $2 AND lease_id = $3`, acct, key, lease, resp)
			if err == nil {
				_, err = tx.ExecContext(ctx, `INSERT INTO outbox (topic, payload)
					VALUES ('receipt-email', $1)`, resp)
			}
			if err == nil {
				err = tx.Commit()
			}
			if err != nil {
				tx.Rollback()
				return 0, "", err
			}
			return 201, resp, nil
		}
	}
}

Three decisions in that code are easy to get wrong:

  • Check the body before taking the lease. If the 422 check came after the lease, a wrong request would grab a 30-second lease and leave without releasing it, and the client's real retry would get 409 until it ran out.
  • A decline is an answer; a timeout is not. "Card declined" is final, so it is saved as done with a 402, and every retry replays it (§05). A timeout or 5xx from the provider says nothing about what happened, so we free the lease and return an error. The next retry runs phase 1 again, and the provider's own dedup keeps it to one charge.
  • Don't let a hang-up stop you halfway. Once we hold the lease, the code switches to context.WithoutCancel(ctx). If the client disconnects, Go cancels r.Context(), and a lease release using it would fail with context canceled, so every retry would get 409 for 30 s. (We checked: without WithoutCancel, the "client hangs up" run below gets exactly that 409.) The provider call is still capped by its own 10 s timeout.
  • Every phase write is fenced. It only applies AND lease_id = <ours>. Why is the next callout.

We ran it with Fault injection: a switch that makes the process "crash" (return an error before finishing) at a chosen line, or stall in the middle of a phase. After each crash, the test expires the lease (as 30 real seconds would) and retries:

== no crash
  try 1: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  try 2: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  try 3: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  provider charges: 1, receipt emails queued: 1, final point: done
== crash after the provider charged
  try 1: 0  💥 crashed after the provider charged
  try 2: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  try 3: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  provider charges: 1, receipt emails queued: 1, final point: done
== crash before the final commit
  try 1: 0  💥 crashed before the final commit
  try 2: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  try 3: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  provider charges: 1, receipt emails queued: 1, final point: done
== P1 is slow: its lease expires mid-charge
  P2 (retry, takes over): 201 {"charge":"ch_1","amount_cents":5000} <nil>
  P1 (wakes up late): 0  our lease expired and another worker took over; stopping
  provider charges: 1, receipt emails queued: 1, final point: done
== wrong body on a key nobody holds, then the real retry
  try 1: 0  provider timeout
  wrong body: 422 {"error":"key reused with a different request"} <nil>
  real retry: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  provider charges: 1, receipt emails queued: 1, final point: done
== card declined
  try 1: 402 {"error":"card declined"} <nil>
  try 2: 402 {"error":"card declined"} <nil>
  provider charges: 0, receipt emails queued: 0, final point: done
== client hangs up during a provider timeout
  try 1: 0  provider timeout
  retry right away: 201 {"charge":"ch_1","amount_cents":5000} <nil>
  provider charges: 1, receipt emails queued: 1, final point: done
== concurrent duplicate
  try: 0  409: a request with this key is still in progress

One charge and at most one email in every scenario. Our first drafts had two bugs that tests like these catch. First, replays of a done key also took the lease and never released it, so the third try got a 409; that's why done rows now replay before any lease is taken. Second, and worse, is the slow-worker case in the next callout. Test your crash paths; they're the code that never runs on a good day.

Side effects outside your database

Pass your key downstream. If the provider accepts idempotency keys (Stripe, Adyen, PayPal and many others do), send one derived from yours, like "42:k1" above. Then a repeated phase 1 costs nothing. This is the single most important line in the function.

Emails, push notifications, webhooks: use the outbox. Don't send them inline. Insert a row into an outbox table in the same transaction as the state change, and let a separate worker send them (Behavioral patterns §10 and Transactions §10 cover the Outbox pattern pattern). The worker itself delivers at-least-once, so a crash after sending but before marking the row sent can send the email twice. Pass the outbox row's ID to the email provider if it supports dedup; otherwise accept the rare duplicate. A duplicate email is annoying; a duplicate charge is a support ticket.

When a provider has no idempotency support, you have weaker options, best first:

  1. Let the provider check for you. Many APIs let you attach your own reference (an order ID) and reject a second object with the same reference, or let you create objects with an ID you choose. That's idempotency under a different name.
  2. Look before you retry. Before redoing phase 1, search the provider for an object carrying your reference. It narrows the window but doesn't close it: the first attempt may still be in flight (§02).
  3. Reconcile. Run a periodic Reconciliation job that compares your records with the provider's (most offer a daily report) and flags mismatches, such as a charge with no matching order, for refund or review.
  4. Escalate unknowns to a person. For high-value, rare operations (a wire transfer), "outcome unknown, a human will check" is a perfectly good final state.
Practitioner§08

Idempotent consumers: dedup for queues and events

In one sentenceA message queue promises At-least-once delivery delivery, so every consumer will see duplicates. Save "I've handled message X" in the same transaction as the effect, and acknowledge the message only after that commits.

So far the duplicates came from clients retrying HTTP requests. The same thing happens, constantly, inside systems built on a Message broker: a server (Kafka, RabbitMQ, Amazon SQS…) that stores messages from producers and hands them to consumers. Queues & Streams covers how brokers work. Here we only need one fact: a broker resends a message until the consumer acknowledges it.

Where duplicates come from

  • The consumer crashed after doing the work, before its ack. The broker never heard "done", so it redelivers. This is §02's lost reply, in a different costume.
  • Kafka consumers restart from their last committed offset. Offsets are usually committed every few seconds, so a crash, a deploy or a rebalance (Kafka moving partitions, the topic's sub-queues, between consumers) replays everything processed since the last commit.
  • SQS's Visibility timeout ran out. If processing takes longer than the timeout, the message reappears and a second consumer starts on it while the first is still working. That's a concurrent duplicate, like §05's double-tap.
  • The producer retried its publish. Its send timed out, but the broker had stored the message. Now there are two copies, each with its own broker-assigned ID.

That last point decides which ID to deduplicate on: one the producer puts in the message (an event ID, or the ID of the business action), the same in every copy. The broker's own delivery ID or offset differs between the two copies of a duplicate publish.

Why the dedup mark must commit with the work
broker
deliver m1
waiting for ack…
redeliver m1
acked
consumer
BEGIN · work · mark m1 · COMMIT
💥 crash
m1 marked → skip
ack
The consumer finishes the work but crashes before telling the broker. The broker re-sends the message. Only a mark saved in the same transaction as the work tells the second attempt to skip it.

The dedup table

Give every consumer a table of message IDs it has finished. The key is (consumer, message_id), so two different consumers of the same message each process it once:

CREATE TABLE processed_messages (
  consumer     text NOT NULL,
  message_id   text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, message_id)
);
consumer.go
type Message struct {
	ID       string // set by the PRODUCER; the same on every redelivery
	WalletID int64
	Cents    int64
}

// handle applies a "top up" message at most once, however many times it's delivered.
func handle(ctx context.Context, db *sql.DB, msg Message) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	res, err := tx.ExecContext(ctx, `
		INSERT INTO processed_messages (consumer, message_id) VALUES ('wallet-topups', $1)
		ON CONFLICT DO NOTHING`, msg.ID)
	if err != nil {
		return err
	}
	if n, err := res.RowsAffected(); err != nil {
		return err
	} else if n == 0 {
		return nil // duplicate: already applied. Ack it and move on.  ← the point
	}

	_, err = tx.ExecContext(ctx,
		`UPDATE wallets SET balance_cents = balance_cents + $1 WHERE id = $2`, msg.Cents, msg.WalletID)
	// (Production code would also check RowsAffected() == 1: a missing wallet is a bug to report, not a duplicate.)
	if err != nil {
		return err
	}
	return tx.Commit() // the effect and the "seen it" mark commit together
}
// The caller acks the message only when handle returns nil.

We fed five deliveries to each version: evt-1, evt-2, evt-1 again (its ack was "lost"), evt-2 again, and evt-3. Both printed:

5 deliveries, 3 distinct messages → balance: 1000

That's 500 + 300 + 200. Without the dedup table, it would have been 1800.

Ack after commit, never before

The order of the last two steps decides your delivery promise:

OrderCrash between the two stepsResult
process + commit, then ackredelivered, dedup table skips it✓ exactly one effect
ack, then processmessage is gone, work never happenedat-most-once: lost effect
commit the work, then mark "seen" in another store (e.g. Redis)the two can disagree after a crashlost or doubled effect

The third row is the sneaky one. A dedup mark in Redis and an effect in Postgres can't commit together: whichever is written first, a crash in between leaves them out of step. Keep the mark in the same database, and the same transaction, as the effect.

When you don't need a dedup table

The §03 tricks work for consumers too, and they need no extra table:

  • Upsert by a natural key. "Set user 7's email to x" or "insert order o-81 if it isn't there" are already idempotent.
  • Versions. If each event carries a version number that goes up with every change to the same thing, apply it only if it's newer than what you have. That also handles events arriving out of order, which plain dedup doesn't:
-- product 1 is at version 4. Events arrive: v6, then v5 (late), then v6 again (duplicate).
UPDATE products SET price_cents = 1500, version = 6 WHERE id = 1 AND version < 6;  -- UPDATE 1
UPDATE products SET price_cents = 1200, version = 5 WHERE id = 1 AND version < 5;  -- UPDATE 0
UPDATE products SET price_cents = 1500, version = 6 WHERE id = 1 AND version < 6;  -- UPDATE 0
-- final: price 1500, version 6 ✓

This works when each event carries the full new state ("price is now 1500"). For events that describe a change ("add 500 to the balance"), skipping an older version would lose money: use the dedup table.

Outbox in, inbox out

Put both halves together and you get effectively-once processing across services. The producer writes its event to an Outbox pattern table in the same transaction as its own change, and a relay publishes it, at-least-once, with an event ID. The consumer records that ID in its dedup table, which in this role is called the inbox, in the same transaction as its effect. Duplicates are expected at every hop and harmless at the end. Sagas build multi-step business flows out of exactly this.

Practitioner§09

Retrying well: what, when, and how much

In one sentenceRetry only failures that might go away, reuse the same key every time, wait longer (and randomly) between attempts, stop when the overall deadline is up, and never let retries multiply load across layers.

Idempotency makes retries safe. This section makes them smart. The basic backoff loop is already in Async / Await §11; here we add what changes when the request has side effects and a key.

What to retry

A Transient error is one that may go away by itself. Those are worth retrying. A permanent error will come back exactly the same, so retrying it just wastes time and load.

What happenedRetry?Why
Connection refused, DNS lookup failureYesThe request never reached the server. Always safe, even without a key.
Timeout, connection reset mid-requestYes, only with a key (or an idempotent operation)The outcome is unknown (§02).
408 Request TimeoutYes, only with a keyThe server gave up waiting for your request; it may or may not have started.
429 Too Many RequestsYes, after Retry-AfterYou're over a Rate limit; the server says when to come back (Rate Limiting).
502, 503, 504YesA proxy or overloaded server; usually temporary. 503 may carry Retry-After too.
500 Internal Server ErrorA few times, with a keyCould be a bug (permanent) or a blip. Cap it low.
409 "this key is still in progress"Yes, after a pauseYour earlier attempt is still running (§05). If the API also uses 409 for business conflicts, tell them apart by the error code in the body.
400, 401, 403, 404, 422, a 409 business conflictNoThe request itself is the problem. Same request, same answer.

gRPC §09 has the same table for gRPC status codes (UNAVAILABLE yes, INVALID_ARGUMENT no).

A retry loop for requests with side effects

retry.js
import { setTimeout as sleep } from "node:timers/promises";

// 409 is here because OUR server (§06, §07) only uses it for "this key is still in progress".
// If yours also uses 409 for business conflicts, check the error code in the body instead.
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);

// Retry-After is either seconds ("2") or an HTTP date. Returns ms, or null if absent.
function retryAfterMs(res) {
  const v = res?.headers.get("retry-after");
  if (!v) return null;
  const secs = Number(v);
  return Number.isFinite(secs) ? secs * 1000 : Math.max(0, Date.parse(v) - Date.now());
}

// Pass in the key that was created when the intent began; every attempt reuses it.
async function postWithRetry(url, body, { key = crypto.randomUUID(), deadlineMs = 10_000, maxAttempts = 5 } = {}) {
  const deadline = AbortSignal.timeout(deadlineMs); // ONE time budget for every attempt
  const deadlineAt = Date.now() + deadlineMs;

  for (let attempt = 1; ; attempt++) {
    let res = null, error = null;
    try {
      res = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": key }, // same key every attempt ← the point
        body: JSON.stringify(body),
        signal: AbortSignal.any([deadline, AbortSignal.timeout(2000)]), // per-try cap too
      });
    } catch (err) {
      if (deadline.aborted) throw err; // out of total time: stop
      error = err;                     // network error or per-try timeout: outcome UNKNOWN
    }
    if (res && !RETRYABLE.has(res.status)) return res; // success, or a final "no" (400, 422…)

    const backoff = Math.min(5000, 200 * 2 ** attempt);
    const wait = retryAfterMs(res) ?? Math.random() * backoff; // server's hint wins; else full jitter
    if (attempt === maxAttempts || wait > deadlineAt - Date.now()) { // no tries left, or no time to wait
      if (res) return res;
      throw error;
    }
    await res?.body?.cancel(); // throw away the failed reply's body
    await sleep(wait, undefined, { signal: deadline }); // rejects at once if the deadline passes
  }
}

We pointed both at a test server that fails on purpose: first 503, then 429 with Retry-After: 1, then it charges and drops the connection without replying, then (on the next attempt) replays the saved response. The JS log, with one console.log added before each wait:

server: /payments attempt 1 key=9dc86581…
client: attempt 1 → 503; waiting 301 ms
server: /payments attempt 2 key=9dc86581…
client: attempt 2 → 429; waiting 1000 ms
server: /payments attempt 3 key=9dc86581…
server: charged, now dropping the connection
client: attempt 3 → UND_ERR_SOCKET; waiting 653 ms
server: /payments attempt 4 key=9dc86581…
client: final 201 true {"charge_id":1}

Four attempts, one charge, and the last line shows the server's Idempotent-Replayed: true header. In Node, the dropped connection surfaces as TypeError: fetch failed with err.cause.code === "UND_ERR_SOCKET". Two more runs, in both languages: an endpoint that answers 409 (in progress) once and then 201 was retried after a short wait and succeeded, and a 429 with Retry-After: 60 against our 10-second deadline gave up at once instead of sleeping for a minute and then failing.

The Go run (also with a log line before each wait) had a surprise: the server again logged four attempts, but the Go client only logged two waits. Go's HTTP client had resent attempt 3 by itself on a fresh connection. Its transport treats a request carrying an Idempotency-Key header as safe to replay when a reused keep-alive connection dies before any reply, so our loop never saw that failure. When we renamed the header to X-Key, the failure came back to our code as EOF. One more reason the server must dedup: retries happen in layers you didn't write.

How long to wait

The server's hint wins. If a 429 or 503 carries Retry-After, wait that long. The server knows when it will have room; your guess doesn't.

Otherwise, exponential Exponential backoff with full Jitter: pick a random wait between 0 and a cap that doubles each attempt. The randomness spreads clients out so they don't all come back in the same instant, which Async / Await §11 shows with a chart.

One deadline for everything. The user waits at most, say, 10 s in total. That's one budget shared by every attempt and every wait, not 10 s per attempt. Both versions above stop the moment it's spent, even in the middle of a wait, and don't start a wait that would outlast it. In Go, the Deadline rides along in the ctx (and across services, too: gRPC §08).

Retry storms: when retries cause the outage

One retry is cheap insurance. Retries at every layer multiply. If the app makes 3 attempts, the gateway makes 3 per request it receives, and the service makes 3 per database call, then a dead database receives 3 × 3 × 3 = 27 queries for one tap, exactly when it can least afford them. That's a Retry storm. Play with the numbers:

Retry storm calculator
Every layer retries the layer below. How much traffic reaches the database for each tap?
📱 App (taps)
1.00×
API gateway
3.00×
Order service
9.00×
🗄 Database
27×
bars = requests arriving at each level, per tapdashed line = what the database can handle (1.5× normal)
At 1,000 taps per second the database receives 27,000 requests per second, and users see 0% success. The database is down. Retries can't help; they only multiply the load it must survive when it comes back.
Simplified model: every failure is at the database; upper layers fail only when all their attempts below fail; the budget lets each layer add at most 10% extra calls.

Notice the two sides. At a 5% failure rate, three attempts hide almost every failure from users for a few percent of extra load: that's what retries are for. At 100%, retries can't help anyone, and they bury the database under several times its normal load the moment it tries to come back.

The defences (Load Balancing §09 and Worker Pools §07 cover them from the server's side):

  1. Retry at one layer, usually the one closest to the user or to the failure. Everyone else fails fast and passes the error up.
  2. Cap retries with a Retry budget. Retries may add at most ~10% to normal traffic. When the budget is spent, stop retrying and return the error. A tiny version:
// Every first attempt earns `ratio` tokens; every retry spends 1.
class RetryBudget {
  #tokens;
  constructor(ratio = 0.1, most = 10) {
    this.ratio = ratio;
    this.most = most;
    this.#tokens = most; // a small starting credit, also the cap
  }
  onRequest() { this.#tokens = Math.min(this.most, this.#tokens + this.ratio); } // per first attempt
  tryRetry() {                                                                    // before each retry
    if (this.#tokens < 1) return false;
    this.#tokens -= 1;
    return true;
  }
}

In a simulated full outage (1,000 requests, every attempt failing, each wanting 2 retries), both versions allowed 109 retries instead of 2,000: about 10% extra load, as designed. #tokens in the JS version is a private field: only code inside the class can see it. gRPC has the same idea built in as retryThrottling (gRPC §09).

  1. Back off and respect Retry-After, as above, so the retries that do happen are spread out.
  2. Stop calling a service that keeps failing. A circuit breaker counts recent failures, and after too many it skips the call entirely (failing instantly) for a cool-down period, then lets a few test requests through. It's the retry budget's bigger sibling.
Staff-level§10

Staff-level: the contract, the cost, the regions, the proof

In one sentenceIn production, idempotency is a promise to your callers: document it, budget for its storage, make sure two regions can't both keep it, prove it with tests that break things on purpose, and watch it with metrics.

1 · Make it a contract

Callers can only use keys well if you tell them exactly how they work. Document, per endpoint:

  • Which endpoints accept a key, and whether it's required. For anything that moves money or sends messages, require it: 400 without one.
  • The header name and format. Idempotency-Key, a string up to 255 characters, UUID recommended. An IETF draft ("The Idempotency-Key HTTP Header Field", from the HTTP APIs working group) standardises this header; it was still a draft when this was written, but it's close to what payment APIs already do.
  • How long keys are kept (e.g. 24 hours) and what happens after: the same key is then treated as new.
  • The replies. 409 for "still in progress, retry later", 422 for "key reused with a different request" (both match the draft), and a header marking replays. Stripe uses Idempotent-Replayed: true.
  • What is saved. Whether a 500 or a validation error is saved and replayed, or retryable with the same key (§05).

Put retries and keys in your SDK. Most callers won't implement §09 correctly. If you ship a client library, have it generate the key once per call and retry with backoff by default. Stripe's official libraries, for example, add a key automatically when they retry. The correct path should be the lazy one.

Webhooks you send are the mirror image. You'll redeliver them (at-least-once), so put a stable event ID in every one and tell receivers to deduplicate on it (§08).

2 · Budget for the key table

A worked example, with round numbers. Say an API handles 2,000 keyed writes per second, keeps keys 24 hours, and each row (key, hash, status, a small saved response, index entry) takes about 1 KB:

  • 2,000/s × 86,400 s = about 173 million rows per day.
  • × 1 KB ≈ 170 GB of keys at any moment, plus churn: 173 million inserts and 173 million deletes every day.

That's a real table. Ways to keep it manageable:

  • Drop whole days instead of deleting rows. Partition the table by creation day (Postgres stores each day in its own sub-table) and drop partitions older than the window. Deleting millions of rows one by one leaves dead space the database must clean up later; dropping a partition is instant.
  • Store less. Keep the status code and the created resource's ID, not a large response body, if your API can rebuild the response from those. (You lose "byte for byte"; decide if callers need it.)
  • Keep the window honest. It must cover the longest retry any client could make, including retries after an app restart. Shrinking it to save disk quietly turns late retries back into duplicates.

3 · Multiple regions

Suppose you run in two regions (say Europe and the US), each with its own database, replicating to each other asynchronously. A client's first attempt lands in Europe and times out; the retry is routed to the US (DNS failover, a load balancer, a moving phone). The US table hasn't received Europe's key row yet, because of replication lag. Both regions process the payment.

Options, roughly in order of how often they're used:

  1. Give every account a Home region. Route all of an account's writes, and therefore all its keys, to that one region. A retry that lands elsewhere is forwarded, not processed.
  2. Use a strongly consistent, multi-region store for keys (Spanner, CockroachDB and similar). Every claim then pays a cross-region round trip, often 50–150 ms. CAP explains why that trade-off can't be avoided.
  3. Accept rare duplicates and reconcile. For low-value effects, detect doubles afterwards and refund them automatically.

4 · Prove it: break things on purpose

Idempotency code only matters on bad days, so ordinary tests never exercise it. Fault injection manufactures the bad day. The most useful fault is the one from §01: do the work, then lose the reply. Wrap your handler in staging (never production) like this:

// dropReplies runs the real handler, then throws its reply away for a share of
// requests. The client sees a dropped connection: exactly a lost response.
func dropReplies(next http.Handler, share float64) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if rand.Float64() >= share {
			next.ServeHTTP(w, r)
			return
		}
		next.ServeHTTP(httptest.NewRecorder(), r) // side effects happen; the reply goes nowhere
		panic(http.ErrAbortHandler)               // abort: the server closes the connection
	})
}

With share set to 1, a plain POST to the wrapped Go server failed on the client with EOF, and the JS one with fetch failed (cause.code: "UND_ERR_SOCKET"), while the handler's counter showed the work was done. That's the failure your clients must survive. Then add:

  • A duplicating client in integration tests: send every mutating request twice, sometimes at the same moment, and assert that the effect happened once.
  • Crash points like §07's: kill the process between phases and check that a retry finishes the job.
  • Invariant checks that run after the test, or nightly against production: "at most one charge per key", "every charge has an order". These are cheap SQL queries and catch what the other tests missed.

5 · Watch it

MetricWhat it tells you
Replay rate (replayed ÷ keyed requests)How often clients retry. A sudden jump means something upstream is timing out.
409 in-progress countConcurrent duplicates: double-taps, or clients retrying before their own timeout.
422 mismatch countClient bugs (one key reused for different requests). Should be near zero; alert on spikes.
Keys stuck in started past their leaseRequests that crashed and were never retried, possibly with a charge made. Feed them to a recovery job or a person.
Duplicates found by reconciliationThe bottom line. Should be zero; any non-zero value is an incident.
Client retry rate and budget exhaustionWhether retries are about to become a storm (§09).

Also log the idempotency key on every line a request writes, the same way you'd log a Request ID. When a customer says "I was charged twice", the key is how you find both attempts in seconds.

Practitioner§11

Pitfalls gallery

01

A new key for every attempt

The key is generated inside the retry loop, or in the click handler, so every attempt carries a different one. The server sees unrelated requests and dedups nothing.

Fix: create the key when the intent begins (the checkout opens, the order row is created) and pass it into the retry loop. Save it with the pending action so it survives an app restart (§05).

02

Check, then insert

✕ Don't
-- two duplicates can BOTH see "no row"…
SELECT 1 FROM idempotency_keys
WHERE account_id = $1 AND key = $2;
-- (none) → do the work…
INSERT INTO idempotency_keys …;
✓ Do
-- claim first; the UNIQUE index decides
INSERT INTO idempotency_keys …
ON CONFLICT DO NOTHING
RETURNING true;
-- a row back = you own it

Looking first and acting second is a check-then-act race (Race Conditions §04): concurrent duplicates both see "not found" and both do the work. Fix: let the insert into a unique key be the check (§06).

03

Saving the key in a different transaction from the work

Save the key after the work, in its own step, and a crash in between leaves the work done but unrecorded: the retry does it again. Save it before, in its own committed step, with no recovery point, and a crash leaves a key that says "seen" for work that never happened: every retry is skipped.

Fix: one transaction for key + work + response (§06). If the work leaves the database, use recovery points (§07).

04

The dedup mark lives in another store

"Seen" goes in Redis, the effect goes in Postgres. They can't commit together, so a crash between them loses or doubles the effect (§08, quiz Q6). Fix: keep the mark in the same database and transaction as the effect.

05

Saving a temporary failure as the final answer

The server times out talking to the database and saves "503" as the key's response. Every retry now replays the 503 forever: the key is permanently broken, and the payment can never go through.

Fix: save only final outcomes. On transient errors, roll back (or leave the recovery point where it was) so the next attempt actually runs (§05).

06

Keys that aren't scoped to the caller

The table's key is just key. Two customers' SDKs pick the same string (it happens with bad generators), and one gets the other's payment response, including their data.

Fix: PRIMARY KEY (account_id, key), with account_id from authentication, never from the request body.

07

Replaying for a different request

The client reuses a key for a new €80 payment, and the server cheerfully replays the old €50 success. The client thinks €80 went through. Fix: store a fingerprint and answer 422 on mismatch (§05).

08

Generic retries on non-idempotent calls

An HTTP client wrapper, a proxy or a service mesh is set to "retry on failure" for all requests, including POSTs without keys. Every timeout becomes a possible duplicate, and nobody remembers the setting exists.

Fix: configure automatic retries only for idempotent methods or requests that carry a key, and check what your load balancer and mesh do by default (Load Balancing §09).

09

Retrying at every layer

App, gateway and service each retry 3 times: 27 attempts per tap against a struggling database (§09). Fix: retry at one layer, with a retry budget, backoff and jitter. Everyone else fails fast.

10

Ack before commit

The consumer acks the message as soon as it arrives, then processes it. A crash mid-processing loses the message for good. Fix: commit the effect and the dedup mark, then ack (§08).

11

A key window shorter than the retry window

Keys expire after 1 hour, but the mobile app queues payments while offline and retries when it gets signal, sometimes the next day. The late retry finds no key and charges again.

Fix: make the window longer than the longest possible retry, or have clients give up (and ask the user) before the window ends.

Practitioner§12

Check yourself

Predict the answer before you click. Every one is covered above: Q1 in §03, Q2 in §05, Q3 in §06, Q4 in §09, Q5 and Q6 in §08.

QUESTION 1 / 6
Which one is idempotent?

Each runs twice in a row, by accident. Which leaves the data exactly as one run would?

QUESTION 2 / 6
The new-key retry

The client's first POST /pay times out. The server had actually charged the card. The client retries with a freshly generated idempotency key. What happens?

QUESTION 3 / 6
Two at once

Using the single-transaction Postgres version from §06, two requests with the same key arrive 5 ms apart. What does the second one do?

QUESTION 4 / 6
Retry or not?

Which of these replies should a client retry automatically (with backoff)?

QUESTION 5 / 6
Crash before the ack

A consumer applies a message and inserts its ID into processed_messages in one transaction, commits, then crashes before acknowledging the message to the broker. What happens next?

QUESTION 6 / 6
Two stores, one crash

A consumer first runs SET seen:m1 1 NX in Redis, then updates the balance in Postgres. It crashes between the two. The message is redelivered. Result?

Answered 0 / 6
Foundations§13

Cheat sheet

❓
Three outcomesSuccess, failure, unknown. Timeouts give you "unknown", and only a retry resolves it. So every write must survive being retried.
🔁
f(f(x)) = f(x)Idempotent = repeating it changes nothing more. It's about the effect, not the reply (DELETE → 204, then 404).
🧱
Safe by design firstSay the result, not the change. Let the caller pick the ID. Use conditional writes (WHERE status = 'paid', WHERE version < 6).
📨
Exactly-once is a lieDelivery is at-most-once or at-least-once. "Exactly-once" = at-least-once + dedup, atomic with the work.
🎟
One key per intentCreate it when the intent begins; reuse it on every retry; random (UUID); scoped to the account on the server.
🗄
One transactionClaim the key (INSERT … ON CONFLICT DO NOTHING), do the work, save the response, commit. The unique index handles concurrent duplicates.
🧭
Recovery pointsWork that leaves your DB: phases + a saved point + a lease with a token that every write checks. Pass your key downstream. Emails go through the outbox.
📥
Consumers dedupProducer-assigned message ID in a dedup table, same transaction as the effect. Ack after commit.
⏱
Retry smartTransient errors only. Retry-After wins, else full jitter. One deadline for all attempts. One layer retries, within a budget.
SituationDo this
Server sees a new keySave row "in progress" → do work → save response → commit
Same key, finished, same requestReplay the saved response (same status, same bytes)
Same key, still running409 Conflict, or wait for the first to finish (§06)
Same key, different request422, never replay
Missing key on a money endpoint400
Retry after 429 / 503Wait for Retry-After; otherwise backoff with full jitter
400, 401, 403, 404, 422Don't retry; fix the request
Consumer gets a messageINSERT its ID into the dedup table → effect → commit → ack
Event carries full new stateUPDATE … WHERE version < $new: handles duplicates and reordering
Provider without idempotency keysYour reference on their object, look before retrying, reconcile
Proving it worksDrop replies on purpose, duplicate requests, crash between phases, check invariants
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Distributed SystemsMessage Queues & Streams
Read →