FjellSystem Design Lab
Lesson 7.2• 8 min read

Delivery guarantees, ordering and idempotency

At-most-once, at-least-once, exactly-once semantics, and deduplication with idempotency keys.

What you will learn

  • Tell at-most-once, at-least-once and exactly-once apart, and why exactly-once is about processing, not delivery.
  • Make a consumer idempotent by recording message IDs in the same transaction as its work.
  • Keep order where it matters by partitioning on an entity key.

The spectrum of distributed delivery semantics

In any distributed network where nodes, switches, and processes can fail, message transport guarantees fall into three categories:

  1. At most once: Messages may be lost but are never redelivered.
    • For example, the producer does not wait for the broker to confirm, or the consumer records its position before doing the work. If something fails at the wrong moment, the message is lost.
  2. At least once: Messages are never lost but may be redelivered.
    • The broker keeps the message until the consumer confirms it has been processed; if that confirmation is lost or times out, the message is delivered again, so the work can happen twice.
  3. Exactly once: Each message is processed once and only once.
    • A sender cannot tell a lost message from a lost acknowledgment, so over an unreliable network it must either risk losing the message (at most once) or risk sending it twice (at least once). "Exactly once" is therefore achieved in processing, not delivery.
    • Inside Kafka this is supported by an idempotent producer and transactions that write to several partitions atomically. When the output goes to an external system, the consumer must store its position in the same place as its output (or make the processing idempotent), so a retry cannot apply the same message twice.

Designing idempotent consumers with idempotency keys

An operation is idempotent if applying it multiple times produces the exact same system state as applying it once:

f(f(x))=f(x)f(f(x)) = f(x)

Implementation: record processed message IDs in the same transaction

The reliable guard is the database itself. Record each message's ID in a table whose primary key is that ID, inside the same transaction as the business update. If the message was already processed, the insert fails and the whole transaction rolls back, so the update is never applied twice:

async function processPaymentEvent(event: PaymentEvent) {
  const { messageId, userId, amount } = event;
  try {
    await db.transaction(async (tx) => {
      // Fails with a unique-key violation if this message was already processed.
      await tx.insert('processed_messages', { message_id: messageId });
      await tx.update('accounts').where({ id: userId }).decrement('balance', amount);
    });
  } catch (err) {
    if (isUniqueViolation(err)) return { status: 'DUPLICATE_SKIPPED' }; // already done: ignore
    throw err; // a real failure: let the broker redeliver
  }
  return { status: 'PROCESSED' };
}

A fast check in Redis (SET key value NX) can sit in front of this, but it must not be the only guard: if the consumer crashes after setting the Redis key and before the database commit, every retry would be skipped and the payment never applied.


Message ordering hazards: Global vs partition ordering

A frequent misconception in system design is requiring global FIFO ordering across all system events. Global FIFO requires a single monolithic queue with concurrency N=1N=1, causing an extreme performance bottleneck.

  • Solution: Enforce causal ordering by entity key. A customer's banking actions (Deposit →\rightarrow Transfer →\rightarrow Balance Check) must execute sequentially. But Customer A's actions are completely independent of Customer B's actions.
  • By setting PartitionKey = userId, all events for a single user land on the same partition and process in order, while many partitions are processed in parallel across the worker cluster.

Delivery guarantees, retries and idempotency keys

Idempotent processing (industry standard): At-least-once transport combined with consumer deduplication using UUID idempotency keys. Re-delivered messages detect the existing key, skip DB side effects, and safely return the cached response.
Consumer account balance
$1000.00
Committed transactions (2)
Order payment #9021
Key: idemp_txn_101
-$50.00
10:14:02
Order payment #9022
Key: idemp_txn_102
-$120.00
10:14:15
Execution trace & network timeline
System ready. Consumer listening on topic "payments.charges.v1"
Figure 2: Idempotent consumer architecture utilizing Redis TTL deduplication ledger.
  • Payment API Producer: 2 × App Compute Server
  • Durable Event Queue: 2 × Message Queue / Kafka
  • Billing Consumer Fleet: 4 × App Compute Server
  • Redis Idempotency Store: 2 × Redis In-Memory Cache
  • Financial Ledger DB: 2 × Primary Relational DB
  • Payment API Producer sends requests to Durable Event Queue
  • Durable Event Queue sends requests to Billing Consumer Fleet
  • Billing Consumer Fleet sends requests to Redis Idempotency Store
  • Billing Consumer Fleet sends requests to Financial Ledger DB

Key takeaways

  • A sender cannot tell a lost message from a lost acknowledgment, so it must risk losing messages or sending them twice.
  • With at-least-once delivery, an idempotent consumer makes redelivery harmless: the insert of a seen ID fails and the transaction rolls back.
  • A Redis check can speed this up but must not be the only guard, or a crash between the check and the commit skips the work for good.
  • Global ordering forces one consumer; order by entity key (for example userId) and process many partitions in parallel.

Sources

Adapted from The System Design Primer by Donne Martin, licensed under CC BY 4.0. Changes were made.