Designing Idempotent .NET RabbitMQ Consumers Under At-Least-Once Delivery

RabbitMQ consumers are easy to demonstrate and deceptively difficult to operate correctly.

A basic example typically creates a connection, declares a queue, subscribes to messages, and acknowledges each delivery after handling it. That code can be perfectly valid while still causing duplicate payments, repeated emails, inconsistent projections, inventory corruption, or hard-to-debug production incidents.

The underlying reason is simple:

In a distributed system, successful processing and successful message acknowledgement are not one atomic operation.

RabbitMQ commonly provides at-least-once delivery semantics. That is usually the correct trade-off: a message is not silently lost when a consumer crashes, loses its connection, or fails before confirming the delivery. The consequence is that consumers must be prepared to receive a logically identical message more than once.

This article explains how to design .NET RabbitMQ consumers that remain correct under duplicate delivery, retries, consumer crashes, redeliveries, transient infrastructure failures, poison messages, concurrent consumption, and external side effects.

The goal is not to claim “exactly once” processing. In most real systems, that claim is either incomplete or false.

Make duplicate delivery harmless, visible, and operationally manageable.

The failure window that creates duplicates

Consider a consumer handling an OrderPaid event:

  1. RabbitMQ delivers OrderPaid.
  2. The consumer writes a row to the database.
  3. The consumer sends a manual acknowledgement to RabbitMQ.
  4. RabbitMQ removes the message from the queue.

Now consider a failure between steps 2 and 3.

RabbitMQ -> Consumer -> Database commit -> Consumer crashes
                                      \
                                       Acknowledgement never reaches RabbitMQ

When the connection closes, RabbitMQ requeues the unacknowledged message. Another consumer—or the restarted process—receives the same event again.

The database write has already happened. The message is delivered again anyway.

The reverse ordering is worse:

  1. The consumer acknowledges the message.
  2. The consumer writes to the database.
  3. The process crashes before the database transaction commits.

The message is now gone, but the business operation never happened.

Acknowledgement first:
RabbitMQ -> Consumer -> ACK succeeds -> Process crashes -> Database write lost

For durable business workflows, acknowledgement must occur after successful processing. This protects against message loss, but it necessarily permits duplicate delivery.

That is not a RabbitMQ flaw. It is a fundamental distributed-systems trade-off.

“Exactly once” is usually an incomplete promise

A message broker can track whether it redelivered a message. A database can enforce a unique constraint. A payment provider can accept an idempotency key.

None of those guarantees exactly-once behavior across every system boundary by themselves.

For example, imagine this workflow:

Message received
  -> Database transaction committed
  -> HTTP request sent to payment provider
  -> Consumer crashes before recording success

On retry, the consumer cannot know whether the remote provider received and completed the request unless the external operation itself supports idempotency.

A more precise vocabulary is useful:

TermMeaning
At-most-onceA message may be lost, but is not intentionally retried.
At-least-onceA message is retried until acknowledged; duplicates are possible.
Effectively-onceDuplicates may arrive, but the observable business effect is applied once.
Exactly-onceEvery effect occurs once across all participating systems. Rare, expensive, and frequently overstated.

For most application consumers, effectively-once business behavior is the achievable and useful objective.

Define idempotency in business terms

A consumer is idempotent when processing the same logical message repeatedly produces the same valid business outcome as processing it once.

This definition is intentionally broader than “avoid duplicate inserts.”

Naturally idempotent operations

Some operations can be expressed as state assignment:

customer.IsVerified = true;

Processing the same verification event ten times still leaves IsVerified as true.

Likewise:

UPDATE orders
SET status = 'Paid'
WHERE id = @OrderId
  AND status <> 'Paid';

This can be safe when the state transition is valid and no other side effect is triggered.

Non-idempotent operations

Other operations accumulate effects:

account.CreditBalance += message.Amount;

or:

INSERT INTO loyalty_points (customer_id, points)
VALUES (@CustomerId, @Points);

