Loading...
Loading...
At-most-once, at-least-once, and exactly-once delivery guarantees explained
The mailbox-or-diary choice settled who reads and for how long. It said nothing about the crash in the middle of the handoff. Think of delivery promises like postal mail, the single analogy for this piece: the post office can promise to never hold your letter back, to keep trying until it arrives even if that means two copies, or to somehow achieve exactly one arrival, and each promise costs a different amount of tracking. You send an email job to a worker, the worker sends the actual email through SMTP, which is the protocol servers use to hand email to each other, and then crashes before acknowledging, which is telling the broker, meaning the middleman holding the message, that the work finished. Did the email go out? Redeliver and the user gets two emails; do not redeliver and they get none. The network makes it impossible to know without extra coordination, because a timeout tells you the acknowledgment never arrived but never tells you whether the work happened before the crash, so every system picks a different guarantee at a different price.
The naive fix is to assume the infrastructure handles it, meaning retries plus acknowledgments somehow add up to exactly once. That fails because the uncertainty sits in the gap between doing the work and recording that you did it: no amount of retrying removes the crash window between a sent email and a recorded acknowledgment. The real solution is to choose explicitly which side of the trade you want, and then make the dangerous side harmless through design.
Producer -> Broker -> Consumer ack? ack? ack? Timeout anywhere -> did it commit? -> retry -> duplicate or loss
| Guarantee | Duplicates | Loss | How |
|---|---|---|---|
| At-most-once | No | Possible | Fire and forget, meaning send with no retry and no acknowledgment. Fast and lossy. |
| At-least-once | Possible | No, with retry | Retry until acknowledged, so the consumer must be idempotent, meaning repeated handling has the same effect as handling once. |
| Exactly-once | No | No | Deduplication plus transactional write, meaning the dedup record and the business update commit atomically, so a replay is detected before any side effect repeats. |
Broker retries, SQS visibility timeouts, which are periods a queue hides a delivered message from other workers, and Kafka offsets, which are per-consumer bookmarks in the log, left uncommitted are all at-least-once in practice. Make the handler idempotent with a dedup table carrying a unique index on the message id:
CREATE TABLE processed(id TEXT PRIMARY KEY);
-- handler
INSERT INTO processed VALUES ($msgId) ON CONFLICT DO NOTHING;
IF inserted THEN process();Kafka exactly-once uses an idempotent producer, which is a sender the broker deduplicates by sequence number, plus a transactional offset commit plus consumers reading only committed data. One team instead tried two-phase commit, which is a coordinator asking the database and the broker to both promise before either commits, across Postgres and Kafka. Each publish paid two extra coordinator round trips of roughly 20 milliseconds, cutting publish throughput by more than half, and a coordinator crash still left transactions in doubt needing manual resolution. The database equivalent writes the business row and the dedup record in the same local transaction. True exactly-once across different systems is the outbox pattern, covered below.
A payments worker pulls an order to charge 49 dollars from SQS, which is a hosted queue service where you pay per call instead of running brokers, calls the payment gateway, and the container is killed before the message is deleted. SQS redelivers after the visibility timeout expires. The retry charges 49 dollars again, support refunds one charge, the ledger shows two, and finance asks why the dashboard counted revenue twice. This is at-least-once in the wild: redelivery is guaranteed by the infrastructure, and uniqueness is your job in the handler.
You cannot prevent redelivery, so you make redelivery harmless. Every message carries a stable idempotency key, which is an identifier derived from the business operation such as the order id plus the word charge, never a fresh random value per attempt, and the handler inserts it atomically with the business write inside one transaction. The same key twice means already done, and the database enforces it with a uniqueness constraint even when two workers race on the same message.
-- Postgres: business row + dedup in ONE transaction
BEGIN;
INSERT INTO idempotency_keys(key, created_at)
VALUES ('order-9917-charge', now())
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- if no row returned: duplicate, ROLLBACK and ack without work
UPDATE orders SET status='charged', charged_at=now()
WHERE id='order-9917' AND status='pending';
COMMIT;
-- Payment API equivalent:
-- POST /v1/charges with header Idempotency-Key: order-9917-charge
-- gateway dedups for 24h; safe to retry on timeoutThe key must survive retries, so derive it from business identity such as order id plus operation name, keep it for at least twice your maximum retry window with 24 hours as the minimum for payments, and return the original stored result on conflict so callers cannot tell a replay from the first attempt.
SQS FIFO queues, which are first-in-first-out queues that preserve order per group, deduplicate on the sender-supplied id for only 5 minutes, which covers transport retries but not business replays hours later. The Kafka idempotent producer, enabled with a single setting, deduplicates sender retries only within one session, so the consumer-side table is still required for end-to-end safety.
Your service updates a Postgres row and publishes an order-paid event to Kafka, which is a distributed log kept on disk for replay. Those are two writes with no shared transaction, so a crash between them leaves the database saying paid while downstream never hears it, or downstream hearing paid while the database says pending. The outbox, which is a table inside your own database where you stage outgoing events, fixes it by collapsing two writes into one: write the business row and the event row in the same local transaction, then let a relay process publish the event row to the log. Consumers deduplicate on the event id, so relay replays stay harmless.
-- same transaction: state + intent
BEGIN;
UPDATE orders SET status='paid' WHERE id='order-9917';
INSERT INTO outbox(event_id, aggregate, type, payload)
VALUES ('evt-9917-paid','order-9917','OrderPaid','{"total":49}');
COMMIT;
-- relay tails outbox -> publishes to Kafka, marks sent
-- consumer dedups on event_id; safe to replay relay outputDuplicates handled, losses bounded, costs understood. But guarantees assume the workers stay up, and some messages are born to fail. What happens to the ones that never succeed, no matter how often you retry?