System Design
Advanced 13 min read Messaging & events

Delivery guarantees and effectively-once

In one line

Networks force a choice between possibly losing messages (at-most-once) and possibly duplicating them (at-least-once); real systems choose at-least-once and make processing idempotent, which gives 'effectively-once' results end to end.

What it is

A delivery guarantee describes what happens to a message when something fails between sender and receiver. At-most-once: send and forget; a crash can lose the message, but it is never processed twice. At-least-once: retry until acknowledged; nothing is lost, but a lost acknowledgement causes a duplicate. Exactly-once: each message affects the result exactly one time.

Exactly-once delivery over an unreliable network is impossible in general: if the sender does not get an acknowledgement, it cannot know whether the message or the acknowledgement was lost. What is achievable is exactly-once effect (often called effectively-once): deliver at least once, and make the receiver's processing idempotent so a duplicate changes nothing.

Kafka's 'exactly-once semantics' (EOS) is real but scoped. The idempotent producer removes duplicates caused by producer retries within a partition, and transactions let a consume-transform-produce job atomically write output records and commit input offsets. Consumers using isolation.level=read_committed then never see aborted writes. This is exactly-once inside Kafka. The moment your consumer charges a card, sends an SMS or writes to Postgres, Kafka cannot roll that back, so you are back to at-least-once plus idempotency.

Idempotency is therefore the core skill: give every message or request a unique id, and make the side effect conditional on that id not having been applied (a dedupe table in the same transaction, a unique constraint, an upsert keyed by a natural id, or a version check).

Why interviewers ask

Payments, wallets, inventory, notifications and ledgers all break on duplicates or losses: a double debit, a missed refund, two OTP SMS, overselling. Interviewers love asking 'how do you make sure the user is charged exactly once?' and listening for whether you claim magic exactly-once or explain at-least-once with idempotency keys. Confusing Kafka's internal exactly-once with end-to-end exactly-once is a classic red flag.