Those operations are unsafe under duplicate delivery unless they are guarded by a durable deduplication mechanism.

Business identity versus broker delivery identity

RabbitMQ delivery tags are scoped to a channel. They are not stable business identifiers and must not be used as a durable idempotency key.

A message needs a stable, producer-generated identity:

{
  "messageId": "018f8b12-0e9b-79e6-a697-7234da683bd6",
  "eventType": "billing.invoice-paid.v1",
  "occurredAtUtc": "2026-09-10T13:00:00Z",
  "payload": {
    "invoiceId": "inv_10394",
    "customerId": "cus_832",
    "amount": 149.00,
    "currency": "USD"
  }
}

A robust message envelope should normally include messageId, eventType, occurredAtUtc, correlationId, causationId, schemaVersion, and the business payload.

If producers cannot reliably provide a unique messageId, derive an idempotency key from immutable business facts—for example invoiceId + "paid"—but only where that truly represents one business event.

The transactional inbox pattern

The most broadly useful solution is a transactional inbox.

The consumer stores the message identity in the same database transaction as the business changes it performs. A uniqueness constraint guarantees that only one transaction can claim a given message for that consumer.

RabbitMQ delivery
      |
      v
Database transaction
  1. Insert message identity into inbox
  2. Apply business state changes
  3. Insert outgoing events into outbox, if necessary
      |
      v
Commit transaction
      |
      v
Acknowledge RabbitMQ delivery

If processing fails before commit, both the inbox entry and business changes roll back. RabbitMQ can safely redeliver the message.

If the process crashes after commit but before acknowledgement, RabbitMQ redelivers it. The next consumer sees the inbox entry and treats the message as already handled.

Inbox schema

A simple PostgreSQL inbox table can look like this:

CREATE TABLE consumer_inbox (
    consumer_name       text        NOT NULL,
    message_id          uuid        NOT NULL,
    event_type          text        NOT NULL,
    correlation_id      text        NULL,
    received_at_utc     timestamptz NOT NULL DEFAULT now(),
    processed_at_utc    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT pk_consumer_inbox
        PRIMARY KEY (consumer_name, message_id)
);

The composite key matters. A message can legitimately be processed by different consumers. BillingProjectionConsumer and CustomerNotificationConsumer should each have their own deduplication boundary.

The correct identity is therefore usually consumer name + message ID, not only message ID.

Retention policy

An inbox table grows continuously. Deleting data too aggressively reintroduces duplicate-processing risk; retaining everything forever increases storage and index costs.

Retention should be derived from actual operational behavior:

maximum broker retention
+ maximum retry delay
+ disaster-recovery replay window
+ safety margin

For many systems, 30 to 90 days is reasonable. Financial, audit-heavy, or event-replay systems may need longer retention.

Do not schedule inbox cleanup before understanding replay procedures. A six-month-old event replayed after a migration can become dangerous if the deduplication history was deleted after seven days.

A production-oriented .NET implementation

The following example shows the key transactional structure. It uses PostgreSQL as the durable idempotency store, but the principle is identical for SQL Server, MySQL, or another transactional database.

using System.Text.Json;
using Npgsql;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

public sealed class InvoicePaidConsumer
{
    private const string ConsumerName = "billing.invoice-paid.v1";

    private readonly NpgsqlDataSource _dataSource;
    private readonly ILogger<InvoicePaidConsumer> _logger;

    public InvoicePaidConsumer(
        NpgsqlDataSource dataSource,
        ILogger<InvoicePaidConsumer> logger)
    {
        _dataSource = dataSource;
        _logger = logger;
    }

