Exactly-Once Illusions: Design At-Least-Once Plus Truly Idempotent Handlers

Exactly-Once Illusions: Design At-Least-Once Plus Truly Idempotent Handlers
6 views
Brokers love the phrase “exactly-once.” Production failure modes do not. Network timeouts after a commit, replay from DLQs, and dual writes across systems recreate duplicates no matter what the marketing slide claimed. Seniors assume at-least-once everywhere and make handlers truly idempotent with durable keys and side-effect fencing.
⚡ TL;DR: Treat every delivery as a potential duplicate. Persist an idempotency record before side effects (or in the same transaction). Make effects themselves keyable (refunds, emails, ledger posts). Pair with Idempotency Keys End-to-End and Lambda Powertools Idempotency.

Exactly-Once Illusions: production guidance

text
1. Handler commits DB work, crashes before ACK → broker redelivers
2. ACK lost on the wire → redelivery of already-processed message
3. DLQ replay after a bugfix → intentional duplicates
4. Two consumers in a "exclusive" group during rebalance overlap
5. Upstream producer retries after 503 with a new broker message id
❌ Deduping only on broker offset — producer retries mint new offsets for the same business event.

Idempotency record pattern

typescript
// consume/idempotent.ts
export async function handleOnce(eventId: string, fn: () => Promise<void>) {
 try {
 await ddb.put({
 Item: { pk: `idemp#${eventId}`, sk: "v1", status: "in_progress", at: Date.now() },
 ConditionExpression: "attribute_not_exists(pk)",
 });
 } catch (e: any) {
 if (e.name === "ConditionalCheckFailedException") {
 const row = await ddb.get({ Key: { pk: `idemp#${eventId}`, sk: "v1" } });
 if (row.Item?.status === "done") return; // ✅ safe replay
 throw new Error("in_progress_elsewhere");
 }
 throw e;
 }
 try {
 await fn();
 await ddb.update({
 Key: { pk: `idemp#${eventId}`, sk: "v1" },
 UpdateExpression: "SET #s = :d",
 ExpressionAttributeNames: { "#s": "status" },
 ExpressionAttributeValues: { ":d": "done" },
 });
 } catch (err) {
 await ddb.delete({ Key: { pk: `idemp#${eventId}`, sk: "v1" } }); // allow retry
 throw err;
 }
}
Prefer transactional outbox: write business row + idempotency marker atomically when the store allows.

Side effects must accept keys too

Effect Idempotent approach
Charge card Processor idempotency key = eventId
Send email Dedup table on (template, eventId)
Ledger post Unique constraint on eventId
S3 write Deterministic object key
Start Step Functions name = hashed eventId
python
# effects/email.py
def send_receipt(event_id: str, to: str, body: str):
 try:
 table.put_item(
 Item={"pk": f"email#{event_id}", "to": to},
 ConditionExpression="attribute_not_exists(pk)",
 )
 except ClientError as e:
 if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
 return # ✅ already sent
 raise
 ses.send_email(to=to, body=body)

Testing duplicates on purpose

typescript
test("handler survives duplicate delivery", async () => {
 const evt = samplePaymentEvent();
 await handleOnce(evt.id, () => applyPayment(evt));
 await handleOnce(evt.id, () => applyPayment(evt)); // second time no-op
 expect(await ledger.count(evt.id)).toBe(1);
});
Wire this into CI alongside Hypothesis for Distributed Systems style reordering tests when you can.

Closing checklist

✅ Dos – ✅ Assume at-least-once for every bus and queue – ✅ Key on business event id, not broker offset alone – ✅ Persist idempotency before external side effects – ✅ Make downstream APIs accept idempotency keys – ✅ Replay duplicates in automated tests ❌ Don’ts – ❌ Don’t trust broker “EOS” as end-to-end exactly-once – ❌ Don’t ACK before durable side effects complete – ❌ Don’t use random UUIDs for effect keys on retry – ❌ Don’t treat DLQ replay as a rare manual exception – ❌ Don’t confuse batch de-dup with single-message safety

Related reading

Last updated on September 11, 2026 Related: DynamoDB Single-Table Design: The Pattern That Scales to Billions of Items; Design a Production Rate Limiter: Algorithms Seniors Actually Use in Interviews and at Work; Designing a URL Shortener That Handles 100 Million Requests Per Day.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a comment

No account needed. Name and email are optional.