Message-based communication is how loosely coupled services stay loosely coupled — it trades immediate consistency for availability and independence, and that trade only pays off if you get the unglamorous mechanics right. Interviewers lean on this topic specifically because it separates people who've read about pub-sub from people who've been paged at 2 a.m. because a poison message wedged a queue, or a retried request double-charged a customer. At the senior level, expect deep questions on what a broker actually guarantees, how to make a consumer safe against redelivery, how the outbox and inbox patterns close the gap between a database commit and a message send, and how ordering, dead-lettering and schema evolution hold together once dozens of services are publishing and consuming the same events.

Q1 Explain the difference between at-most-once, at-least-once and "exactly-once" delivery. Which does a typical broker default to, and why is true exactly-once so hard?#

Short answer: At-most-once delivers a message zero or one times and can silently drop it on failure. At-least-once redelivers whenever there's any doubt, so a message may arrive more than once but is never silently lost. "Exactly-once" processing across a network boundary doesn't really exist as a broker feature — what you actually get is at-least-once delivery plus deduplication, which behaves like exactly-once only if the consumer is idempotent. Most production brokers, including Azure Service Bus and RabbitMQ, default to at-least-once.

The reason exactly-once is fundamentally hard is the acknowledgment itself travels over the same unreliable network as everything else. If a consumer processes a message and then its acknowledgment is lost on the way back to the broker, the broker has no way to know the work already happened, so it redelivers — that's at-least-once. If instead the consumer acknowledges before finishing the work and then crashes, the message is gone and the work never completes — that's at-most-once. There is no third option that avoids ever redelivering and never losing a message when the acknowledgment itself can be lost; the only safe default is at-least-once, which pushes the "did I already do this" question onto the consumer.

Kafka's idempotent producer and transactional APIs get closer to "exactly-once semantics" than most systems, but that guarantee holds only within Kafka's own pipeline — producer to broker to consumer offset commit. The instant a consumer's handler has a side effect outside Kafka's transaction, such as writing to an external database or calling another service, that side effect is not covered by the guarantee, and you're back to needing an idempotent consumer for it.

What interviewers look for: understanding "exactly-once" as at-least-once delivery plus idempotency rather than a feature you switch on, and precision about what is and isn't covered by a broker's transactional boundary.

Common mistakes: claiming a broker "guarantees exactly-once" without qualifying what falls inside versus outside its transaction.

Q2 What makes a message consumer idempotent, and how do you implement it in practice?#

Short answer: An idempotent consumer produces the same end state no matter how many times the same message is delivered — processing it once or five times leaves the system identically correct. You get there either through an operation that's naturally idempotent, like an upsert keyed by a business ID, or by explicitly recording which message IDs have already been processed and skipping duplicates.

Some operations are idempotent by construction: "set the order status to Shipped" produces the same result no matter how many times it runs. Others aren't: "add $10 to the account balance" applied twice adds $20. For anything in the second category, the standard fix is a deduplication store — persist the message's unique ID in the same database transaction as the side effect it causes, and check for that ID before doing any work. If it's already there, acknowledge and return without repeating the effect. That check has to be atomic with the business write in a single transaction, not a separate call made before or after it, or you reintroduce the exact race the dedup check exists to close.

C#
public async Task HandleAsync(OrderPlaced message, CancellationToken ct)
{
    await using var tx = await _db.Database.BeginTransactionAsync(ct);

    bool alreadyProcessed = await _db.ProcessedMessages
        .AnyAsync(m => m.MessageId == message.MessageId, ct);
    if (alreadyProcessed)
    {
        await tx.CommitAsync(ct); // safe no-op — this message was already handled
        return;
    }

    _db.Shipments.Add(new Shipment(message.OrderId));
    _db.ProcessedMessages.Add(new ProcessedMessage(message.MessageId, DateTimeOffset.UtcNow));

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}

What interviewers look for: distinguishing naturally idempotent operations from ones that require an explicit dedup mechanism, and insisting the dedup check and the business write share one transaction.