    public async Task HandleAsync(
        IChannel channel,
        BasicDeliverEventArgs delivery,
        CancellationToken cancellationToken)
    {
        InvoicePaidEvent message;

        try
        {
            message = JsonSerializer.Deserialize<InvoicePaidEvent>(
                delivery.Body.Span,
                SerializerOptions)
                ?? throw new InvalidOperationException(
                    "InvoicePaid message body was empty.");
        }
        catch (JsonException exception)
        {
            _logger.LogError(
                exception,
                "Invalid JSON received on invoice-paid queue. Delivery tag: {DeliveryTag}",
                delivery.DeliveryTag);

            await RejectToDeadLetterQueueAsync(
                channel,
                delivery.DeliveryTag,
                cancellationToken);

            return;
        }

        try
        {
            var result = await ProcessTransactionallyAsync(
                message,
                cancellationToken);

            if (result == ProcessingResult.AlreadyProcessed)
            {
                _logger.LogInformation(
                    "Ignoring duplicate message {MessageId} for consumer {ConsumerName}.",
                    message.MessageId,
                    ConsumerName);
            }

            await channel.BasicAckAsync(
                delivery.DeliveryTag,
                multiple: false,
                cancellationToken);
        }
        catch (TransientProcessingException exception)
        {
            _logger.LogWarning(
                exception,
                "Transient failure while handling message {MessageId}. It will be retried.",
                message.MessageId);

            await RequeueAsync(
                channel,
                delivery.DeliveryTag,
                cancellationToken);
        }
        catch (Exception exception)
        {
            _logger.LogError(
                exception,
                "Permanent processing failure for message {MessageId}.",
                message.MessageId);

            await RejectToDeadLetterQueueAsync(
                channel,
                delivery.DeliveryTag,
                cancellationToken);
        }
    }

    private async Task<ProcessingResult> ProcessTransactionallyAsync(
        InvoicePaidEvent message,
        CancellationToken cancellationToken)
    {
        await using var connection =
            await _dataSource.OpenConnectionAsync(cancellationToken);

        await using var transaction =
            await connection.BeginTransactionAsync(cancellationToken);

        var inserted = await TryInsertInboxRecordAsync(
            connection,
            transaction,
            message,
            cancellationToken);

        if (!inserted)
        {
            await transaction.RollbackAsync(cancellationToken);
            return ProcessingResult.AlreadyProcessed;
        }

        await MarkInvoiceAsPaidAsync(
            connection,
            transaction,
            message,
            cancellationToken);

        await InsertOutboxEventAsync(
            connection,
            transaction,
            new InvoicePaymentRecordedEvent(
                MessageId: Guid.NewGuid(),
                InvoiceId: message.InvoiceId,
                PaidAtUtc: message.PaidAtUtc),
            cancellationToken);

        await transaction.CommitAsync(cancellationToken);

        return ProcessingResult.Processed;
    }
}

The essential property is not the exact library API. It is the transaction boundary:

inbox insert + domain mutation + outbox insert = one database transaction

The RabbitMQ acknowledgement occurs only after that transaction commits.

Why the uniqueness constraint is non-negotiable

A common but unsafe implementation first queries the inbox:

var exists = await inboxRepository.ExistsAsync(message.MessageId);

if (!exists)
{
    await ProcessAsync(message);
    await inboxRepository.InsertAsync(message.MessageId);
}

Two consumers can execute this concurrently:

Consumer A: checks inbox -> not found
Consumer B: checks inbox -> not found
Consumer A: processes message
Consumer B: processes message

The bug is a classic time-of-check-to-time-of-use race.

The database must decide who owns the message. A unique index or primary key does exactly that. The insert attempt becomes the atomic claim operation.

Manual acknowledgements are mandatory for durable workflows

Auto-acknowledgement tells RabbitMQ that a message has been successfully handled as soon as it is delivered to the consumer.

That may be appropriate for telemetry, best-effort analytics, or disposable cache invalidation signals. It is not appropriate for workflows where data loss matters.

  • Disable automatic acknowledgements.
  • Acknowledge only after durable success.
  • Reject invalid or permanently unprocessable messages.
  • Route rejected messages to a dead-letter exchange.
  • Make redeliveries observable.

An acknowledgement is not a “received” signal. It is a durable statement that the consumer has completed all work it is responsible for doing for this message.

Retry topology: do not immediately requeue forever

A transient database timeout should be retried. A malformed message should not. A message that violates a business invariant may require human intervention.