How it works

  1. 1Producer side: retry sends on timeout, with a stable message id generated once (UUID or business id), so retries carry the same id.
  2. 2Broker side: persist and replicate before acknowledging (Kafka acks=all with min.insync.replicas=2; SQS and RabbitMQ durable queues with publisher confirms). Enable the idempotent producer in Kafka so broker-level retries do not duplicate records.
  3. 3Consumer side: process first, then acknowledge or commit the offset. A crash in between causes redelivery, never loss.
  4. 4Make processing idempotent: in the same database transaction as the side effect, insert the event id into a processed_events table with a unique key. If the insert conflicts, skip the side effect.
  5. 5Where possible use naturally idempotent operations: SET status = 'shipped' WHERE order_id = 7 AND status = 'packed' (a state transition), an upsert by business key, or 'set balance to X at version V' with optimistic concurrency, rather than 'add 100'.
  6. 6For external calls (payment gateways, SMS, email), pass an idempotency key the provider honours (Stripe's Idempotency-Key header, a merchant order id for UPI or card payments). If the provider has no such key, record intent before calling and reconcile uncertain outcomes by querying status.
  7. 7For pure Kafka-to-Kafka stream processing, use Kafka transactions (Kafka Streams processing.guarantee=exactly_once_v2, or Flink's two-phase-commit Kafka sink) so output and offsets commit atomically.
  8. 8Bound the dedupe store: keep processed ids for longer than the maximum redelivery window (for example 7 days, matching topic retention), then expire them.

Worked example · Cashback credit to a wallet, delivered twice

  1. A payments service publishes PaymentSucceeded {event_id: e-77, user_id: 42, cashback: 50} to Kafka. The rewards consumer credits Rs 50 to user 42's wallet.
  2. The consumer applies the credit and is killed by a deploy before committing its offset. On restart, it reads e-77 again.
  3. Naive consumer: balance goes from 100 to 150, then to 200. The user got Rs 100 cashback; across 1M events a day, even a 0.1% duplicate rate is 1,000 wrong credits daily.
  4. Idempotent consumer: inside one Postgres transaction it inserts (rewards, e-77) into processed_events and updates the balance. The first run commits both. The second run's insert hits the unique key, so it skips the update. Balance stays 150.
  5. The dedupe table grows by 1M rows a day, about 60 MB with ids and indexes; a daily job deletes rows older than 7 days, so it stays around 7M rows.
  6. Result: at-least-once delivery from Kafka plus idempotent processing gives an exactly-once effect on the wallet.

Picture it

Sending an important courier with tracking: if you get no delivery confirmation, you send it again, so the receiver might get two copies. Exactly-once is achieved not by the courier but by the receiver: the cheque has a unique number, and the bank will never cash the same cheque number twice.

Reference code

def handle(event, db):
    # One local transaction: dedupe record + side effect commit together.
    with db.transaction():
        inserted = db.execute(
            "INSERT INTO processed_events (consumer, event_id) VALUES (%s, %s) "
            "ON CONFLICT DO NOTHING",
            ("wallet-credit", event["event_id"]),
        ).rowcount
        if inserted == 0:
            return  # duplicate delivery: already applied, do nothing
        db.execute(
            "UPDATE wallets SET balance = balance + %s WHERE user_id = %s",
            (event["amount"], event["user_id"]),
        )
    # Commit the Kafka offset only after the DB transaction succeeded.
    # A crash before this line means redelivery, which the dedupe row absorbs.

Key patterns

Dedupe table in the same transaction

When: Consumers whose side effect is a database write

How: processed_events(consumer, event_id) with a unique key, inserted in the same transaction as the business write; skip if it already exists.

Idempotency keys on APIs

When: Clients retry POST requests (payments, orders, bookings)

How: Client sends a unique key; server stores key, request hash and response; a repeat returns the stored response; a different request with the same key is rejected.

Conditional state transitions

When: Entities with a status lifecycle

How: UPDATE ... SET status = 'paid' WHERE id = ? AND status = 'pending'; zero rows updated means already done or invalid.

Transactional consume-transform-produce

When: Kafka-to-Kafka stream processing

How: Kafka transactions or Kafka Streams exactly_once_v2: output records and input offsets commit atomically; downstream reads with read_committed.

Reconciliation

When: Calls to external systems with uncertain outcomes

How: Record intent first, query the provider for status on timeout, and run periodic comparisons to fix drift.

Real-world use

Stripe idempotency keys

Stripe's API accepts an Idempotency-Key header on POST requests. A retried request with the same key returns the stored result of the first attempt instead of creating a second charge. Keys can be removed after 24 hours.

Kafka exactly-once semantics

Introduced in Kafka 0.11 (2017): idempotent producers and transactions. Kafka Streams exposes it as processing.guarantee=exactly_once_v2. It covers reads and writes within Kafka, not side effects in external systems.

Amazon SQS

Standard queues are at-least-once and may deliver out of order. FIFO queues deduplicate messages with the same deduplication id within a 5-minute window; consumers still need idempotency for longer gaps and their own side effects.

Apache Flink

Flink uses checkpoints for exactly-once state inside the job and two-phase commit sinks (for example to Kafka) for exactly-once output to systems that support transactions.

UPI and card payments

Payment networks give each transaction a unique reference, and merchants use their own order id to avoid charging twice. When a response is lost, the merchant checks status instead of retrying the debit blindly; reconciliation files catch anything left uncertain.

In a project

Your notification service consumes OrderShipped events and sends an SMS. During a broker failover and a consumer rebalance, about 3% of customers got the same SMS two or three times, and the SMS bill for the day went up noticeably.

  1. Accept that Kafka redelivers after rebalances and crashes; the fix is in the consumer, not in hoping for no duplicates.
  2. Add a dedupe key: (event_id, channel). Before sending, do SET sms:<event_id> 1 NX EX 604800 in Redis, or insert into a Postgres sent_notifications table with a unique key.
  3. Order the steps carefully: claim the key, send, then mark status sent. If sending fails, release the claim so a retry can happen; if the process dies after sending, the claim stays and stops a duplicate.
  4. Pass the event id as the client reference to the SMS provider where supported, so the provider can also drop duplicates.
  5. Keep the claim window longer than topic retention and maximum replay time.
  6. Add a metric for 'duplicates suppressed' to see how often redelivery really happens.

Impact: Duplicate SMS drop to near zero, the SMS bill falls back to normal, and the team can replay topics after incidents without fear of spamming users. The rare case of a crash between send and claim is accepted and documented as at-most-one-extra.

Trade-offs

Use it when

  • At-least-once plus idempotency: payments, wallets, orders, inventory, notifications, almost everything
  • At-most-once: metrics samples, telemetry, live location pings where the next update replaces the lost one
  • Kafka transactions: stream processing that reads from and writes to Kafka only

Avoid it when

  • Promising 'exactly-once' end to end because the broker advertises it
  • Using Kafka transactions to protect writes to an external database; they cannot cover it
  • At-most-once for anything involving money, orders or user-visible state
  • Keeping dedupe ids forever in a hot store; expire them after the redelivery window

Common mistakes

  • Saying 'Kafka gives exactly-once, so duplicates cannot happen' for a consumer that sends emails or writes to a database.
  • Committing offsets before processing, turning at-least-once into silent message loss.
  • Doing the dedupe check and the side effect in separate transactions, so a crash between them causes a loss or a duplicate.
  • Using non-idempotent operations like 'balance = balance + 50' with no event id check.
  • Generating a new idempotency key on each retry, which defeats the purpose.
  • Retrying a payment call after a timeout without checking whether the first attempt succeeded.

Interview questions

Quick revision

  • At-most-once: may lose, never duplicates. At-least-once: never loses, may duplicate.
  • Exactly-once delivery is impossible in general; exactly-once effect is achievable.
  • Effectively-once = at-least-once + idempotent processing.
  • Kafka EOS = idempotent producer + transactions, inside Kafka only.
  • Process, then commit offset/ack.
  • Dedupe row and side effect in one transaction.
  • Idempotency keys: generated once by the client, reused on every retry.
  • Prefer state transitions and upserts over increments.
  • External calls: idempotency key, status check, reconciliation.

Check yourself

1. A consumer charges a card via an external API after reading from Kafka with transactions enabled. What delivery effect does the card charge have without extra work?

2. Which consumer order gives at-least-once delivery?

3. Which operation is naturally idempotent?

4. Why must the dedupe insert be in the same transaction as the business write?

See it in a full design

These walkthroughs use this lesson.

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free