Follow-up questions:

  • How would you expire old rows in an idempotency store without risking a very late redelivery being processed twice?
  • What happens if two instances of the same consumer process the same message concurrently?

Q3 Walk through the transactional outbox pattern. What does the inbox pattern add on the consumer side?#

Short answer: The outbox pattern solves the dual-write problem: updating your database and publishing a message are two separate systems, and if the process crashes between them, you either lose the message or send it for a change that never committed. Instead, you write the message into an outbox table in the same local transaction as the business change, and a separate publisher reads that table and sends the messages — so a message exists if and only if the business change it describes actually committed. The inbox pattern applies the same idea on the receiving side: persist the incoming message's ID before processing so a redelivered copy is recognized and skipped, which is the idempotency-store pattern from the previous question under a different name.

On the publishing side, a background process — a poller, or a change-data-capture reader watching the transaction log — picks up unpublished outbox rows in order, sends them to the broker, and marks them published. That send can still fail or be duplicated, so the outbox guarantees the message is eventually sent at least once, not exactly once, which is exactly why the consumer still needs to be idempotent regardless of how solid the producer side is. Frameworks like MassTransit ship a built-in outbox integration that hooks into SaveChangesAsync so application code doesn't have to hand-roll the poller.

C#
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
    _db.Orders.Add(order);
    _db.OutboxMessages.Add(OutboxMessage.For(new OrderPlaced(order.Id, order.CustomerId, order.Total)));

    await _db.SaveChangesAsync(ct); // both rows commit together, or neither does
}

What interviewers look for: clarity that the outbox solves the dual-write problem specifically, still only delivers at-least-once, and does not remove the need for idempotent consumers.

Common mistakes: believing the outbox alone achieves exactly-once delivery; publishing directly from application code right after SaveChangesAsync because it usually works, which reintroduces the dual-write race the moment either call fails independently.

Q5 What is a poison message, and how should a consumer and the broker cooperate to handle one?#

Short answer: A poison message is one a consumer can never successfully process no matter how many times it's redelivered — a malformed payload, a reference to an entity that will never exist, a deserialization failure — and left alone it blocks the queue or burns retries indefinitely. The standard cooperation is a bounded retry count with backoff on the consumer side for genuinely transient failures, and a dead-letter queue on the broker side that automatically captures a message once it exceeds that count, so it stops blocking healthy traffic and becomes something a human or an automated process can triage.

The distinction that should drive the retry policy is transient versus permanent failure. A transient failure — a downstream dependency was briefly unavailable — deserves retry with backoff because it will probably succeed shortly. A permanent, poison failure — the payload itself can't be deserialized or references data that will never exist — makes retrying pure waste: it delays discovery and burns the same retry budget a transient fault would use. Where the failure type can be told apart (a deserialization exception is almost always permanent; an HttpRequestException from a downstream call is probably transient), the consumer should fail fast to the dead-letter queue instead of retrying blindly.

Having a dead-letter queue is only half the story — a DLQ nobody watches is just where messages go to be silently lost. Pair it with alerting on queue depth and age, and a defined process for what happens next: automatic replay once a known bug is fixed, manual triage for anything involving money or irreversible side effects.

What interviewers look for: the transient-versus-permanent distinction driving retry strategy, and a real answer for what happens to a message after it dead-letters, not just that a DLQ exists.

Common mistakes: applying the same retry policy to permanent and transient failures; treating the DLQ as a place messages disappear rather than a queue with its own operational process.

Q6 Explain the competing consumers pattern. How does it interact with ordering and with scaling a consumer group?#

Short answer: Competing consumers means multiple instances of the same consumer pulling from the same queue or partition set, so each message is handled by exactly one instance and throughput scales roughly linearly as you add consumers — up to the number of partitions or sessions, since a partitioned broker delivers each partition to only one consumer at a time. It directly trades off against the per-key ordering from the earlier question: you can add consumers to increase parallelism across keys, but you cannot add consumers beyond the partition count without breaking the "one consumer per key at a time" guarantee that ordering depends on.