Blindly calling BasicNack(requeue: true) on every error creates hot loops:

Consumer fails
  -> message requeued immediately
  -> same consumer receives it again
  -> fails again
  -> CPU usage rises, logs explode, useful work stalls

A better topology separates retryable delivery failures from terminal failures.

orders.events
    |
    v
orders.invoice-paid.queue
    |
    +-- transient failure --> orders.invoice-paid.retry.1m
    |                             |
    |                             +-- TTL expiry --> original queue
    |
    +-- retry exhausted --> orders.invoice-paid.dead-letter

A typical retry setup uses a primary queue, one or more retry queues with TTL, a dead-letter exchange, and a parking-lot queue for messages requiring investigation.

FailureRecommended action
Database timeout or temporary network issueRetry with delay
Dependency rate limitRetry with backoff, respecting Retry-After if available
Invalid JSONDead-letter immediately
Unknown schema versionDead-letter and alert
Validation failure caused by producer defectDead-letter and create an operational incident
External provider duplicate responseTreat as success if the idempotency contract confirms prior completion

The error taxonomy must be deliberate. Treating every exception as transient hides producer defects and creates noisy retry storms.

The outbox pattern protects downstream publication

The transactional inbox protects a consumer from duplicate input. It does not solve the next problem:

Database transaction commits
  -> Consumer publishes next event to RabbitMQ
  -> Process crashes before publish

The business state now changed, but downstream services never receive the event. Publishing first is not safe either, because downstream consumers may observe an event for a state change that did not persist.

The transactional outbox pattern resolves this by writing the outbound event to an outbox_messages table in the same transaction as the business state:

Transaction:
  - Record inbox message
  - Update domain state
  - Insert outbound event in outbox
Commit

Outbox publisher:
  - Reads unpublished outbox rows
  - Publishes with publisher confirms
  - Marks rows as published

The outbox publisher may itself publish a message more than once if it crashes after RabbitMQ accepts the publication but before the database records success. That is expected. Downstream consumers must still be idempotent.

Every consumer is idempotent, even when every producer uses an outbox.

External side effects need their own idempotency contract

Database transactions cannot atomically commit an HTTP request, a payment-provider call, an email send, or a file upload.

For external effects, use a provider-supported idempotency key whenever possible.

POST /payments
Idempotency-Key: 018f8b12-0e9b-79e6-a697-7234da683bd6

The key should be stable for the logical operation, not regenerated per retry.

Avoid embedding an unprotected external call in the middle of a consumer transaction. It increases transaction duration, holds database resources while waiting on networks, and leaves uncertain outcomes after timeouts.

Consumer concurrency and prefetch are correctness concerns

prefetch is frequently treated as a throughput-only tuning parameter. It also influences failure blast radius, fairness, memory pressure, and shutdown behavior.

Prefetch = 500
Consumer receives 500 messages
Consumer process crashes
RabbitMQ requeues 500 messages

That may be acceptable for fast, idempotent handlers. It is risky for expensive consumers with slow database transactions or rate-limited external dependencies.

Start with a modest prefetch value and measure:

prefetch ≈ consumer concurrency × expected work in flight

If a process has eight workers and each message requires a database transaction plus a small amount of CPU work, a prefetch between 16 and 64 is a reasonable point to test—not a universal answer.

RabbitMQ queues are ordered, but concurrent consumers and retries alter observed processing order. If events for the same aggregate must be processed sequentially, partition by aggregate key, enforce optimistic concurrency, version events per aggregate, or design handlers to be commutative where possible.

Graceful shutdown prevents unnecessary redelivery

A consumer should stop receiving new work before closing its connection, then allow in-flight work to finish within a bounded timeout.

  1. Mark the consumer instance as draining.
  2. Cancel consumption or stop accepting new deliveries.
  3. Wait for in-flight handlers to finish.
  4. Acknowledge successfully completed deliveries.
  5. Close the channel and connection.
  6. Allow uncompleted deliveries to be redelivered.

