system-design

Idempotency and Exactly-Once Delivery ⭐

Networks force retries. Retries cause duplicates. Idempotency is what makes duplicates harmless — and it’s the single most valuable thing you can bring up unprompted in a design interview.

Prerequisites: The 8 Fallacies, Message Queues Time to read: ~26 minutes


The problem

A customer taps “Pay $500.” The request times out.

Client → POST /payments {amount: 500}
        ⏱️ 30 seconds pass
        ❌ timeout

Did the payment succeed?

The client cannot tell. Three completely different situations produce an identical observation:

  1. The request never reached the server. Nothing happened.
  2. It reached the server, which is still processing it. It’s happening now.
  3. It completed successfully and the response was lost. It already happened.

🚨 This ambiguity is the fundamental problem of distributed systems, and there is no protocol that removes it. The client must decide: retry (and risk charging twice) or don’t (and risk not charging at all).

Idempotency makes the choice easy: retry, and design the operation so the second attempt is a no-op.


The definition

An operation is idempotent if performing it multiple times has the same effect as performing it once.

# ✅ Idempotent — the end state is the same however many times you run it
set_status(order, "shipped")
delete_user(42)
UPDATE accounts SET balance = 500 WHERE id = 1

# ❌ Not idempotent — each execution changes the result
increment_counter()
UPDATE accounts SET balance = balance - 100 WHERE id = 1
append_to_list(item)
send_email()

🚨 Note the second one carefully. balance = balance - 100 is not idempotent, even though it’s a single atomic SQL statement. Atomicity and idempotency are different properties, and conflating them is a common error.

HTTP methods and their guarantees (from HTTP):

Method Idempotent?
GET, HEAD, OPTIONS ✅ Also safe — no side effects at all
PUT ✅ Setting a value repeatedly gives the same result
DELETE ✅ Deleting twice leaves it deleted (return 204 or 404, not an error)
POST By default. This is where the work is.
PATCH ❌ Usually not ({"increment": 5} is not idempotent)

Making POST idempotent: the idempotency key

The client generates a unique key and sends it with the request. The server records it.

POST /payments HTTP/1.1
Idempotency-Key: 7f3a9c2e-4b1d-4e8a-9f2c-1a5b7d3e9f01
Content-Type: application/json

{"amount": 500, "currency": "PKR", "card": "..."}
def create_payment(idempotency_key, request):
    with db.transaction():
        existing = db.query(
            "SELECT * FROM idempotency_keys WHERE key = %s FOR UPDATE",
            idempotency_key
        )
        if existing:
            if existing.status == "completed":
                return existing.response           # ← return the ORIGINAL response
            if existing.status == "in_progress":
                raise Conflict409("Request already in progress")

        db.insert("idempotency_keys",
                  key=idempotency_key, status="in_progress",
                  request_hash=hash(request))

    result = charge_card(request)                  # the actual work

    with db.transaction():
        db.update("idempotency_keys", key=idempotency_key,
                  status="completed", response=result)
    return result

🚨 Four details that matter, and each is a thing interviewers probe:

1. Return the original response, not just “already done.” The client needs the payment ID. A retry should be indistinguishable from the original success.

2. Handle the in-progress case. Two concurrent requests with the same key: one proceeds, the other gets a 409 Conflict telling it to retry shortly. Without the FOR UPDATE lock, both proceed and you’ve charged twice.

3. Store a hash of the request body. If the same key arrives with different parameters, that’s a client bug — return 422, don’t silently return the old response for a different request.

4. Expire keys. Stripe retains them for 24 hours. Keeping them forever means unbounded storage. Choose a window longer than any realistic retry sequence.

Where the key comes from: the client generates it (a UUID), so the same logical operation keeps the same key across every retry. 🚨 If the server generated it, retries would get different keys and the whole mechanism would fail — this is a surprisingly common misunderstanding.


The natural-key alternative

Often you don’t need a separate key table — the data already has a unique business identifier.

-- Idempotent by construction
INSERT INTO invoices (customer_id, period, amount_cents)
VALUES (42, '2026-07', 150000)
ON CONFLICT (customer_id, period) DO NOTHING;

-- Or with an explicit event ID
INSERT INTO processed_events (event_id) VALUES (%s)
ON CONFLICT (event_id) DO NOTHING;
-- 0 rows affected → we've already handled this event → return