Scaling a consumer group isn't free. Adding or removing an instance triggers a rebalance — partition ownership is reassigned, and processing briefly pauses while that happens — and any messages that were in flight to a consumer that just lost a partition (or crashed) get redelivered to whichever instance picks it up next, which is exactly why idempotent, resumable handlers matter as much for competing consumers as for any other redelivery scenario. For a plain queue with no partitioning, scaling out is simpler, but you lose per-entity ordering entirely unless you introduce your own partitioning scheme, such as session-enabled queues.

What interviewers look for: connecting competing consumers directly to the ordering trade-off, and awareness that scaling a consumer group has an operational cost — rebalancing — that isn't free.

Follow-up questions:

  • What happens to in-flight messages when a consumer instance crashes mid-processing?
  • How would you scale a topic beyond its current partition count without a full migration?

Q7 How do you version a message schema without breaking producers or consumers that haven't upgraded yet?#

Short answer: Treat the schema like a public API: changes within a version must be additive and backward compatible — new optional fields with sensible defaults, never removing or repurposing a field, never tightening a previously optional field to required — and reserve a new schema version, run in parallel with the old one through a deprecation window, for anything that genuinely can't be done that way.

The practical mechanics matter as much as the principle. Put a schema or version identifier on every message so a consumer can branch on it, or reject an unrecognized version explicitly, instead of failing unpredictably deep inside deserialization. Prefer a serialization format with real compatibility rules — protobuf's numbered, optional fields, or a JSON convention where unknown fields are ignored and missing fields fall back to defaults — over a format that breaks on any structural change. Where several teams both produce and consume the same event types, a shared, versioned schema registry or a shared package of typed contracts published from the producer's build catches an incompatible change at build time, before it reaches production as a runtime deserialization failure.

The trap worth naming explicitly is consumer-side over-strictness: a consumer that deserializes strictly and throws on any unrecognized field will break the moment the producer adds a harmless optional field, which defeats the entire purpose of additive evolution. Consumers should tolerate unknown fields by default and only fail on fields they actually depend on being absent or malformed.

What interviewers look for: the additive-only discipline paired with a concrete enforcement mechanism — a version field, a schema registry, or compatibility-checked serialization — rather than "we just try not to break things."

Common mistakes: a consumer that fails hard on unrecognized fields, turning every harmless producer change into a breaking one.

Q8 Compare a point-to-point queue and a publish-subscribe topic. When would a service choose each for the same event?#

Short answer: A queue delivers each message to exactly one consumer, or one consumer group under competing consumers — it's a work-distribution mechanism. A topic delivers each message independently to every subscriber — it's a broadcast mechanism. The same underlying event can go out over a topic while each interested service maintains its own queue-backed subscription underneath, which is how most pub-sub brokers implement multi-consumer delivery in practice, such as Azure Service Bus topics with a dedicated subscription queue per subscriber.

Choose a queue when there's one logical unit of work that should happen exactly once, load-balanced across workers — processing a single payment, resizing a single image. Choose a topic when multiple, independent, unrelated consumers each need to react to the same fact without the producer knowing or caring who they are: an OrderPlaced event might need to trigger inventory reservation, a confirmation email and an analytics pipeline, three consumers that shouldn't compete for the same message but should each get their own copy. Getting this backward has two distinct failure modes — publishing a unit of work on a fan-out topic causes every subscriber to redundantly redo it, while publishing a broadcast notification on a single competing queue means only one of several interested services ever gets to react.

What interviewers look for: the work-distribution-versus-broadcast distinction, applied concretely to which pattern fits the same event under different consumption needs.

Q9 How do you avoid duplicate side effects — double-charging a customer, for example — when a consumer crashes after doing the work but before acknowledging the message?#