For ASP.NET Core hosted services, connect this flow to the application cancellation token. Kubernetes termination grace periods must be longer than the worst-case expected in-flight processing time, including database retry behavior.

Observability: duplicates should be measurable, not surprising

A reliable consumer is not only correct. It is diagnosable during an incident.

At minimum, log these fields consistently:

message_id
event_type
consumer_name
correlation_id
causation_id
rabbitmq_redelivered
retry_attempt
queue_name
processing_duration_ms
processing_result
MetricWhy it matters
consumer_messages_processed_totalThroughput and success/failure totals
consumer_duplicates_totalDetects redelivery patterns and publisher issues
consumer_processing_duration_secondsReveals latency changes and capacity constraints
consumer_retry_totalDetects transient dependency instability
consumer_dead_letter_totalRequires operational attention
rabbitmq_queue_messages_readyMeasures backlog
rabbitmq_queue_messages_unacknowledgedMeasures in-flight work
rabbitmq_queue_oldest_message_age_secondsMore useful than count for freshness-sensitive systems
outbox_pending_messagesDetects downstream publishing problems
outbox_oldest_unpublished_age_secondsAlerts before integration delays become incidents

Useful alerts include dead-letter messages greater than zero for critical workflows, oldest message age exceeding the service-level objective, retry rate increasing beyond baseline, duplicate rate spikes, outbox age growth, no successful consumption for a critical queue, and queue depth growing while consumer replicas are healthy.

Message contracts must evolve safely

Consumers should not assume event contracts remain static forever.

Prefer versioned event types:

billing.invoice-paid.v1
billing.invoice-paid.v2

Additive changes are usually safer. Removing or changing the meaning of existing fields is much more dangerous.

  1. Publish a new event version.
  2. Support both versions during migration.
  3. Move producers and consumers deliberately.
  4. Monitor old-version traffic.
  5. Retire the previous version only after consumers no longer need it.

An event is a contract between independently deployable systems. Treat it with the same discipline as a public API.

A production readiness checklist

Delivery and idempotency

  • The producer supplies a stable messageId.
  • The consumer uses manual acknowledgements.
  • Acknowledgement happens only after durable processing succeeds.
  • Duplicate delivery is safe by design.
  • The database enforces deduplication with a unique constraint.
  • Inbox records and business changes are committed atomically.
  • Inbox data retention covers replay and recovery windows.

Failure handling

  • Transient and permanent errors are classified separately.
  • Retries use delayed backoff rather than immediate hot-loop requeues.
  • Invalid payloads are dead-lettered.
  • Dead-letter queues are monitored and have an investigation process.
  • Poison-message handling does not silently discard business data.

Downstream consistency

  • Outbound events use a transactional outbox.
  • Outbox publishing uses RabbitMQ publisher confirms.
  • External providers receive stable idempotency keys where supported.
  • Every downstream consumer is independently idempotent.

Operations

  • Logs include message and correlation identifiers.
  • Metrics cover throughput, duplicates, retries, dead letters, lag, and outbox age.
  • Alerts are based on age, failure trends, and business criticality.
  • Shutdown drains in-flight work safely.
  • Prefetch and concurrency were measured under realistic load.
  • Event schema evolution has a compatibility strategy.

Final thoughts

At-least-once delivery is not a problem to eliminate. It is a property to design around.

The dangerous consumer is not the one that receives duplicates; duplicates are inevitable under realistic failures. The dangerous consumer is the one that treats duplicate delivery as impossible.

A production-grade .NET RabbitMQ consumer should assume every message can arrive more than once, durably claim each message through an inbox uniqueness constraint, process business state within the same transaction, acknowledge only after commit, publish downstream work through an outbox, retry transient failures with controlled backoff, dead-letter permanent failures visibly, and make every stage observable.

When these principles are implemented consistently, consumer crashes, retries, deployment interruptions, connection failures, and broker redeliveries stop being exceptional edge cases. They become normal, recoverable parts of the system’s operating model.

Yorumlar

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir