The question this answers
Two requests arrive carrying the same key. Under what conditions is that enough to conclude they are the same operation?
At most one effect per (namespace, key, request fingerprint) — within the dedup record’s retention window, and within the consistency and failure scope of the store that holds it. Outside any of those three qualifiers there is no guarantee at all. Removing a qualifier from that sentence is how teams end up believing they have exactly-once behaviour that they do not have.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
The receiver knows the bytes of this request and whatever it has retained about previous ones. It does not know the caller’s intent, whether the caller reused a key deliberately or by accident, whether a different replica of itself has seen this key, or whether it once saw this key and has since forgotten. "I have not seen this key" and "this key does not exist" are different statements, and the receiver can only make the first.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
Identity is a claim, not an observation
The receiver cannot observe that two requests are the same operation. It can only observe that they carry the same token, and then *decide* to treat that as identity. Everything difficult here follows from the gap between those two things — because the caller and the receiver are different machines with different beliefs about what the token means.
The caller believes: "this key names the operation I am attempting; my retries carry it; nobody else uses it." The receiver believes: "this key names an operation I may already have applied; if I have, I return what I returned before." Those two beliefs only line up if there is an agreement, made at design time, about the key’s namespace, its lifetime, and what happens when the same key arrives with different content. Nothing in the protocol enforces that agreement, which is why it fails silently.
This is the reason this lesson is not the same as the mechanism. Storing a key and replaying a response is a small amount of code. Deciding what the key *means* across two organisations, three regions and a retention policy is a distributed-systems design problem, and it is where the real bugs live.
| Dimension | The question | Failure when unscoped |
|---|---|---|
| Namespaceprotocol | Same key, different tenant — same operation? | Tenant B receives tenant A’s stored response |
| Body bindingprotocol | Same key, different body — same operation? | A real second order silently returns the first order’s receipt |
| Endpointassumption | Same key on a different route — same operation? | A refund returns a charge’s stored response |
| Retentionassumption | Same key, four days later — same operation? | A DLQ replay charges the customer again |
| Failure scopeassumption | Same key, other region — same operation? | Every in-flight retry duplicates during failover |
| Outcome scopeassumption | Same key after a failed attempt — same operation? | A transient failure is cached and the caller can never succeed |
Namespace: the key belongs to someone
A key is a string a client chose. Clients choose badly. They use 1, test, order-1, an auto-incrementing integer, or a UUID that a badly-seeded random generator repeats. Two different customers of your API will collide, and when they do, an unscoped dedup store returns one tenant’s stored response to another tenant’s request.
That is not a correctness bug in the ordinary sense — it is a data disclosure. The stored response may contain an order id, an amount, an address. The remedy is trivial and must be non-optional: the dedup record is keyed by (tenant_id, api_key_id, endpoint, client_key), never by client_key alone, with the tenant taken from the authenticated principal and never from the request body (Security owns the general form of this rule under tenant isolation).
The endpoint component deserves its own thought. If a client reuses one key across POST /charges and POST /refunds, should the second return the first’s response? Almost certainly not — but "almost" is doing work, because some designs deliberately scope a key to a whole logical operation spanning several endpoints. Decide explicitly and document it, because the client cannot infer it.
1dedup_key = hash(2 tenant_id, # from the authenticated principal, NEVER the body3 api_key_id, # two credentials of one tenant are different callers4 endpoint, # a key on /charges must not answer /refunds5 client_key, # the caller's own identifier6)7 8record = {9 dedup_key,10 request_fingerprint: sha256(canonical(body)), # body binding11 status: IN_PROGRESS | COMPLETED | FAILED_PERMANENT,12 response, # replayed on a match13 created_at,14 expires_at, # retention — and the boundary of the guarantee15}Body binding: same key, different body
A client sends key K with a €50 charge. It later sends key K with a €500 charge — a bug in their retry loop, a reused variable, a form resubmitted after an edit. Three policies exist and only one is safe.
Ignore the body and replay the first response. The client believes it charged €500. You charged €50 and told them it worked. This is the worst option because it is silent and the divergence is between your record and their belief, which nothing will reconcile.
Treat it as a new operation. Then the key means nothing, and a genuine retry with a body that differs by a timestamp field will double-charge. Also silent.
Reject it. Store a fingerprint of the canonicalised request body with the record; if a request arrives with a matching key and a non-matching fingerprint, return a definite error (Stripe returns a 400 with idempotency_key_in_use semantics; the exact code matters less than that it is a 4xx and not a replay). The client learns immediately that it has a bug, and no wrong effect occurs. This is the only option where the failure is loud.
Canonicalisation is the subtle part: JSON key order, absent versus null fields, numeric formatting and a client-side timestamp in the body will all change the hash without changing the operation. Fingerprint a canonical form and exclude fields that legitimately vary between attempts, or you will reject correct retries — which teaches clients to generate a fresh key per attempt, which destroys the mechanism entirely.
Retention: the guarantee has an expiry date
The dedup store cannot grow forever. Records must expire, and the moment a record expires, a request carrying that key becomes a new operation. So the guarantee is not "at most once" — it is "at most once within the retention window", and the window must be chosen against the maximum possible retry horizon, which is almost always longer than anyone estimates.
The client’s own retry loop might last thirty seconds. But a message sitting in a dead-letter queue may be replayed by an operator on Monday morning after failing on Friday night. A webhook sender may retry with exponential backoff for three days. An operator may click "re-drive" in an admin console a week later. A batch job may be re-run from a checkpoint. Each of those presents the original key long after any client-side retry would have given up.
So the rule is: retention ≥ the longest path by which the same key can legitimately reach you again, plus margin. Twenty-four hours is a common default and is too short for any system with a manual DLQ workflow. And because retention is finite, expiry must be observable: a metric for eviction rate and for the age of the oldest retained record, because an eviction is a future duplicate you have already accepted.
The storage cost is real and is the honest counter-pressure. A high-throughput API storing a fingerprint and a response body per operation for seven days is storing a lot. The usual compromise is to keep the *claim* (key plus fingerprint plus status) for a long window and the *response body* for a short one — a replay after the body expires can then return a definite "this operation was already applied" rather than the original payload, which is far better than treating it as new.
path max age of a legitimate replay -------------------------------- ------------------------------ client library retry loop 30s gateway retry 5s async worker + backoff 15m webhook sender (3rd party) 3d 0h dead-letter queue, manual drain (unbounded — policy: 7d) operator "re-drive" in admin UI (unbounded — policy: 7d) -------------------------------- ------------------------------ required retention 7d + margin => 10d # Current setting: 24h. Every DLQ drain older than a day # is a duplicate the system will not catch.
The genuinely distributed part: where the record lives
The dedup store is itself a distributed system, and its topology decides whether the guarantee survives the events that produce the most retries.
Co-location with the effect. If the claim and the effect commit in one transaction in one store, the claim is exact. If the claim lives in Redis and the effect in Postgres, there is a window: crash after claiming and before committing, and the operation is now permanently blocked (the claim says done, the effect never happened); crash after committing and before claiming, and the retry duplicates. Which of the two you get is decided by the order, and neither is good. Separate stores buy latency and cost correctness.
Partitioning. The dedup store is partitioned by something. If the retry routes to a different partition than the original — because the partition function includes a field that changed, or because the routing layer rebalanced — the check finds nothing. Dedup state must be partitioned by the dedup key itself, so every attempt with that key lands on the same shard (Hash Partitioning and the Modulo Trap).
Failure domain. This is the sharp one. A regional failover is exactly the event that produces a burst of retries — clients time out, retry, and get routed to region B. If the dedup store is regional and not replicated synchronously, region B has never seen any of those keys. Every in-flight operation duplicates, precisely at the moment the system is least able to cope. The dedup record must be in the same failure domain as the effect: if the effect fails over, the claim must fail over with it, atomically. A globally replicated dedup store with asynchronous replication is worse than useless here, because the replication lag is measured in exactly the window the retries occupy (EU and US Are Partitioned. Can Both Keep Accepting Writes?, Three Ways to Accept a Write in More Than One Place).
Outcome scope: which failures are worth remembering
A claim has a status, not just an existence. Three cases behave differently and conflating them causes real incidents.
In progress. The first attempt is still running when the retry arrives. Returning "already applied" is wrong — nothing has been applied yet. The correct answer is a definite "in progress, retry later" (a 409, or a 425), because the caller must not conclude success and must not start a third attempt that races the first two.
Completed. Replay the stored result. This is the case everyone implements.
Failed. Now it matters *why*. A deterministic failure — validation error, insufficient funds, unknown account — will fail identically on every retry, and caching it saves work and gives the client a consistent answer. A transient failure — a downstream timeout, a lock conflict, a 503 — must not be cached, because caching it burns the key: every retry replays the failure and the client can never succeed with that operation identity, even though the operation is perfectly valid. The client’s only escape is to generate a new key, which is precisely the behaviour that reintroduces duplicates.
So the rule is: cache deterministic failures, release the claim on transient ones. Getting this wrong produces a support case that reads "your API returns the same error forever for this order", and the cause is three layers away from where anyone looks.
- IN_PROGRESS → 409/425, do not execute, do not report success.
- COMPLETED → replay the stored response.
- FAILED (deterministic) → replay the failure; the retry cannot succeed anyway.
- FAILED (transient) → release the claim so a retry can genuinely re-attempt.
- EXPIRED → treat as new, and count it, because it is a duplicate you chose to accept.
Key points
- Identity across a machine boundary is a design agreement, not an observation — the receiver only sees a matching token.
- Scope the record by tenant, credential and endpoint, taken from the authenticated principal; an unscoped key is a cross-tenant disclosure.
- Bind a canonical fingerprint of the body to the key and reject mismatches loudly; replaying or re-executing are both silent failures.
- Retention must exceed the longest legitimate replay path — DLQ drains and operator re-drives, not just the client’s retry loop.
- The claim must live in the same store, the same partition and the same failure domain as the effect.
- A regional failover produces a burst of retries and is exactly when a regional dedup store fails to help.
- Claim status matters: in-progress is not completed, and caching a transient failure burns the key permanently.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • The caller generates a key before its first attempt and reuses it for every retry of that operation.
- • The receiver derives a scoped dedup key from the authenticated tenant, the credential, the endpoint and the client key.
- • It computes a canonical fingerprint of the request body, excluding fields that legitimately vary between attempts.
- • It atomically claims the scoped key with status IN_PROGRESS in the same store as the effect.
- • On a claim conflict it compares fingerprints: mismatch means reject; match means act on the recorded status.
- • On completion it records the outcome and response against the claim, in the same transaction as the effect.
- • Records expire on a schedule chosen from the longest replay path, and evictions are counted as accepted future duplicates.
- • Two tenants choose the same key and an unscoped store cross-wires their responses.
- • A retry carries a body that differs in an irrelevant field, so the fingerprint mismatches and a valid retry is rejected.
- • A retry carries a materially different body and the receiver replays the old response.
- • A record expires before a DLQ replay arrives, so the replay executes as a new operation.
- • A regional failover routes retries to a region whose dedup replica has not caught up.
- • The claim store and the effect store are different and a crash lands between them.
- • A transient failure is cached, permanently burning a valid key.
- • The claim is left IN_PROGRESS forever because the process died, blocking all future retries of that operation.
- • Cross-tenant response leak: a customer reports seeing another company’s order id in an API response. The dedup store was keyed by the client-supplied string alone, and two tenants both used
order-1. This surfaces as a security incident, not a reliability one. - • Duplicate charges concentrated in a failover window: the operator sees a spike of duplicates whose timestamps cluster exactly around a regional failover, and none at any other time. The dedup store was regional with asynchronous replication.
- • Monday-morning duplicates: a DLQ drained by an operator produces effects that were already applied on Friday. The duplicate rate has a weekly shape, which nobody connects to a 24-hour retention setting.
- • Permanently stuck operation: a client retries the same key forever and always receives the same 503. The claim cached a transient failure; the client cannot succeed and cannot know why.
- • Silently swallowed second order: a customer places two genuinely different orders and receives one confirmation. The client reused a key and the receiver replayed rather than rejecting. Neither side logs an error.
- • Rejected valid retries: the fingerprint includes a client-side timestamp, so every retry mismatches and is rejected. Clients respond by generating a fresh key per attempt, and the duplicate rate quietly rises.
- • The claim is a coordination point on every write, and its scope determines how much coordination you are buying: same-transaction is free, same-region is cheap, cross-region is expensive and forces a choice between latency and correctness during failover.
- • A globally consistent dedup store means every write pays a cross-region round trip — the direct application of Coordination Couples Availability to what looks like a local concern.
- • Namespace and fingerprint rules are coordination at design time between API provider and consumer, with no runtime enforcement, which is why they must be documented as part of the contract.
- • Retention is a coordination decision with operations: the dedup window must be at least as long as the operational processes that can replay a request.
- • Within the retention window and the store’s consistency scope, the at-most-once property holds through crashes and retries.
- • Outside the window, or across a failure-domain boundary the record did not cross, the property simply does not hold, and nothing signals that it has lapsed.
- • A claim left IN_PROGRESS by a crashed process blocks retries until it is reaped, converting a duplicate risk into an availability problem for that operation.
- • The effect itself remains correct and durable throughout; what degrades is the receiver’s ability to recognise a repetition.
- • Detect: count evictions, expiries and fingerprint mismatches. Each is a distinct signal and each maps to a distinct bug class.
- • Contain: reap stale IN_PROGRESS claims on a timeout longer than the maximum handler duration, so a crashed attempt does not block the operation forever.
- • Recover: for duplicates that did occur, dedupe downstream by a natural business key, which does not depend on the claim having survived (Deduplication: Bounded Memory Against an Unbounded Stream).
- • Reconcile: compare operations against effects per tenant and per window; the duplicate population is bounded and identifiable by the shared client key.
- • Verify: after any failover, explicitly measure the duplicate rate in the failover window rather than assuming the mechanism held.
- • Dedup hits split by outcome: replayed-completed, rejected-fingerprint-mismatch, blocked-in-progress, replayed-failure. One counter for "dedup hits" hides all the interesting cases.
- • Eviction and expiry rate, plus the age of the oldest retained record — evictions are accepted future duplicates and should be a deliberate number.
- • Store size and growth rate against the retention policy, so the cost of the window is visible when someone proposes shortening it.
- • Duplicate effect rate measured independently at the sink by natural key, which is the only check that does not trust the dedup mechanism.
- • Cross-region claim replication lag, if the store is replicated — the direct measure of the failover exposure window.
- • Count of stale IN_PROGRESS claims reaped, which distinguishes crashed handlers from ordinary contention.
- • Public APIs where the caller is a third party whose retry behaviour you cannot see or control.
- • Payment and ledger operations, where the cost of a duplicate justifies a durable record per operation.
- • Multi-tenant platforms, where the namespace question is not optional and getting it wrong is a disclosure.
- • Systems with operator-driven replay tooling, where the retry horizon is days and only an explicit retention decision covers it.
- • For operations that are already naturally idempotent, where a dedup store adds cost, latency and a dependency for no gain.
- • At very high throughput on low-value operations, where the storage and write amplification exceed the cost of the duplicates being prevented.
- • When the store is placed outside the effect’s transaction for convenience, converting an exact guarantee into a probabilistic one while keeping the name.
- • When teams state the guarantee without its qualifiers and downstream systems are built assuming exactly-once behaviour that only holds for 24 hours, in one region, for identical bodies.
- • Dedupe by a natural business key with an upsert, which needs no separate store and no retention policy at all (Deduplication: Bounded Memory Against an Unbounded Stream).
- • Make the operation naturally idempotent so identity does not need defining (Idempotent Is a Property of the Whole Effect, Not the Write).
- • Use a monotonic per-client sequence number instead of an opaque key, so the receiver stores one watermark per client rather than one record per operation — far cheaper, at the cost of requiring ordered submission.
- • Push identity down to the effect: a unique constraint on
(tenant, client_reference)in the target table, which is exact, free and automatically scoped to the effect’s failure domain. - • Accept duplicates and reconcile, where the business impact is small and the machinery is not worth its operational surface.
At most one effect per (namespace, key, fingerprint), within a window, within one store
What people believe, and what is true
The idempotency key guarantees the operation happens at most once.
It guarantees at most once per scoped key, per fingerprint, within a retention window, within one consistency scope. Drop any qualifier and the sentence becomes false.
Same key means same operation.
Only if you also agreed on the namespace and the body. A key from another tenant, or the same key with a different amount, is not the same operation and must not be treated as one.
We can just store keys forever to be safe.
Storage grows with total operations, not with concurrency, and the cost becomes the dominant one. The real decision is retention against the longest legitimate replay path, plus a metric for what you evict.
Our dedup store is replicated globally, so we are covered during failover.
If replication is asynchronous, the records lost are exactly the recent ones — and the recent ones are the ones being retried. Async replication does not cover the failover case at all.
If the key exists, return the stored response.
Not if the first attempt is still running (return in-progress), and not if the stored outcome was a transient failure (release the claim). Conflating the three states creates both duplicates and permanently stuck operations.
Rejecting a body mismatch is unfriendly; better to just process it.
Processing it either double-charges or silently drops a real operation. A loud 4xx tells the client about a bug in their code, immediately, at zero cost to your data.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
Deduplication needs a definition of "same". Scope the key by tenant and endpoint, bind it to the request body, keep it long enough to cover every way a replay can reach you, and store it where the effect lives.
Practical
Key the record on (tenant, credential, endpoint, client key) with the tenant from the authenticated principal. Store a canonical body fingerprint and reject mismatches with a 4xx. Track claim status — in-progress, completed, deterministic failure, transient failure — and treat each differently. Set retention from the longest replay path in your operations, and alert on evictions.
Advanced
The scope that matters most is the failure domain. A dedup record that does not fail over atomically with the effect provides no guarantee during a failover — which is the event that produces the retries. Either co-locate the claim with the effect in one transactional store that fails over as a unit, or accept and document a per-region guarantee and dedupe again at a global sink by natural key. Synchronously replicating the dedup store across regions is the third option and it prices every write at a cross-region round trip, which is usually the wrong trade for the same reason multi-region synchronous writes usually are.
Apply it
- 🔧 Audit your dedup key derivation. Confirm the tenant comes from the authenticated principal and not from anything the caller can set.
- 🔧 Compute the longest legitimate replay path in your system — including manual DLQ drains — and compare it with your current retention setting.
- 🔧 Simulate a failover with an asynchronously replicated dedup store and measure the duplicate burst as a function of replication lag.
- ⚡ Duplicate charges appear only in a two-minute window every few months, always at the same time as an infrastructure event. Explain.
- ⚡ A partner integration reports that one of their orders "disappeared" — they got a success response containing a different order’s id. Walk through the cause.
- ⚡ A client complains that a specific operation has returned the same error for three days and they cannot get past it. What is happening?
- 💬 A request arrives with a key you have seen and a body you have not. What do you do, and why are the other two options wrong?
- 💬 How long should an idempotency record be retained, and how would you derive the number?
- 💬 Your dedup store is regional. Describe what happens during a regional failover.
- 💬 Two tenants both use the key "order-1". What breaks?
- 💬 The first attempt is still executing when the retry arrives. What should the receiver return?
- 💬 Why is caching a 503 against an idempotency key harmful?