Short answer: This is the idempotent-consumer problem at its highest stakes: the broker redelivers because it never saw an acknowledgment, so the only safe fix is making the side effect itself idempotent, or gating it behind an idempotency key checked and recorded atomically with the effect. For a payment specifically, that means the call to the payment provider itself carries an idempotency key, so a retried charge request is recognized and deduplicated by the provider — not just by your own database.

Two layers of defense combine well here. Your own inbox or dedup table stops the consumer from re-running the whole handler for a message it has already fully processed. An idempotency key passed to any external side-effecting call — most payment APIs, and many other third-party APIs, support this explicitly — stops a duplicate call from producing a duplicate effect even in the narrow window where your own dedup check already passed but the previous attempt's external call hadn't finished, for example if the process crashed after calling the payment provider but before writing the dedup record.

Resist the instinct to fix this by acknowledging the message before doing the risky work — that flips the failure mode from "possible duplicate" to "possible silent loss," which for a payment is almost always the worse outcome. The correct order is: perform the work with an idempotency key, record that it's done, then acknowledge, accepting that a crash between "work done" and "ack sent" causes a redelivery your idempotency key correctly absorbs.

What interviewers look for: naming the payment-provider-level idempotency key as the second line of defense, since your own database can't protect against a duplicate external call if the crash lands between the call and the record.

Follow-up questions:

  • What would you do if the third-party API you're calling doesn't support an idempotency key at all?

Q10 How would you design a dead-letter queue reprocessing workflow for production, including alerting and replay?#

Short answer: Treat the dead-letter queue as a triage queue, not a graveyard: alert on its depth and age, since a message sitting there for more than a few minutes is worth paging on for a critical flow; capture enough context on each dead-lettered message to diagnose it without reproducing the whole pipeline; and build an explicit replay path — automatic for known-transient causes once the underlying issue is fixed, manual and approved for anything that touched money or customer-facing state.

"Enough context" means the original message payload, the failure reason and exception, the delivery attempt count and timestamps, and ideally a correlation or trace ID that lets you pull the full distributed trace for that message's journey. Without that, triage devolves into guessing from a generic "deserialization failed" error with no way to reconstruct what actually went wrong or how widespread it is.

Replay itself should re-publish the message to its original queue, not through a special bypass path, so it goes through the normal idempotent-consumer logic and stays safe even if an earlier attempt partially processed it before crashing. Track replay attempts explicitly, too: a message that dead-letters again after a "fixed" replay is a strong signal the root cause wasn't actually fixed, and that should escalate rather than loop silently through the same retry-and-replay cycle.

What interviewers look for: a complete workflow — alerting, diagnosable context and a replay mechanism that still respects idempotency — rather than "we have a DLQ configured" as the whole answer.

Quick-Fire Round#

QuestionAnswer
What delivery guarantee do most production brokers default to?At-least-once.
What actually makes "exactly-once" work in practice?At-least-once delivery plus an idempotent consumer.
What problem does the outbox pattern solve?The dual-write problem between a database commit and a message publish.
What's the inbox pattern, in one line?The idempotency/dedup check applied on the consumer side.
What guarantees per-key ordering in a partitioned broker?Routing every message for the same key to the same partition or session.
What should happen once a message exceeds its retry count?It moves to a dead-letter queue instead of blocking the queue.
Queue or topic for a single unit of work that should run once?A queue, or a competing-consumers group.
Second line of defense against a duplicate payment charge?An idempotency key passed to the payment provider's API.

How to Prepare#

  • Be precise about what "exactly-once" actually means — at-least-once delivery plus idempotency — since interviewers use this to filter marketing language from real understanding.
  • Practice describing the outbox pattern's failure mode (dual write) and what it does not solve (still only at-least-once).
  • Have a clear answer for partition or session-key ordering, including the throughput trade-off it creates for a single hot key.
  • Know the transient-versus-permanent failure distinction and how it should change your retry policy.
  • Bring one real dead-letter-queue story: what ended up there, how you found out, and how you replayed it safely.
  • Be ready to design an idempotent consumer end to end, including exactly where the dedup check sits relative to the transaction boundary.