Idempotency Explained: Safe Retries and Idempotency Keys
Idempotency explained: safe vs idempotent HTTP methods, why retries need it, how idempotency keys work, idempotent consumers, upserts and re-runnable jobs.
By Matthews WongReviewed on
Key takeaway
Idempotency means doing an operation twice has the same effect as doing it once. It matters because networks fail mid-request: after a timeout the client cannot tell whether the work happened, so it retries. GET, PUT and DELETE are idempotent by definition; POST is not, so APIs add an idempotency key that lets the server replay its first answer instead of repeating the work.
You will be able to
- Tell safe methods from idempotent ones, and say which requests a client may retry on its own after a timeout.
- Design an idempotency key flow: who creates the key, what the server stores, for how long, and what a duplicate in flight gets.
- Make consumers and scripts safe to re-run with upserts, a processed-messages table and writes that set a value instead of adding to it.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
A lost response, a retry, and an idempotency key
A payment request succeeds but its response is lost; retried without a key it charges twice, while with an idempotency key the server stores its first answer and replays it, answers 409 to a duplicate still in flight, and rejects the key when it comes back with a different body.
Keys: ← → step · Home / End · K play or pause
A checkout app sends POST /charges to a payments API, which writes each charge to a table. The reply travels back over the network. The key store is unused for now.
The API charges the card and answers 201, but the network drops the reply. The client only sees a timeout, so it cannot tell whether the charge happened.
Predict the next step The client sends the same POST again, with nothing added. What does the API do? (Returns the first answer · Charges the card again · Rejects it as a duplicate)Without a key, the API cannot tell a retry from a new order, so it charges again. POST is not idempotent: two identical sends, two effects.
Fix: the client creates one idempotency key before the first attempt. The API claims the key, charges, then saves the result under it. This reply is lost too.
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"The retry carries the same key. The API finds k1 and returns the stored 201 without charging again. Same request, same effect: the endpoint is now idempotent.
Idempotent-Replayed: truePredict the next step A new order sends key k2, then a duplicate with k2 lands while the first is still running. What should the duplicate get? (A second charge · The stored answer for k1 · A conflict: try again later)The key is claimed as in progress before the work starts. A duplicate that lands mid-flight gets 409 Conflict and retries later, instead of racing the first request.
HTTP/1.1 409 ConflictReusing k1 with a different body is a client bug: the IETF draft answers 422, Resend 409. Keys also expire: Stripe keeps them at least 24 hours.
HTTP/1.1 422 Unprocessable Content
What is idempotency?
An operation is idempotent when doing it twice has the same effect as doing it once. Setting a light switch to off is idempotent; flipping it is not. RFC 9110, the HTTP specification, defines it for requests: a method is idempotent if the intended effect on the server of several identical requests is the same as the effect of a single one.
Read more (1)Show less
Two words in that definition carry the weight. Effect, not response: RFC 9110 notes that repeating an idempotent request has the same intended effect even if the first one succeeded, though the response might differ. A second DELETE of the same item finds nothing to delete, yet the item is gone either way. Intended: the server is still free to log every request separately or keep a revision history. Idempotency is about what the client asked for.
Safe vs idempotent: which HTTP methods are which?
A safe method is read-only by definition: the client neither requests nor expects a state change on the server. RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe. Every safe method is also idempotent, and so are PUT and DELETE, which change state but land on the same state when repeated. POST is neither. RFC 5789 says PATCH is neither safe nor idempotent either, while noting that a PATCH can be written so that it is.
Read more (1)Show less
The difference between PUT and POST is intent. A PUT carries the complete new state of a resource the client names, so sending it twice leaves that same state. A POST hands its data to the resource to process by its own rules, such as creating a new order, so each send can create another. RFC 9110 draws the line itself: a service that picks the new resource's URI on the client's behalf should use POST rather than PUT.
Why do retries make idempotency a correctness problem?
A timeout is ambiguous. The request may never have arrived, the server may have stopped halfway, or the work may have finished and only the reply was lost. Stripe's error guide says it plainly: restoring connectivity does not tell you whether the original request succeeded. That leaves the client two bad choices, give up and maybe lose the order, or retry and maybe do it twice.
Read more (1)Show less
RFC 9110 sets the rules that follow. A client can repeat an idempotent request automatically after a connection failure. It should not automatically retry a non-idempotent one unless it knows the request is idempotent anyway, or that the original was never applied, and a proxy must never retry a non-idempotent request. The reliability lesson (lesson 5) covered when and how often to retry; this one makes the retry safe, so that at-least-once attempts produce the effect exactly once.
How does an idempotency key work, and what does the server store?
The client generates a unique key for one operation, before the first attempt, and sends it on every retry in an Idempotency-Key header. A random UUID is the usual choice: the IETF draft recommends a UUID or a similar random identifier, Stripe suggests V4 UUIDs, and crypto.randomUUID() makes one in browsers and in Node. Make it per operation, never per attempt, because a new key looks like a new request. Stripe also suggests deriving the key from something the user already holds, such as a shopping cart's ID, which guards against a double submission.
Read more (3)Show less
The server keeps a small record per key: a fingerprint of the request body, whether the work is in progress or done, and the status code and body it answered. On the first request it claims the key, does the work and saves the result. On a retry it returns the saved result without doing the work again, and Stripe marks such a reply with Idempotent-Replayed: true. Stripe saves a result only once the endpoint has started executing, so a request rejected by validation can simply be retried, while a saved 500 is replayed as a 500.
Keys do not live forever, and the server should publish how long it keeps them. Stripe keeps them for at least 24 hours and may remove them after that, so a key reused later can run as a new request; Resend keeps them for 24 hours. Two edge cases need an answer. A duplicate that arrives while the first is still running gets 409 Conflict in the IETF draft, and Stripe's guide says to wait and retry it with the same key. A key reused with a different body gets 422 in the draft; Resend answers both cases with 409 and different error names.
The header name comes from an IETF HTTPAPI working group draft. On 2026-10-18 the IETF datatracker lists it as an expired Internet-Draft, last revised as version 07 on 2025-10-15, so it is not a standard. Stripe and Resend use the same header name anyway, which makes it a safe default for your own API.
What are at-least-once delivery and idempotent consumers?
Message queues meet the same problem from the other side. RabbitMQ's reliability guide says acknowledgements guarantee at-least-once delivery, and without them only at-most-once is guaranteed. A consumer should acknowledge a message only after it has done the work. If its channel or connection closes first, every unacknowledged delivery is requeued and comes back with the redelivered flag set. A publisher whose confirm was lost may send a message twice as well.
Read more (1)Show less
So duplicates are normal, and RabbitMQ recommends designing consumers to be idempotent rather than explicitly deduplicating. The redelivered flag is only a hint: when it is false the message is new, when it is true the consumer may have seen it. The common recipe is the Idempotent Consumer pattern on microservices.io: inside the same database transaction as the business change, record the ID of every processed message. A redelivery finds its ID already recorded, and the consumer skips the work and acknowledges.
What is natural idempotency, and where do you already use it?
The cheapest idempotency needs no key at all: write operations that set a state instead of changing it by an amount. Set the stock to 11 can run twice; subtract one cannot. PUT replaces a resource, so it repeats safely. In SQL, an upsert does the same for inserts: INSERT with ON CONFLICT DO NOTHING, in PostgreSQL and in SQLite since 3.24.0, skips a row whose unique key already exists, and ON CONFLICT DO UPDATE turns the insert into an update. PostgreSQL guarantees an atomic insert-or-update outcome for DO UPDATE, even under high concurrency.
Read more (1)Show less
Declarative tools are idempotent by design, because they compare the state you declare with the state that exists. docker compose up recreates a service's container only when its configuration or image changed after the container was created, so a second up -d with nothing changed leaves the running container alone. Shell has the same split: chmod 600 sets the mode absolutely, mkdir -p does not fail when the directory exists, and > replaces a file's contents while >> appends, so re-running a script that uses >> doubles every line. This repo's scripts/setup-resend-templates.mjs says in its header that re-running it updates the templates: it finds them by a fixed alias instead of creating new ones.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
if (error) {
console.error("Resend send error:", error);
return apiError({
status: 502,
code: "upstream_error",
message: "Failed to send message.",
hint: "The email provider rejected the message. Retry later, or email matthewswong2610@gmail.com directly.",
});
}This site's contact API is an honest non-idempotent POST. The send call just above (lines 138-146) passes Resend one argument, the message, and no idempotency key, so every POST is a new email. The installed SDK (resend 6.17.1) also turns a network failure into this same error value instead of throwing, so this branch cannot tell a rejected message from a lost connection after which the email may already have gone out, yet the hint says retry.
setShowSuccessModal(true);
resetWizard();
} catch (error) {
console.error(error);
setShowErrorModal(true);
} finally {
setIsSubmitting(false);
}This is where the retry comes from. Any failure, a lost connection included, lands in catch and opens a dialog that says the message didn't send and asks the visitor to try again. Only success calls resetWizard, so the typed message is still there and one more press of Send posts it as a new request. isSubmitting disables the button while a request is in flight, which stops a double click but not a retry.
cat > .env <<'ENVEOF'
GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
IMAGE=${{ needs.build-and-push.outputs.image }}
APP_PORT=${{ vars.APP_PORT || 3002 }}
ENVEOF
chmod 600 .env
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker compose pull
# Normal path updates the container in place. On the first cutover the
# pre-existing (non-compose) container holds the name — remove it once,
# then compose adopts the name going forward.
docker compose up -d --remove-orphans || {
docker rm -f matthewswong-portfolio 2>/dev/null || true
docker compose up -d --remove-orphans
}Read this deploy step as a script that must be safe to run twice, which is what makes running the job again after a half-finished deploy harmless. cat > .env rewrites the whole file each time, where >> would add a second copy of every line. chmod 600 sets the mode absolutely. docker compose up -d recreates the container only when its configuration or image changed. Even the fallback is re-runnable: rm -f with || true succeeds whether or not the old container exists.
* Idempotent: a slug already in BLOG_POSTS_STATIC is skipped.
*
* node scripts/merge-blog-staging.mjs [--dry-run]
*
* Design note — why this is plan-then-write rather than validate-then-write:
* the previous version validated the staged CONTENT completely, then began
* writing, and resolved each insertion anchor at write time. When a constant
* moved module (REAL_IMAGE_SLUGS left blog-posts-static.ts), the third write
* threw after messages/*.json and the post registry had already been updated —
* a half-merged repo that a re-run would not fix, because by then the slug
* existed in en.json and got skipped. Anchors are now resolved during
* validation, so either every edit is possible or nothing is touched.Idempotent by skipping: a post that is already registered is not merged again. The design note records how that went wrong. One write failed after earlier writes had landed, so the done marker existed, the re-run skipped the post, and the repo stayed half-merged. The fix is the rule behind every re-runnable job: settle everything that can fail before the first write, so a job is either fully done or untouched.
Mistakes that cost real time
A new key on every attempt
Generating the key inside the retry loop makes every retry a new request, and the protection is gone. Create the key once per operation, store it with the pending operation, and reuse it across retries, process restarts and page reloads. Stripe's guide warns that a new key can start a second operation.
Check the key, then save it, in two steps
Two duplicates that arrive together both read no record for the key, both do the work, and the customer is charged twice. Claim the key first, in one atomic step that a unique constraint decides, mark it in progress, and only the request that won the claim does the work. The other gets 409.
A done marker written before the work is done
Skip-if-exists is only idempotent if the marker means the whole job finished. If a later step fails, a re-run skips a half-done job forever, as this repo's merge script once did. Write the marker in the same transaction as the work, or settle everything that can fail before the first write.
Treating a 500 as permission to try again fresh
A 500 is indeterminate: the operation may have had side effects. Stripe's guide says not to retry with a new key until you have resolved the original outcome, and to stop retrying an unresolved operation after the 24-hour window and reconcile it instead.
What I'd change here: one key per contact message
The form could create crypto.randomUUID() once per filled-in message, send it with the POST, and clear it only after success. The route would pass it to emails.send as idempotencyKey, which Resend keeps for 24 hours, so a second press after a lost reply returns the first result instead of a second email. The error copy should also say the message may have been sent.
| Method | Safe | Idempotent | After a timeout |
|---|---|---|---|
| GET, HEAD, OPTIONS | Yes | Yes | Retry automatically |
| PUT | No | Yes | Retry: it sets the same state again |
| DELETE | No | Yes | Retry: the resource is gone either way |
| POST | No | No | Retry only with an idempotency key |
| PATCH | No | No, unless the patch is written to be | Retry only if idempotent or keyed |
Do it on your own machine
You need Node 22 or later, curl, and the sqlite3 command line (preinstalled on macOS; on Linux, install it from your package manager). A toy payments server on 127.0.0.1:4242 takes 1.5 seconds per charge, and no real payment or email API is involved.
Save the server as server.mjs and start it with node server.mjs in its own terminal. It prints the number of charges after each one.
// server.mjs: a toy payments API. Each charge takes 1.5 s. With an // Idempotency-Key header, the first result is stored and replayed. import { createHash } from "node:crypto"; import http from "node:http"; const charges = []; const keys = new Map(); // key -> { fingerprint, done, status, body } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function reply(res, status, body, headers = {}) { res.writeHead(status, { "Content-Type": "application/json", ...headers }); res.end(JSON.stringify(body) + "\n"); } http .createServer(async (req, res) => { if (req.method === "GET") return reply(res, 200, { charges: charges.length }); let raw = ""; for await (const chunk of req) raw += chunk; const key = req.headers["idempotency-key"]; const fingerprint = createHash("sha256").update(raw).digest("hex"); if (key) { const seen = keys.get(key); if (seen && seen.fingerprint !== fingerprint) return reply(res, 422, { error: "key reused with a different body" }); if (seen && !seen.done) return reply(res, 409, { error: "a request with this key is in progress" }); if (seen) return reply(res, seen.status, seen.body, { "Idempotent-Replayed": "true" }); keys.set(key, { fingerprint, done: false }); // claim the key BEFORE the work } await sleep(1500); // the slow part, like a call to a card network const charge = { id: `ch_${charges.length + 1}`, body: raw }; charges.push(charge); if (key) keys.set(key, { fingerprint, done: true, status: 201, body: charge }); console.log("charges so far:", charges.length); reply(res, 201, charge); }) .listen(4242, "127.0.0.1", () => console.log("listening on 127.0.0.1:4242"));Lose a reply on purpose. curl -m 1 gives up after one second, before the 1.5-second charge answers, so it prints a timeout (error 28) although the server charged. Retry the same POST and read the count: 2 charges for one order.
curl -sS -m 1 -X POST http://127.0.0.1:4242/charges -d 'order=A-1' curl -sS -X POST http://127.0.0.1:4242/charges -d 'order=A-1' curl -sS http://127.0.0.1:4242/chargesDo it again with an idempotency key made once, before the first attempt. The first call times out again; the retry gets the stored 201 with Idempotent-Replayed: true, and the count goes up by one, not two.
KEY=$(node -p "crypto.randomUUID()") curl -sS -m 1 -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=B-1' sleep 1 curl -sS -i -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=B-1' curl -sS http://127.0.0.1:4242/chargesSend a duplicate while the first request is still running. The duplicate prints 409 at once; the original prints 201 when it finishes.
KEY=$(node -p "crypto.randomUUID()") curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-1' & sleep 0.2 curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-1' waitIn the same terminal, reuse that key with a different body. The server refuses with 422, as the IETF draft suggests, instead of replaying an answer to a different request.
curl -sS -w '%{http_code}\n' -X POST http://127.0.0.1:4242/charges -H "Idempotency-Key: $KEY" -d 'order=C-2'Now natural idempotency in SQLite. The upsert inserts once (changes 1) and does nothing the second time (changes 0). Subtracting twice leaves 10; setting 11 twice leaves 11.
sqlite3 :memory: <<'SQL' CREATE TABLE processed (message_id TEXT PRIMARY KEY); CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER); INSERT INTO stock VALUES ('mug', 12); -- a redelivered message, handled twice INSERT INTO processed VALUES ('msg-42') ON CONFLICT (message_id) DO NOTHING; SELECT changes(); INSERT INTO processed VALUES ('msg-42') ON CONFLICT (message_id) DO NOTHING; SELECT changes(); -- add-by-amount twice, then set-a-value twice UPDATE stock SET qty = qty - 1 WHERE sku = 'mug'; UPDATE stock SET qty = qty - 1 WHERE sku = 'mug'; SELECT qty FROM stock; UPDATE stock SET qty = 11 WHERE sku = 'mug'; UPDATE stock SET qty = 11 WHERE sku = 'mug'; SELECT qty FROM stock; SQLStop the server with Ctrl+C in its terminal. It keeps everything in memory, so nothing is left behind.
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.
Two questions from earlier lessons
Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.
Answer in your head, then check
1.Why is DELETE idempotent even though a second call may answer differently?Show the answer
Answer:Idempotency is about the intended effect on the server, not the response. After one DELETE or two, the resource is gone; RFC 9110 says the response to a repeat might differ.
2.What does a server store for each idempotency key, and when?Show the answer
Answer:A fingerprint of the body, the state of the work, and the status and body it answered. It claims the key as in progress before doing the work, so a duplicate in flight gets 409, and saves the result when the work ends.
3.A deploy script appends to .env with >>. What happens when the deploy runs again, and what is the fix?Show the answer
Answer:Every line appears twice, and the file grows on each run. Rewrite the whole file with >, as this site's deploy does with cat > .env, so each run leaves the same file.
4.When is natural idempotency enough, and when do you need a key?Show the answer
Answer:When the request names the resource and carries its full state, as with PUT, a set or an upsert on a natural unique key, repeats converge. When each request creates something new, such as a charge or an email, only a key the client chose can tell a retry from a new request.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- RFC 9110: HTTP Semantics (safe and idempotent methods, PUT vs POST)
- RFC 5789: PATCH Method for HTTP
- IETF datatracker: The Idempotency-Key HTTP Header Field (expired draft, revision 07)
- Stripe API reference: Idempotent requests
- Stripe docs: Advanced error handling (network errors, 409, retry window)
- Resend docs: Idempotency keys
- RabbitMQ docs: Reliability guide (at-least-once, idempotent consumers)
- RabbitMQ docs: Consumer acknowledgements and publisher confirms
- microservices.io: Idempotent Consumer pattern
- PostgreSQL docs: INSERT, ON CONFLICT
- SQLite docs: UPSERT
- Docker docs: docker compose up
- POSIX: chmod (absolute modes)
- POSIX: mkdir -p
- POSIX Shell Command Language: redirecting and appending output
- MDN: Crypto.randomUUID()