⚖️ Prefer this when a natural key exists. It’s simpler, has no extra table to maintain, no expiry policy, and the uniqueness constraint is enforced by the database for every writer — including a migration script or someone running SQL manually.

🎙️ “I’d make it idempotent on (customer_id, billing_period) with an upsert rather than adding a separate idempotency key table. The constraint does the work, and it protects us regardless of which code path writes.”


Exactly-once delivery is impossible

🚨 Claiming otherwise is a red flag in interviews. Here’s the proof, and it’s short:

Broker sends message → Consumer processes it → Consumer sends ACK ──✗ ACK is lost
Broker never received the ACK. It must assume failure. It redelivers.
→ duplicate

The broker cannot distinguish “the consumer died before processing” from “the consumer processed it and the ACK was lost.” Both look identical. Closing the gap would require an atomic commit spanning the broker and the consumer’s side effects — which is a distributed transaction, with all its problems.

What you actually build:

at-least-once delivery  +  idempotent processing  =  effectively-once

🚨 “Effectively-once” is the correct term, and using it precisely is a strong signal.

What about Kafka’s “exactly-once semantics”? It’s real, and it’s narrower than the name suggests: Kafka’s transactions make a read-process-write cycle within Kafka atomic — consume from a topic, produce to another topic, and commit the consumer offset, all or nothing. The moment your consumer charges a card or writes to Postgres, you’re outside that boundary and you need idempotency again.

🎙️ “Kafka’s exactly-once is exactly-once within Kafka’s transaction boundary. Since our consumer calls a payment API, that’s an external side effect outside the transaction — so we still need an idempotency key.”


Deduplication strategies

Strategy How Best for
Idempotency key table Store keys, check before processing API endpoints with side effects
Natural unique constraint ON CONFLICT DO NOTHING on a business key When one exists — prefer this
Conditional update UPDATE ... WHERE status = 'pending' State machines
Optimistic concurrency WHERE version = 7 Concurrent edits
Bloom filter + storage Fast negative check before a lookup Very high volume, some false positives OK
Sequence numbers Track the highest processed per producer Ordered streams

🚨 The transactional guarantee is what makes it work. Recording that you processed the event must be atomic with the side effect:

# ✅ Atomic — either both happen or neither
with db.transaction():
    if db.exists("processed_events", event_id):
        return
    apply_business_change(event)
    db.insert("processed_events", event_id)

# ❌ Broken — crash between them and you reprocess
apply_business_change(event)
db.insert("processed_events", event_id)      # crash here → duplicate on retry

⚖️ And when the side effect is external (a payment API, an email), you can’t include it in your transaction. Then you need the external system to be idempotent too — which is exactly why Stripe accepts an Idempotency-Key header. Idempotency has to compose across service boundaries.


Making inherently non-idempotent operations safe

Counters. balance = balance - 100 isn’t idempotent. Options:

  1. Record the transaction, derive the balance:
    INSERT INTO ledger (id, account, amount) VALUES (%s, 42, -100)
    ON CONFLICT (id) DO NOTHING;
    -- balance = SELECT sum(amount) FROM ledger WHERE account = 42
    

    🚨 This is how real financial systems work — an immutable append-only ledger with derived balances, never a mutable balance field. It’s idempotent, auditable, and reconstructible.

  2. Conditional update: UPDATE accounts SET balance = 400, version = 8 WHERE id = 1 AND version = 7
  3. CRDT counters for eventual consistency → Conflict Resolution

Emails and notifications. You cannot unsend. So deduplicate before sending:

if db.insert_if_not_exists("sent_notifications", (user_id, event_id)):
    send_email(...)      # only runs the first time

Note the ordering: record first, then send. 🚨 The reverse means a crash between them sends twice. This deliberately biases toward possibly not sending over definitely sending twice — which is usually the right trade for a notification, and the wrong one for a critical alert. Decide consciously.

Appending to a list. Use a set keyed by item ID, or an upsert, rather than a blind append.

Calling a third-party API. Use their idempotency mechanism if they have one. If not, record the attempt with its result before and after, and reconcile.


Where idempotency is required

Almost everywhere, once you look:

Context Why
Any API with side effects Clients retry on timeout
Message consumers At-least-once delivery guarantees duplicates
Webhook receivers Providers retry aggressively; you’ll get duplicates
Background jobs Locks expire, workers crash, humans re-run them
Saga steps and compensations Orchestrators crash between “did it” and “recorded it”
Payment flows The stakes are money
Data pipelines Reprocessing after a bug fix must not double-count
CDC consumers Events replay on restart

