open atlas
↑ Back to track
NestJS, zero to senior NEST · 08 · 03

Message patterns and delivery semantics

Two message patterns (send/request-response vs emit/event) and the delivery guarantee that actually ships: at-least-once. Exactly-once delivery is a myth; you get effectively-once only by making the consumer idempotent. Dedup by key, DLQ poison messages.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The incident report had one line that made the on-call engineer’s stomach drop: “customer billed twice for the same order, 4 minutes apart.” There was no retry in the application code. Nobody clicked “pay” twice. What happened was quieter: the payments consumer processed a payment.charge event, called Stripe, and then — in the 30 milliseconds between charging the card and committing its acknowledgement back to the broker — the pod was rotated by a routine deploy. The ack never landed. The broker did exactly what it promised to do: it saw an un-acknowledged message and redelivered it. The new pod picked it up, saw a perfectly valid charge event, and charged the card again. The broker was not broken. The code was not “buggy” in any line you could point at. The whole system was behaving correctly — and the customer was still charged twice. This lesson is about why that happens to everyone eventually, and the three decisions that stop it: which message pattern you reach for, which delivery guarantee you actually have, and why idempotency is not optional.

Two patterns: a question vs a fact

Why does the pattern choice matter before you even pick a broker? Because the wrong one silently couples services in time — and that coupling is what turns a single downstream outage into a cascading failure. A Nest microservice client speaks in two registers, and choosing the wrong one is the first structural mistake. client.send(pattern, data) is request-response: it returns an Observable of a reply, and the caller waits for the handler (@MessagePattern) to compute and return an answer. client.emit(pattern, data) is event-based: it is fire-and-forget — the handler (@EventPattern) reacts, returns nothing, and the emitter does not wait.

// REQUEST-RESPONSE: you need an answer NOW, and you block on it
const total: number = await firstValueFrom(
  this.client.send('cart.total', { cartId }),   // ↔ @MessagePattern('cart.total')
);

// EVENT-BASED: you are announcing a fact; nobody is waited on
this.client.emit('order.placed', { orderId, total }); // ↔ @EventPattern('order.placed')

The senior rule is about coupling in time. Use send only when you genuinely need the answer to continue — a query whose result you’ll use on the next line. Use emit for “this happened” facts that other services react to on their own schedule. The trap is reaching for send everywhere because it feels like a function call: a chain of send → send → send across services quietly rebuilds a synchronous monolith on top of a message bus. Now you have all the operational cost of a distributed system — network latency stacked per hop, and cascading failure when any link is down — with none of the decoupling that justified splitting the services in the first place. emit is what lets the order service finish even when the email service is down.

Delivery semantics: the three guarantees, one of which is a lie

Every broker offers a delivery guarantee, and there are exactly three to know.

At-most-once is fire-and-forget with no redelivery: the message is sent, and if it’s lost in transit or the consumer dies before processing, it is simply gone. Fast, cheap, lossy. Acceptable for a metric tick; unacceptable for a payment.

At-least-once is the workhorse and the default for Kafka, NATS JetStream, and RabbitMQ: the broker keeps redelivering a message until the consumer acknowledges it. Nothing is ever lost — but the same message can be delivered more than once, because a redelivery fires whenever the broker isn’t sure the consumer finished.

Exactly-once is the one people ask for and the one you almost never actually get. True end-to-end exactly-once delivery across a network is, in the general case, impossible. What production systems actually build is at-least-once delivery + idempotent processing, which yields effectively-once: the message may arrive twice, but the second arrival has no additional effect. Say it precisely, because the imprecise version is what causes the double-charge: you do not get exactly-once delivery; you engineer exactly-once effect.

Together these three categories cover every broker you will encounter: at-most-once when loss is acceptable, at-least-once when loss is not, and effectively-once when the processing side absorbs duplicates with idempotency. Without that idempotency layer, at-least-once becomes “sometimes twice,” which is how the incident in the Hook happened.

Why this works

Why is end-to-end exactly-once delivery effectively impossible, and what do real systems do instead? Picture the consumer finishing the work and sending an ack back to the broker. The network can drop that ack on the way. Now the broker is stuck with a question it cannot answer: did the consumer process the message and the ack got lost, or did the consumer die before processing? Those two cases are indistinguishable from the broker’s side — the only signal it has is “no ack arrived.” It must choose a default behaviour for that ambiguity. If it assumes “processed” and moves on, it risks losing a message that was never actually handled. If it assumes “not processed” and redelivers, it risks duplicating a message that was. There is no third option that magically knows the truth, because the information needed to distinguish the cases was lost with the ack. So mature systems stop trying to make delivery exactly-once and instead choose at-least-once (never lose) and make the processing idempotent (dedup by a stable key), so a duplicate delivery produces no duplicate effect — effectively-once, not exactly-once-delivery.

Acknowledgements and the redelivery window

The duplicate is born in the gap between “work done” and “ack recorded.” The consumer does the work, then acknowledges: Kafka commits a consumer offset; NATS JetStream and RabbitMQ send an explicit ack. If the consumer crashes, or the ack is lost, before the broker records that acknowledgement, the broker redelivers — and the message is processed a second time.

This means the order of operations is the choice of which failure you accept, and there is no free option:

// ACK BEFORE processing → at-most-once in effect: a crash here LOSES the message
@EventPattern('payment.charge')
async handleBad(@Payload() data, @Ctx() ctx: KafkaContext) {
  await ctx.getConsumer().commitOffsets([/* ... */]); // committed first
  await this.charge(data); // if the pod dies here, the charge never happens, never retried
}

// ACK AFTER processing → at-least-once: a crash here DUPLICATES the message on redelivery
@EventPattern('payment.charge')
async handleGood(@Payload() data, @Ctx() ctx: KafkaContext) {
  await this.charge(data);                              // work first
  await ctx.getConsumer().commitOffsets([/* ... */]);  // crash before this → redelivered → charged twice
}

Acking before processing loses messages on a crash; acking after processing duplicates them on a crash. You cannot have neither. The mature systems pick ack-after (never lose) and pay for it with idempotency (never double-apply). Which is the whole point of the next section.

Idempotency: the fix, not a nicety

Because at-least-once will deliver duplicates, the consumer must be idempotent: processing the same message twice must produce the same end state as processing it once. This is not achieved by hoping; it is engineered with three moving parts:

  1. A stable idempotency key that identifies the logical operation — carried on the message, the same across redeliveries (e.g. paymentId, or a client-supplied key). Not a random per-attempt id, or every redelivery looks new.
  2. A dedup record of keys already processed — a processed_events table, checked first and written in the same transaction as the effect.
  3. A side effect that is safe to repeat — guarded so the second arrival is a no-op.
@EventPattern('payment.charge')
async handleCharge(@Payload() data: { paymentId: string; amount: number }) {
  await this.dataSource.transaction(async (manager) => {
    // claim the key; if it already exists, this insert affects 0 rows → already processed
    const res = await manager.query(
      `INSERT INTO processed_events (key) VALUES ($1) ON CONFLICT (key) DO NOTHING`,
      [data.paymentId],
    );
    if (res.rowCount === 0) return; // duplicate redelivery → skip the charge entirely
    await this.charge(data); // first time only; commits atomically with the dedup row
  });
}

That ON CONFLICT (key) DO NOTHING is the load-bearing line: the dedup row and the charge commit in one transaction, so a redelivery either finds the key already there (and skips) or claims it and charges — never both. This is what the double-charge incident was missing. The fix shipped that day was exactly this: a unique idempotency key on the charge plus a processed-events check before calling Stripe.

Poison messages and the dead-letter queue

At-least-once has a second, sharper edge. Suppose a message can never succeed — malformed payload, a schema mismatch, a bug that throws on this one record. The broker’s redelivery, which saves you from transient crashes, now works against you: it redelivers the poison message forever. Worse, in an ordered stream the poison sits at the head and blocks every message behind it — head-of-line blocking — while burning CPU on an infinite retry loop.

The answer is a dead-letter queue (DLQ): after N failed attempts (commonly 3–5 retries), stop retrying inline and route the message to a separate queue for inspection and manual or automated handling, so the main stream keeps flowing. The retry count is the dial — too low and you DLQ transient blips that would have succeeded; too high and a poison message stalls the partition for minutes.

One more guarantee to keep honest: ordering is per-partition only. Kafka guarantees order within a partition (chosen by the message key), JetStream within a subject stream — but across partitions/keys there is no global order. If two events must be applied in sequence, they must share a partition key; otherwise “we’ll process them in order” is a guarantee you don’t actually have.

Pick the best fit

A 'charge customer' message must never double-charge, even though the broker can redeliver it after a consumer restart. Which approach actually holds?

Quiz

When should you use client.send() versus client.emit() between Nest microservices?

Quiz

A payment.charge event is redelivered after a consumer pod restart, and the customer is charged twice. What is the root cause and the fix?

Recall before you leave
  1. 01
    Contrast request-response (send) with event-based (emit) messaging, and explain the senior rule for choosing — including the failure mode of overusing send.
  2. 02
    Explain the three delivery semantics, why exactly-once delivery is effectively impossible, and how at-least-once + idempotency + a DLQ together produce a safe consumer.
Recap

Nest microservices speak in two patterns: client.send(pattern, data) is request-response — it returns an Observable reply and the caller waits for the @MessagePattern handler, coupling the services in time — while client.emit(pattern, data) is fire-and-forget, handled by @EventPattern with no reply and no waiting. Use send only when you need the answer now; use emit for “this happened” facts, because a chain of send calls rebuilds a synchronous monolith with stacked latency and cascading failure. On delivery: at-most-once is lossy, at-least-once (the default for Kafka, NATS JetStream, RabbitMQ) never loses but CAN duplicate, and exactly-once delivery is a myth — when an ack is lost the broker cannot tell “processed” from “not processed”, so it must risk loss or duplication. The duplicate is born in the window between doing the work and recording the ack/offset: ack-before-processing loses on a crash, ack-after duplicates on a crash, so you choose ack-after and pay with idempotency. An idempotent consumer carries a stable idempotency key, claims it in a processed-events dedup table with INSERT … ON CONFLICT DO NOTHING in the same transaction as the effect, and makes the side effect safe to repeat — so a redelivery finds the key and skips, yielding effectively-once. That is exactly what the double-charge incident was missing. A poison message that always fails would be redelivered forever and head-of-line-block its partition, so after ~3–5 retries route it to a dead-letter queue; and ordering holds only per partition key, so events that must be sequenced must share a key. Now when you see a double-charge incident report, you know exactly what to look for: a missing idempotency key and an ack-after gap — and exactly what to ship to close it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.