Skip to article content
AAverho

Reliable distributed workflows

Idempotency

Learners understand idempotency as the foundation for safe retry and replay semantics in distributed systems.

20 min read12 sectionsPractice & review available

The problem

A timeout does not prove that an operation failed. If the client retries without preserving operation identity, the same side effect may happen twice.

What you’ll learn

After completing this topic you will be able to:

  • Define idempotency in the context of distributed systems and contrast it with safety and determinism.
  • Identify which HTTP methods are idempotent by specification and explain the boundaries of that guarantee.
  • Explain why idempotency is critical for safe retry behaviour when networks fail and timeouts occur.
  • Distinguish between application-level idempotency and protocol-level idempotency.
  • Apply idempotency keys to non-idempotent operations (POST, payment authorizations, async work).
  • Reason about scope: which operation is idempotent, over what time window, against what storage.
  • Handle the failure window: the race between deduplication storage and the side effect (email, charge).
  • Explain why exactly-once semantics are unsafe and when at-least-once-with-deduplication is the pragmatic alternative.
  • Trade off complexity: when to use idempotency keys, when to retry, when to use transactions, when to accept partial idempotency.
  • Design idempotent workflows for payment APIs, email dispatch, job systems, and message brokers.
Contents

Overview

Idempotency is not "add an idempotency key to every POST." It is the decision to give a specific operation a stable identity and coordinated duplicate handling, because that operation may be attempted more than once and a duplicate effect would cost something real. It assumes the retry behaviour covered in reliable-workflows.timeouts-retries-backoff-jitter — this guide is about what happens when one of those retries lands on a server that already processed the first attempt, not about deciding whether or how to retry.

When not to reach for a key by default

  • Read-only operations. Nothing to coordinate; re-execution has no effect.
  • Naturally idempotent state-setting. PUT /users/123 { "email": "…" } already converges to the same state no matter how many times it lands — see Operation identity for why "same request" still needs care.
  • Low-risk, low-cost duplicates. A duplicate "mark notification as read" is not worth a deduplication store.
  • Operations already protected by a business invariant — e.g., a unique constraint on (user_id, plan) that makes a second "subscribe" a no-op at the database layer. This is real protection, but it is narrower than it looks: it stops a second *row*, not a second *side effect* (a second welcome email can still fire from the same insert-conflict path) and it says nothing about a request still mid-flight — see Lifecycle and concurrent claims.

01

Operation identity

"Is this idempotent?" is ambiguous until you say idempotent *with respect to what*. These are not the same thing:

Definition

Same request

The same bytes over the wire — same HTTP method, path, headers, body. A client retry after a timeout is the same request by this definition, but a second browser tab submitting the same form is not, even though a human might call both "the same order."

Definition

Same business operation

The thing the caller actually intends: "charge this cart once," "provision this account once." An idempotency key identifies *this*, not a transport attempt. Two different HTTP requests (different retry, different connection) can be the same business operation; that's exactly the case a key exists to catch.

Definition

Idempotency contract

The rule the server commits to for a given key: what counts as a match, what happens on a match, and what happens on a conflicting reuse. A key without a stated conflict policy is not a contract, it's a cache.

A workable contract states, in advance:

  • The key identifies one intended business operation, not a transport attempt — a retry of the same call must reuse the same key; a genuinely new order must not.
  • The server binds a safely-comparable representation of the request to the key at first use. Comparing full payloads (canonicalizing JSON, ignoring field order, deciding which fields are significant) is a real design problem, not a one-line hash — don't reach for full payload hashing unless you're prepared to keep the canonicalization rules deliberate and versioned.
  • If the same key arrives with a materially different request, that is a conflict, not a replay — return a defined error, don't silently execute the new payload or replay the old response.
  • Key scope must include every identity boundary that could otherwise collide: tenant, account, endpoint/operation type, and anything else that makes "abc-123" from customer A different from "abc-123" from customer B.
  • For workflows built around a resource the caller already names — "the payout for invoice_9f2" — a stable business identifier is often a better key than a client-generated random one: it can't be forgotten, regenerated, or reused incorrectly across retries.

02

Lifecycle and concurrent claims