🚨 Webhooks deserve special mention. Every serious provider (Stripe, GitHub, Twilio) retries webhooks on non-2xx responses and on timeouts, and they all document that you will receive duplicates. A webhook handler without deduplication is a bug waiting to be triggered.


⚖️ Trade-offs

Approach Gain Cost
Idempotency keys Safe retries for any operation A key store to maintain and expire; an extra lookup per request
Natural unique constraints Simple, enforced for all writers Only when a business key exists
Append-only ledger Idempotent, auditable, reconstructible More storage; balance requires aggregation (or a materialized view)
Dedupe before an external call Never double-send May not send if you crash after recording
Dedupe after Never miss May double-send
Longer key retention Safe for slow retries More storage

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Cause a double charge. An endpoint that inserts a payment row. A client that retries on timeout. Make the server sleep past the client’s timeout, then complete successfully. Watch two payments appear for one user action. Then add an idempotency key and watch the second attempt return the first result.

This is the single most valuable exercise in Part 4 — it takes fifteen minutes and it makes the concept permanent.

2. Break it with concurrency. Send two requests with the same idempotency key simultaneously. Without SELECT ... FOR UPDATE, both pass the existence check and both proceed. Add the lock and watch one get a 409.

3. Break the atomicity. Split the side effect and the dedup record into two transactions. Kill the process between them. Retry. Observe the duplicate. Then combine them into one transaction.

4. Test a real webhook. Point a Stripe (test mode) or GitHub webhook at your endpoint and return a 500. Watch the retries arrive. Count how many duplicates you receive — then implement deduplication on the provider’s event ID.


Check yourself

1. Why can't a client simply retry a failed POST? Because a timeout doesn't tell you what happened. Three situations look identical from the client's side: the request never arrived (retrying is correct), the request arrived and is still being processed (retrying creates a concurrent duplicate), or the request completed and only the response was lost (retrying duplicates the effect). For an operation with side effects — charging a card, placing an order — retrying blindly risks doing it twice. The client's only safe options are to make the operation idempotent (via an idempotency key), to poll a status endpoint to discover what happened, or to reconcile later.
2. Why must the idempotency key be generated by the client? Because the key must be *identical across all retries of the same logical operation*, and only the client knows that two requests are retries of each other. If the server generated the key, each retry would arrive without one, receive a fresh key, and be treated as a new operation — exactly the duplicate you were preventing. The client generates a UUID once, before the first attempt, and sends the same value on every retry of that attempt. This also means the key must be generated *before* the request is sent, not regenerated per attempt — a subtle bug worth watching for in client libraries.
3. Why is exactly-once delivery impossible, and what do you build instead? Because acknowledgments can be lost. The broker sends a message, the consumer processes it and sends an ACK, and the ACK is lost in transit. The broker cannot distinguish this from "the consumer died before processing" — both are silence — so it must redeliver, producing a duplicate. Eliminating the gap would require an atomic commit spanning the broker and the consumer's external side effects, which is a distributed transaction with all its blocking problems. So you build **at-least-once delivery plus idempotent processing**, which yields *effectively-once* semantics: duplicates are delivered but produce no additional effect.
4. Why must the deduplication record and the side effect be in the same transaction? Because otherwise a crash between them leaves you in a broken state. If you apply the business change first and crash before recording the event ID, the retry sees no record and applies the change again — a duplicate. If you record first and crash before applying, the retry sees the record, assumes it's done, and skips it — a lost update. Putting both in one database transaction makes them atomic: either the change happened and is recorded, or neither. When the side effect is *external* (a payment API, an email) you can't do this, which is why the external system must offer its own idempotency — idempotency has to compose across boundaries.
5. How would you make "increment the balance by 100" idempotent? Don't store a mutable balance. Store an **append-only ledger** of immutable entries, each with a unique ID, and derive the balance by summing them: `INSERT INTO ledger (id, account, amount) VALUES (:txn_id, 42, 100) ON CONFLICT (id) DO NOTHING`. A retry with the same transaction ID inserts nothing, so the balance is unchanged. This is how real financial systems work, and it also gives you a complete audit trail and the ability to reconstruct the balance at any past moment. If summing is too slow, maintain a materialized balance updated in the same transaction as the ledger insert. The alternatives — optimistic concurrency with a version column, or a separate idempotency key table — also work but give you less.

Further reading