Skip to solution
hardSystem Design

How do you guarantee idempotency in a queue consumer?

364 views
01

Understand the problem

Question presented to candidate: "A payment-processing message gets delivered to your queue consumer twice — the queue's broker genuinely redelivered it, maybe because an earlier acknowledgment was lost on the network. What happens to the customer's account, and how do you make sure it's not charged twice?"

What a strong answer should cover:

  • Most real message brokers (SQS, RabbitMQ, Kafka) offer at-least-once delivery, not exactly-once — meaning the exact scenario in the prompt (the identical message delivered twice) is a real, expected occurrence in production, not a rare edge case to shrug off.
  • 📌 Verified, not assumed: a "naive" consumer with no deduplication, receiving the identical message object twice, genuinely double-charged a simulated account — a real balance of 100 dropped to a real 60 instead of the correct 80. An idempotent consumer, receiving the identical message twice, correctly charged the account exactly once — a real 100 became a real 80, the duplicate correctly and visibly skipped.
  • The core mechanism: track a unique message/idempotency key (a message ID the producer includes, or a deterministic hash of the message's meaningful content) in a durable store the consumer checks before applying the message's side effect — if the key has already been processed, skip the side effect entirely (verified directly: the exact log line "SKIPPED duplicate delivery").
  • A precise answer names where that idempotency-key store must live for the guarantee to actually hold under real failure conditions: an in-process Set (as used to demonstrate the mechanism here) only protects against duplicates arriving while that one process is alive — a durable, shared store (a database unique constraint, Redis) is required so the guarantee survives the consumer process restarting, or a duplicate being routed to a different consumer instance entirely.
  • The honest trade-off: idempotency does not mean "the message is only delivered once" (that is the broker's delivery guarantee, and at-least-once is what most brokers actually offer) — it means "processing the same message more than once has the same effect as processing it once," which is a property the consumer's own code is responsible for, not something the broker provides automatically.

Clarifying questions expected:

  • "Does the message carry a stable, unique ID from the producer, or does one need to be derived from its content?" — decides how the idempotency key is actually constructed.
  • "Must the idempotency guarantee survive the consumer process restarting, or is duplicate delivery only a concern within a single process's lifetime?" — directly decides whether an in-process Set is sufficient or a durable external store is required.

Code / implementation expected: Yes — the real, measured naive-vs-idempotent balance comparison (a genuine double-charge bug vs. a genuine correct single charge) is the single most convincing, concrete proof of why this matters and how the fix actually works.

nodejsqueueidempotencyreliability
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Node.js system-design and messaging interviews — assumes familiarity with the background-job-queue question's real decoupling/retry proof. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview te

Solution ready — 2 min read

Classified // press E to declassify

04

Read the code

Real double-charge bug (naive consumer) vs. real correct single charge (idempotent consumer), same duplicate message
let accountBalance = 100;
const processedMessageIds = new Set();

function chargeAccount(amount) { accountBalance -= amount; return accountBalance; }

function naiveConsume(message) {
  const newBalance = chargeAccount(message.amount);
  console.log(`[naive] processed messageId=${message.id}, balance now ${newBalance}`);
}

function idempotentConsume(message) {
  if (processedMessageIds.has(message.id)) {
    console.log(`[idempotent] SKIPPED duplicate delivery of messageId=${message.id}, balance stays ${accountBalance}`);
    return;
  }
  processedMessageIds.add(message.id);
  const newBalance = chargeAccount(message.amount);
  console.log(`[idempotent] processed messageId=${message.id}, balance now ${newBalance}`);
}

const message = { id: "msg-9", amount: 20 };

accountBalance = 100;
naiveConsume(message);
naiveConsume(message); // genuine redelivery
console.log("naive final balance:", accountBalance); // 60 — WRONG

accountBalance = 100;
processedMessageIds.clear();
idempotentConsume(message);
idempotentConsume(message); // genuine redelivery, correctly skipped
console.log("idempotent final balance:", accountBalance); // 80 — CORRECT

// naive final balance: 60 (charged twice for one message)
// idempotent final balance: 80 (charged exactly once)
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 145 of 152 decoded in the Node.js track. One more won't hurt.

Back to track