A unique index prevents two idempotency *records* from existing for the same key. It does not, by itself, tell you whether the *operation* those records refer to is still running, finished, or safe to retry — that requires an explicit state.

Flow diagram: a request arrives, its operation identity is validated, the key is atomically claimed, the effect is processed, and completion is recorded — with branches for an already-processing key, an already-completed key, a conflicting payload, an abandoned claim, and an external call with an unknown outcome
One operation's recovery strategy, not a universal state machine or a database-specific recipe — see the pseudocode sketch below for one way to write it down.

A request arrives. Its operation identity — key plus request — is validated. The service atomically claims the key. From that claim, four outcomes are possible: a genuinely new key proceeds; a key already completed replays its stored logical outcome; a key still processing returns a defined in-progress response instead of a guess; a key reused with a conflicting payload is rejected; and a claim abandoned by a crashed or lease-expired owner needs explicit recovery. A newly claimed key proceeds to process its effect, which crosses an external boundary — if that call's outcome is ambiguous, the system reconciles rather than assumes success or failure. Completion is then recorded. Later duplicate arrivals return to the claim step. After the correctness-retention window passes, a claim expires and a later arrival is treated as new only if that is safe.

The pseudocode sketch below is one way to write the same states down, not a second, different model:

absent
  -> processing   (the claim: one attempt now owns this key)
  -> completed    (safe to replay: return the recorded outcome)

processing
  -> completed
  -> retryable / failed / recovery-required   (the claimant died or errored)

The right shape of this state model — whether failed is retryable, whether there's a distinct unknown state, how a stuck processing row gets recovered — depends on the operation and the chosen recovery strategy. There is no universal version of this table; a payment claim and a notification claim recover differently.

Four separate mechanisms get collapsed into "use a unique index" far too often. They solve different problems and are often combined:

MechanismWhat it actually guarantees
Atomic insert / claimExactly one caller wins the right to move `absent` to `processing`
Uniqueness constraintNo two rows for the same key can exist — enforces the claim, doesn't manage the state after it
Compare-and-setAn update only applies if the row is still in the expected state (e.g., `processing` to `completed` only if still `processing`)
Lease / row lockA `processing` claim expires or is releasable if the claimant crashes, so it doesn't block forever
Transactional mutationThe state transition and the local business write commit together, or neither does

A duplicate that arrives while the first attempt is still processing is not the same problem as a duplicate that arrives after completed — see Replay policy by state. And a claim that never resolves because its owner crashed needs an explicit recovery path (a lease timeout, a reconciliation job) or it silently blocks every future retry forever.

03

The external-effect failure window

A local claim and a local transaction only cover what's inside your own transactional boundary. They cannot tell you, with certainty, what a remote system did.

Diagram of the local transaction boundary, an external provider outside it, three returning outcomes (success, known failure, unknown), and a reconciliation path that repairs local state when the outcome is unknown
A local claim proves only what happened inside this boundary — an unknown external outcome needs reconciliation, not a guess.

Inside the local transaction boundary, the durable claim and business state commit together. A call crosses to an external provider, outside that boundary. Three outcomes come back: success, a known failure, or an unknown outcome, for example after a network timeout. Success and known failure update local state directly. An unknown outcome instead goes to reconciliation, which checks the provider's own record and repairs local state to match, rather than assuming what happened.

What actually closes this gap:

  • Propagate the key downstream. If the provider supports idempotent requests, pass it the same stable key (or a derived, still-stable one) so a retried call at the provider is also deduplicated there.
  • Transactional outbox is the pattern for coordinating a local state change with a message or event that must be published — see reliable-workflows.transactional-outbox. It does not, by itself, resolve *this* problem (a remote synchronous call whose result is unknown); it solves the narrower one of "local write and publish must not diverge."
  • Reconciliation is the pattern for the case above: periodically checking the provider's own record of what happened and repairing local state to match — because "unknown" is not a state you can leave a payout in indefinitely.

Idempotency is one piece of a workflow's reliability design, not a replacement for outbox, sagas, observability, or reconciliation — see reliable-workflows.exactly-once-claims-reality and reliable-workflows.sagas-compensating-transactions for the surrounding pieces.

04

Replay policy by state

