A queue is a promise about delivery between a producer and a consumer. That promise can be “at least once” (never lose) or “at most once” (never duplicate), but never both for free. Choosing the guarantee is choosing which failure you can tolerate.

The trade between losing and duplicating

A queue is a promise about what happens to a message, and the promise you choose decides the failure modes you live with. At-most-once tries hard to send a message exactly once and, on failure, prefers to drop it rather than risk a duplicate; at-least-once guarantees a message is not lost but hands out duplicates when retries happen; exactly-once is the ideal that requires an idempotent consumer or a transactional handshake to actually hold. Most people do not set this directly; they inherit it from the broker, the consumer, and how they acknowledge.

The real spot the guarantee shows is the acknowledgment. A consumer that acknowledges after writing makes a small window where a crash can redeliver a message, which is how an “at-least-once” queue is born. A consumer that drops a message silently on processing failure turns the queue into “at most once”. Understanding where the acknowledgment sits is the difference between the guarantee your queue documents and the one it actually delivers.

Idempotency makes at-least-once safe

At-least-once reads scary in theory and is common in practice, because its risk is manageable when the consumer is idempotent. The reason a duplicate is harmless is a consumer that can apply the same work twice and still produce the same result: a payment that is keyed by a stable id, an insert that is naturally deduplicated by a unique constraint. Building idempotency into the consumer turns the duplicate that at-least-once can cause into a non-event.

Idempotency is not a magic property; it is an explicit design choice at the function level. The classic trick is to store the message id alongside a unique key, so a retry hits a constraint instead of re-processing. When a job has expensive side effects, wrap the whole unit in a transaction that includes the dedup marker, and ensure the retry, not the process, is what decides the final outcome. Judged across a real failure, the cost of an occasional duplicate is cheaper than the cost of a lost message.

Pick by what the job allows

The right pick depends on what the job tolerates, not on what sounds most reliable. A command that charges a user wants the message to never be lost, so at-least-once plus idempotency fits; a telemetry counter where a rare drop is acceptable may prefer at-most-once to avoid unbounded duplicates and storage growth. Exactly-once is not usually real without extra machinery, so treat it as a behavior you assemble from at-least-once plus an idempotent consumer plus a durable dedup store.

The checklist to settle the choice: does a lost message cost more than a duplicated one, can the consumer be made idempotent cheaply, and what does the broker actually preserve on restart? Write the chosen guarantee into the code docs, and add a test that replays the safe duplicate to prove the consumer tolerates it. The guarantee is a contract with the system, not a label you can assume.

When it breaks

A double charge bills one order twice while the broker reports no error. The cause is a crash between applying the message and sending the acknowledgment, so the broker redelivers work already finished. Join the ledger against message ids: identical ids on two charges mean redelivery, distinct ids mean the producer sent twice.

A second pattern is a stuck consumer where throughput falls to zero and one offset retries forever. That is a poison message that always raises, paired with unbounded retry and no dead-letter queue. It differs from a redelivery storm, where throughput stays high but the same batch reappears because acknowledgments are lost or the visibility timeout is shorter than processing. One message with a huge redelivery count means poison; many messages with climbing counts mean lost acknowledgments.

The slower failure is dedup expiry: duplicates take effect again after months of quiet because the window aged out or the table was truncated.

Check it quickly

Save the following as delivery_guarantee_probe.py and run python3 delivery_guarantee_probe.py.

def main():
    inbox = [("pay-1", 100), ("pay-2", 50)]
    deliveries = inbox + [inbox[0]]  # lost ack redelivers pay-1 once
    naive = list(deliveries)  # non-idempotent: bills every delivery
    seen = set()
    ledger = []
    for msg_id, amount in deliveries:
        if msg_id not in seen:
            seen.add(msg_id)
            ledger.append((msg_id, amount))
    assert len(naive) == 3, naive  # duplicate was billed twice
    assert sum(a for _, a in ledger) == 150, ledger  # idempotent bills pay-1 once
    assert seen == {"pay-1", "pay-2"}, seen  # dedup holds one entry per id
    print("PASS: naive=3 idempotent=2 dedup=2")


if __name__ == "__main__":
    main()

The asserts prove the naive consumer bills the redelivery twice while the idempotent consumer bills it once and the dedup set holds two ids. Expect PASS: naive=3 idempotent=2 dedup=2.

Further reading

Sources