SysDesignPrep.com
Interactive concepts

At-most-once, at-least-once and exactly-once: an interactive delivery simulator

A message system can promise to deliver each message at most once, which may lose some, or at least once, which may repeat some. Exactly once delivery across a network is not possible in general, because a consumer can always crash between doing the work and recording that it did. What systems offer instead is effectively once processing: at-least-once delivery, plus consumers that make repeats harmless.

The playground runs 200 messages, each crediting an account, through a consumer that crashes now and then and restarts from its last committed position. Each grid shows what happened to every message.

Commit everyCrash rate

200 messages, each crediting an account. The consumer commits its position every 10 messages and crashes on 2% of them, then restarts from the last committed position. Committing first, 10 credits are never applied: the consumer had already recorded those messages as done when it died. Processing first, nothing is lost, but 20 messages are applied more than once, 20 extra credits in all, because they were redelivered after a crash. Recording each message id in the same transaction as its effect turns those replays into no-ops: 200 of 200 applied exactly once, though the consumer still handled 222 deliveries.

At most oncecommit offsets, then process
Applied once
190
Lost
10
Applied twice or more
0
At least onceprocess, then commit offsets
Applied once
180
Lost
0
Applied twice or more
20
Effectively onceprocess and record the id together, skip ids seen
Applied once
200
Lost
0
Applied twice or more
0
applied oncenever appliedapplied more than once (count)Each consumer crashes at random, at the same rate.

Where the guarantee comes from

A consumer does two things for every message: the work, and recording its position in the stream, a Kafka offset commit or an SQS delete. A crash can fall between them, and the order decides the outcome:

  • At most once: record the position first, then do the work. A crash in between skips the message forever. With commits every 10 messages and 2% crashes, the playground loses 10 of 200 credits.
  • At least once: do the work first, then record the position. A crash in between redelivers the batch on restart, and the work runs again: 20 credits applied twice in the same run.
  • Effectively once: at-least-once delivery, with the work made idempotent. Every one of the 200 credits is applied exactly once, even though the consumer handled 222 deliveries.

Making repeats harmless

  • Record processed message ids in the same database transaction as the effect, and skip any id already recorded. This is what the playground does.
  • Make the operation naturally idempotent: “set status to shipped” or an upsert by a unique key can safely run twice; “add $40” cannot.
  • Use versions: apply an update only if the record’s version is older than the event’s.
  • Store the offset with the result: if the output and the consumer’s position commit in one transaction, a crash rolls back both.

Kafka’s exactly-once support does the last of these within Kafka: an idempotent producer drops duplicate sends, and transactions commit the output messages and the consumer’s offsets atomically. It covers a read-process-write pipeline from Kafka to Kafka, not side effects in other systems, such as charging a card or sending an email, which still need idempotency of their own.

Batch size

Committing after every message keeps the damage of a crash to one message but costs a round trip each time; committing every 50 is cheaper but replays or loses up to 50. In the playground, at-least-once with commits every 50 applies 77 messages more than once. Most consumers commit every few hundred milliseconds or few hundred messages and rely on idempotency for the rest.

Effectively-once processing in code

# effectively once: the side effect and "I have handled this message" commit together
def handle(msg, db):
    with db.transaction() as tx:
        inserted = tx.execute(
            "INSERT INTO processed (message_id) VALUES (%s) ON CONFLICT DO NOTHING",
            (msg.id,),
        ).rowcount
        if inserted == 0:
            return                                    # seen before: a redelivery, skip it
        tx.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s",
                   (msg.amount, msg.account_id))

for batch in consumer.poll(max_records=10):
    for msg in batch:
        handle(msg, db)
    consumer.commit()                                 # after processing: at least once
# a crash before commit replays the batch; handle() makes the replay harmless

In a system design interview

Never claim exactly-once delivery; say how duplicates are handled: “Payment events are consumed at least once: the consumer commits offsets after processing. Each event carries an id, and the ledger insert uses it as a unique key in the same transaction as the balance update, so a redelivered event is a no-op. Losing an event is worse than seeing it twice, so we never commit before processing.” Expect the follow-ups: how long processed ids are kept, ordering within a key, and what happens with side effects outside your database.

Read the guide to delivery semantics, see idempotency keys for the same idea in APIs and the transactional outbox for the producing side, or watch a strong candidate design a message queue.

Questions people ask

What is the difference between at-most-once and at-least-once delivery?

With at-most-once delivery a message is never processed twice but may be lost, because the consumer records it as done before processing it. With at-least-once delivery a message is never lost but may be processed more than once, because the consumer records it as done only after processing, and a crash in between causes a redelivery.

Is exactly-once delivery possible?

Not in general across a network: a consumer can always crash after doing the work but before recording that it did, and then the system cannot know whether to redeliver. What is achievable is effectively-once processing, at-least-once delivery combined with idempotent handling, so that redelivered messages have no additional effect.

How do you achieve effectively-once processing?

Deliver at least once and make processing idempotent: record each message id in the same transaction as its effect and skip ids already recorded, use naturally idempotent operations such as upserts or setting a value, apply updates only if their version is newer, or commit the output and the consumer position atomically.

What does Kafka exactly-once mean?

Kafka’s exactly-once semantics combine an idempotent producer, which discards duplicate sends, with transactions that atomically commit output messages and the consumer’s offsets. It guarantees exactly-once results for pipelines that read from and write to Kafka; effects in external systems still need their own idempotency.

When should a consumer commit its offset?

After processing, for at-least-once delivery, which is what most systems want, since losing a message is usually worse than seeing it twice. Committing before processing gives at-most-once delivery, acceptable only for data like metrics where losing a little is fine. Commit frequency trades overhead against how much is replayed after a crash.

Why do message consumers need to be idempotent?

Because at-least-once delivery redelivers messages after crashes, timeouts and rebalances, and producers may retry sends that actually succeeded. A consumer that is not idempotent applies those duplicates again: double charges, double emails, counts that drift. Idempotency makes the redelivery harmless.

How long should processed message ids be kept for deduplication?

At least as long as a duplicate can arrive: the longest redelivery window, which depends on retry policies, retention and how long a consumer can be down before restarting from old offsets. Days are common. Ids can be expired by time or kept per partition with offsets, since ids below the committed offset are known to be done.