RabbitMQ is widely used to build asynchronous applications and distributed systems. It allows services to communicate without requiring them to be available at the same time.
For example, an e-commerce application may publish an order-created message after a customer completes a purchase. A background consumer receives the message and performs operations such as updating inventory, generating an invoice, sending notifications, or initiating payment processing.
This architecture improves scalability and separates business operations into independent services.
However, there is an important challenge: RabbitMQ can deliver the same message more than once.
Imagine a consumer receives an order message, successfully updates the database, but crashes before acknowledging the message. RabbitMQ does not know that the database operation was completed. When the consumer disconnects, the unacknowledged message can be delivered again.
If the application does not handle duplicate processing, the same order might be processed twice.
This can lead to duplicate payments, repeated notifications, incorrect inventory updates, and inconsistent business records.
The solution is not simply to prevent RabbitMQ from delivering a message again. Reliable messaging systems must assume that duplicate delivery is possible and design consumers to handle it safely.
This article explains why duplicate processing happens, how RabbitMQ acknowledgements work, and how to build idempotent consumers using C#, database transactions, and message deduplication.
Why RabbitMQ Delivers Messages More Than Once
RabbitMQ supports message acknowledgements to determine whether a consumer has successfully processed a delivery.
When a consumer uses manual acknowledgements, it must explicitly acknowledge a message after processing it.
Consider this workflow:
A publisher sends an order-created message.
RabbitMQ stores the message in a queue.
A consumer receives the message.
The consumer updates the database.
The consumer crashes before sending an acknowledgement.
RabbitMQ detects that the consumer connection or channel has closed.
The unacknowledged message becomes available for redelivery.
Another consumer receives the same message.
The database operation may already have succeeded, but RabbitMQ has no confirmation of that business operation.
From the broker's perspective, the message was never acknowledged.
Example of duplicate processing
Consider an order processing service.
The publisher sends:
{
"messageId": "msg-1001",
"orderId": 5001,
"customerId": 2001,
"totalAmount": 2500.00,
"eventType": "OrderCreated"
}The consumer receives the message and updates the order status:
UPDATE orders
SET status = 'Processing'
WHERE order_id = 5001;The database successfully executes the update.
However, before the consumer acknowledges the message, the application crashes.
When the consumer reconnects, RabbitMQ can deliver the message again.
If the consumer performs the same operation without checking whether it has already been completed, duplicate business processing may occur.
This is one of the reasons RabbitMQ consumers should be designed to handle redeliveries.
Understanding RabbitMQ Delivery Guarantees
Before implementing duplicate protection, it is important to understand the delivery guarantees provided by RabbitMQ.
Messaging systems commonly discuss three delivery models.
Delivery model | Meaning | Main concern |
|---|---|---|
At-most-once | A message is delivered zero or one time | Messages may be lost |
At-least-once | A message is delivered one or more times until acknowledged or otherwise handled | Duplicate delivery is possible |
Exactly-once | A business operation produces one effective result despite retries or duplicate delivery | Requires coordination across system boundaries |
RabbitMQ supports reliable at-least-once processing patterns through durable messaging, acknowledgements, publisher confirms, and redelivery.
However, RabbitMQ does not automatically guarantee that a consumer's database operation or external API call executes exactly once.
For example, a message may be delivered twice even though the publisher sent it only once.
The application must therefore implement idempotency or deduplication.
Why exactly-once processing is difficult
Consider a consumer that performs two operations:
Updates a database.
Sends a payment request to an external payment provider.
These operations do not automatically share one transaction.
The database may successfully commit the update while the payment request times out.
The application cannot always determine whether the external provider processed the request.
Retrying blindly could create a second payment.
This is why exactly-once delivery and exactly-once business effects are different concepts.
A practical production design focuses on making repeated message processing safe.
Understanding Manual Acknowledgements
RabbitMQ supports automatic and manual acknowledgement modes.
Automatic acknowledgement
With automatic acknowledgement, RabbitMQ considers a message acknowledged when it delivers it to the consumer.
The consumer does not explicitly confirm that processing has completed.
This provides high delivery throughput, but it creates a risk of message loss.
For example:
RabbitMQ delivers message
|
v
RabbitMQ considers it acknowledged
|
v
Consumer starts processing
|
v
Consumer crashes
|
v
Message is not redeliveredFor important business operations, automatic acknowledgement is generally not appropriate unless message loss is acceptable.
Manual acknowledgement
With manual acknowledgement, the consumer explicitly acknowledges a message after successful processing.
The workflow becomes:
RabbitMQ delivers message
|
v
Consumer processes message
|
v
Database transaction commits
|
v
Consumer sends ACK
|
v
RabbitMQ removes messageIf the consumer crashes before the acknowledgement, RabbitMQ can redeliver the message.
This protects against losing messages when consumers fail, but it also means duplicate delivery must be handled.
In RabbitMQ's AMQP 0-9-1 protocol, the consumer acknowledgement must be sent on the same channel that delivered the message.
Implementing a Basic RabbitMQ Consumer in C#
The following example demonstrates manual acknowledgement using the RabbitMQ .NET client.
It illustrates the processing sequence. The database operation is represented by a placeholder and should be implemented using the application's database access layer.
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;
using System.Text.Json;
public class OrderConsumer
{
private readonly IConnection _connection;
public OrderConsumer(IConnection connection)
{
_connection = connection;
}
public async Task StartAsync()
{
var channel = await _connection.CreateChannelAsync();
await channel.BasicQosAsync(
prefetchSize: 0,
prefetchCount: 10,
global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, eventArgs) =>
{
try
{
var json = Encoding.UTF8.GetString(
eventArgs.Body.ToArray());
var order = JsonSerializer.Deserialize<OrderCreatedEvent>(
json);
if (order is null)
{
await channel.BasicNackAsync(
eventArgs.DeliveryTag,
multiple: false,
requeue: false);
return;
}
await ProcessOrderAsync(order);
await channel.BasicAckAsync(
eventArgs.DeliveryTag,
multiple: false);
}
catch (Exception ex)
{
Console.WriteLine(
$"Order processing failed: {ex.Message}");
await channel.BasicNackAsync(
eventArgs.DeliveryTag,
multiple: false,
requeue: false);
}
};
await channel.BasicConsumeAsync(
queue: "orders",
autoAck: false,
consumer: consumer);
}
private Task ProcessOrderAsync(
OrderCreatedEvent order)
{
// Perform the actual business operation.
return Task.CompletedTask;
}
}
public class OrderCreatedEvent
{
public string MessageId { get; set; } = string.Empty;
public int OrderId { get; set; }
public int CustomerId { get; set; }
public decimal TotalAmount { get; set; }
}The important setting is:
autoAck: falseIt enables manual acknowledgements.
The consumer acknowledges the delivery only after the processing method completes successfully.
The example also sets a prefetch count of 10. This limits the number of unacknowledged deliveries that RabbitMQ can send to this consumer according to the configured QoS scope.
A suitable prefetch value depends on message processing time, consumer concurrency, and available resources.
Important acknowledgement considerations
The example demonstrates the acknowledgement sequence, but production consumers need additional safeguards.
For example:
Do not acknowledge a message before its database transaction commits.
Do not acknowledge a message when processing has only been started in the background.
Do not use the same channel concurrently without ensuring the client library's channel usage requirements are respected.
Avoid unlimited message requeueing after repeated failures.
Keep track of processing attempts and route permanently failed messages to an appropriate dead-letter queue.
There is also an important limitation in the example: if the database operation succeeds and the application crashes before the ACK, RabbitMQ can redeliver the message. Manual acknowledgement alone does not prevent duplicate business processing.
For that, the consumer needs idempotency.
What Is Idempotency in Message Processing?
Idempotency means that executing the same operation multiple times produces the same effective result as executing it once.
For example, consider an operation that marks an order as processed.
A non-idempotent implementation might create a new invoice every time it receives an order-created message.
An idempotent implementation checks whether the message has already been processed before performing the business operation.
The first delivery performs the operation.
The second delivery detects that the message has already been handled and skips the duplicate work.
This is one of the most effective ways to protect applications from repeated message delivery.
Example of a non-idempotent operation
Consider this SQL statement:
INSERT INTO invoices (
order_id,
invoice_amount,
created_at
)
VALUES (
5001,
2500.00,
CURRENT_TIMESTAMP
);If the same message is processed twice, the application may create two invoices for the same order.
Making the operation idempotent
You can enforce uniqueness at the database level:
CREATE TABLE invoices (
invoice_id BIGINT GENERATED ALWAYS AS IDENTITY
PRIMARY KEY,
order_id BIGINT NOT NULL UNIQUE,
invoice_amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP
);Now the database prevents multiple invoices from being created for the same order.
However, the consumer must still handle the unique constraint violation appropriately.
For more general message processing, use a dedicated message-processing record.
Using a Processed Messages Table
A common approach is to maintain a table that records messages already processed by a consumer.
For example:
CREATE TABLE processed_messages (
message_id UUID PRIMARY KEY,
consumer_name VARCHAR(150) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP
);The message ID uniquely identifies the logical message.
The consumer checks this table before executing the business operation.
If the message ID already exists for that consumer, the application skips the operation.
If it does not exist, the application performs the business operation and records the message as processed.
Why the message ID matters
Consider two messages:
{
"messageId": "f3f2a2a0-1a44-4c9e-9f6d-2a4f7b001001",
"orderId": 5001
}And:
{
"messageId": "f3f2a2a0-1a44-4c9e-9f6d-2a4f7b001001",
"orderId": 5001
}Both messages have the same ID, so the consumer can identify them as repeated deliveries of the same logical event.
Now consider another event:
{
"messageId": "f3f2a2a0-1a44-4c9e-9f6d-2a4f7b001002",
"orderId": 5001
}This is a different message, even though it references the same order.
The application may need to process it because it could represent another valid business event.
Do not use only the order ID as a deduplication key unless the business requirement explicitly allows only one event per order.
Implementing Idempotent Processing With a Database Transaction
The most important rule is that recording a message as processed and performing its business operation should happen in the same database transaction.
Otherwise, the application can still encounter inconsistent states.
Consider this incorrect sequence:
Insert message ID into processed_messages.
Commit the transaction.
Update the order.
Application crashes before updating the order.
When RabbitMQ redelivers the message, the application sees its ID in processed_messages and skips it.
The order update never happens.
The opposite sequence is also unsafe:
Update the order.
Commit the transaction.
Application crashes before recording the message ID.
RabbitMQ redelivers the message.
The order operation executes again.
The correct approach is to combine both database operations into one transaction.
SQL implementation
The following PostgreSQL statement attempts to insert the message ID:
INSERT INTO processed_messages (
message_id,
consumer_name
)
VALUES (
@MessageId,
@ConsumerName
)
ON CONFLICT (
message_id,
consumer_name
)
DO NOTHING
RETURNING message_id;For this uniqueness rule, the table should use a composite primary key:
Join the conversation! Your thoughts help the community grow.