"Return the stored response" is the right answer for exactly one of these states. Replay policy is part of the contract, decided per state:

Existing state for this keyAppropriate treatment
Completed, same requestReturn the recorded logical outcome, where the recorded response is still valid to hand back (see privacy note below)
ProcessingA defined in-progress result — a retry hint, a polling location, or, only if truly appropriate, a bounded wait. Never a guess at the outcome.
Same key, conflicting requestReject with a defined conflict/error response — never silently execute the new payload, never replay the old response as if it matched
FailedExplicitly decide: safe to retry, terminal, or needs operator/reconciliation action. "Failed" is not automatically "retryable."
Expired / not foundTreat as a genuinely new request only if the business correctness window (see below) actually permits it

A stored response is not automatically safe to replay years later, or to replay for a caller you haven't re-authorized. If the response can go stale (a price, a status) or contains anything sensitive, replaying it must still pass the same authorization check as the original request — a matching key is not a substitute for checking who's asking. In many designs, storing a compact outcome or reference (a status, a transaction ID) rather than the full original response sidesteps both problems at once.

Don't standardize on a single HTTP status code for these cases across every API — which code fits which row above is a choice each API's contract makes explicitly, not a universal convention.

05

Scope, expiry, and storage

Three different retention questions get merged into "TTL" far too often:

  • Correctness retention — how long a late duplicate must still be caught. Driven by the realistic tail of retry and delayed-delivery behaviour for *this* operation, not a round number. A background job fed by a queue with hours of possible redelivery delay needs a much longer window than a synchronous HTTP retry loop.
  • Operational/debug retention — how long the claim record is useful for investigating an incident, independent of correctness.
  • Audit/compliance retention — where regulation or finance requires keeping a record regardless of the other two.

These can have different lifetimes for the same key, which is a reason to think about what you're storing (a full response vs. a compact outcome) and where, rather than one blanket expiry.

Any numbers below are illustrative, not a standard to copy:

StorageSpeedDurabilityIllustrative fit
In-memory cacheFastLost on restartVery short correctness window where losing entries on restart is acceptable
Database (with the business write)SlowerDurable, needs deliberate TTLMost cases where the claim must survive a crash and coexist with the transaction
External cache (e.g. Redis)FastDurable if configured, needs its own monitoringHigh-throughput paths that still need a durable claim

Whatever the retention window, remember that request or response bodies stored for replay may contain sensitive data — that's a data-minimisation decision (store a reference, not the payload) independent of how long the entry lives.

06

Bounding "exactly-once" claims

"Exactly-once" is precise only when it names a boundary and a failure model. Four different claims get flattened into that one phrase:

  • At-most-one committed effect inside a named local transactional boundary — real, and provable, for writes that share one transactional datastore.
  • Duplicate suppression within a service — what an idempotency key actually buys you: the service won't knowingly re-execute for a key it has already completed.
  • Provider-level idempotency — a guarantee the *downstream* system makes about its own handling of a repeated key, which your own service can rely on only to the extent that provider documents and tests it.
  • End-to-end exactly-once across independently failing systems — the broad claim, and the one that is almost never actually justified, because it requires every hop in the chain to have made one of the narrower guarantees above and for those guarantees to compose without a gap.

Broad end-to-end exactly-once claims are usually unjustified — not because exactly-once is impossible in every sense, but because most systems making the claim haven't actually closed every failure window in the chain (see the example above). Bounded guarantees, stated precisely, are meaningful and worth stating. In practice, at-least-once delivery, plus idempotent processing at each hop, plus reconciliation for what idempotency can't cover, is the design that actually ships.

07

Transactions

A transaction is powerful and narrow:

  • It can atomically create the local business mutation and its local idempotency claim together, when both live in the same transactional datastore — that's the guarantee behind the lifecycle table above.
  • It does not extend to a remote API call, a message broker publish, an email send, or a second, independently-committed database — none of those roll back if your transaction does.
  • Publish after commit (write locally, then publish) leaves a gap where the write succeeded but the publish can still fail — the case a transactional outbox exists to close; see reliable-workflows.transactional-outbox rather than re-deriving it here.
  • Call before commit (call the remote system, then commit) leaves the opposite gap: the call can succeed and the local commit can still fail, leaving an effect with no local record of it.
  • A workflow that runs long — waiting on a person, a batch process, a slow provider — should not be modelled as one long-held transaction. It needs explicit state (the processing/completed shape above) and an explicit recovery path, not a lock held open across the wait.

08

Practical example: duplicate charge on a payout retry

A merchant-payout API pays a merchant's linked bank account for a period's sales. POST /payouts accepts an idempotency key from the caller (typically the merchant's own back-office system, itself retrying after a timeout).

Without a key, or with the concurrency gap in Lifecycle and concurrent claims left unhandled: the back-office system times out waiting for a response, retries, and both requests reach the payout service before either has claimed the key. Both execute. The merchant is paid twice for the same period — a real, refund-requiring, reconciliation-triggering mistake, not a cosmetic duplicate.

With the design above: the second request's atomic claim fails (the first already owns processing or completed), so it never reaches the bank call. If the first request is still processing when the second arrives, the second gets a defined "still processing, check status at /payouts/{id}" response rather than a guess. If the bank call itself is in the ambiguous state from the failure-window example above, reconciliation — not another retry — is what resolves it.

The unique index stops a second row. Only the state model stops a second charge.

09

Signals it's time to evolve the design

  • Retries or duplicate deliveries are arriving after the chosen correctness-retention window — the window was sized for the wrong tail.
  • processing claims are accumulating without resolving — a sign recovery/lease logic is missing, not that duplicates are rare.
  • The operation now crosses a service or provider boundary it didn't before, and the existing local-only claim no longer covers the new failure window.
  • Idempotency storage itself becomes a bottleneck or a single point of failure for the request path.
  • Reconciliation volume is growing — a sign the "unknown" state from an external call is being hit often enough to need first-class handling, not ad hoc fixes.
  • The operation has become asynchronous or long-running, and the original synchronous request/response idempotency model no longer fits — see reliable-workflows.queues-async-processing.
  • A downstream provider ships its own idempotency feature — that's usually a signal to propagate your key to it rather than only deduplicating locally.

10

Common misconceptions

11

Checklist

  • The business operation identity is explicit — what exactly is being deduplicated, and at what scope (tenant, account, endpoint)?
  • The conflict policy is defined: what happens when the same key arrives with a materially different payload?
  • Claiming is atomic and race-safe — two simultaneous arrivals cannot both execute.
  • processing state exists, and abandoned claims (crashed owner, expired lease) have a recovery path.
  • Completion and replay policy is defined per state (completed / processing / conflicting / failed / expired), not just "return the cache."
  • Retention window is chosen from realistic retry/delayed-delivery tails, not a round default — and correctness, debug, and audit retention are considered separately where they differ.
  • Storage decision addresses sensitive data — full response vs. compact outcome, and who's authorized to see a replay.
  • The idempotency key is propagated to any downstream provider that supports its own deduplication.
  • There's an explicit plan — reconciliation, provider status check, or similar — for the case where an external call's outcome is genuinely unknown.
  • Duplicates, conflicts, stuck claims, expiries, and recoveries are all observable, not just the happy path — see reliable-workflows.observability-reliability for what "observable" should mean beyond a log line.

08

Key takeaways

How to make repeated operations safe in the presence of retries.

  • The decision is not "add a key to every POST" — it's whether plausible duplicates would cause a costly effect that nothing else already prevents.
  • A unique index prevents duplicate *records*; it does not by itself manage `processing`, recovery, or what a concurrent duplicate should see while the first attempt is still running.
  • A local claim and a local transaction only prove what happened inside your own boundary — an external call's outcome after an ambiguous failure needs propagated keys, an outbox for publish, or reconciliation, not another guess.
  • "Exactly-once" is only a precise claim when it names its boundary; the practical design is at-least-once delivery, idempotent processing, and reconciliation for what that can't cover.

Practice

Test what you’ve learned

Work through guided Idempotency decision scenarios. Every choice becomes a mentoring conversation, so you find out where your reasoning holds up.

Practice

Review

Revisit real examples

A calm, five-minute pass back through the concrete Idempotency examples in this guide, before they fade.

Review