How Do You Handle Duplicate Messages in Microservices?
Duplicate messages are a normal possibility in distributed messaging systems.
The standard approach is to make the consumer idempotent.
Idempotent Consumer = processing the same message multiple times produces the business effect only once.
For example:
PaymentCompleted Event
↓
Received first time
↓
Update Order → Paid ✓
Same event received again
↓
Already processed?
↓
YES
↓
Ignore duplicate ✓
1. Why Do Duplicate Messages Happen?
Consider this flow:
Message Broker
│
│ OrderCreated
▼
Consumer
│
├── Update database ✓
│
└── ACK message
Normally everything works.
But imagine:
Broker
│
│ EventId = ABC123
▼
Consumer
│
├── Update database ✓
│
├── CRASH / network failure ❌
│
└── ACK never reaches broker
The broker doesn't know whether processing completed.
So it may deliver the message again:
Broker
│
│ EventId = ABC123
▼
Consumer
│
└── Process again ❌
This is a common source of duplicate processing.
2. Why Are Duplicates Dangerous?
Some operations are relatively harmless if repeated.
For example:
UPDATE Orders
SET Status = 'Paid'
WHERE Id = 1001;
Running this twice may still result in:
Status = Paid
But consider:
Charge Credit Card
Add Loyalty Points
Reduce Inventory
Create Invoice
Send Email
Transfer Money
Running those twice could cause:
Customer charged twice ❌
Inventory reduced twice ❌
100 points added twice ❌
Two invoices created ❌
So consumers must be designed carefully.
3. Main Solution — Idempotent Consumer
Every event/message should have a unique identifier.
Example:
{
"eventId": "91d8ef64-a342-4ed5-b6e5-9cb23a7ef513",
"eventType": "OrderCreated",
"orderId": 1001,
"amount": 2500
}
The consumer records the EventId after processing.
For example:
ProcessedMessages
EventId
------------------------------------
ABC123
DEF456
GHI789
When a message arrives:
Receive Event
↓
Check EventId
↓
Already processed?
│
┌─┴─┐
YES NO
│ │
│ ▼
│ Process
│ ↓
│ Record EventId
│
▼
Ignore
4. Basic Algorithm
Conceptually:
if (await IsProcessedAsync(message.EventId))
{
return;
}
await ProcessMessageAsync(message);
await MarkAsProcessedAsync(message.EventId);
This is called the Idempotent Consumer Pattern.
However, this simple code still has an important problem that we'll fix shortly.
5. SQL Server Table for Processed Messages
For an ASP.NET Core + SQL Server application, create a table such as:
CREATE TABLE ProcessedMessages
(
MessageId UNIQUEIDENTIFIER NOT NULL,
ConsumerName NVARCHAR(200) NOT NULL,
ProcessedAtUtc DATETIME2 NOT NULL,
CONSTRAINT PK_ProcessedMessages
PRIMARY KEY (MessageId, ConsumerName)
);
Why include ConsumerName?
Because the same event may legitimately be processed by several different subscribers:
Event ABC123
│
├── Inventory Consumer
├── Notification Consumer
└── Analytics Consumer
All three should process it once.
Therefore:
MessageId + ConsumerName
is a useful deduplication key.
6. Example Data
MessageId ConsumerName ProcessedAt
----------------------------------------------------------
ABC123 InventoryConsumer 10:00
ABC123 NotificationConsumer 10:01
DEF456 InventoryConsumer 10:02
If Inventory Consumer receives ABC123 again:
ABC123 + InventoryConsumer
↓
Already exists
↓
Duplicate
↓
Ignore
But Notification Consumer can still process the same event independently.
7. EF Core Entity
public class ProcessedMessage
{
public Guid MessageId { get; set; }
public string ConsumerName { get; set; }
= string.Empty;
public DateTime ProcessedAtUtc { get; set; }
}
Configure composite key:
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProcessedMessage>()
.HasKey(x => new
{
x.MessageId,
x.ConsumerName
});
}
8. DbContext
public class InventoryDbContext : DbContext
{
public InventoryDbContext(
DbContextOptions<InventoryDbContext> options)
: base(options)
{
}
public DbSet<ProcessedMessage> ProcessedMessages
=> Set<ProcessedMessage>();
public DbSet<Product> Products
=> Set<Product>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProcessedMessage>()
.HasKey(x => new
{
x.MessageId,
x.ConsumerName
});
}
}
9. Event Contract
public class OrderCreatedIntegrationEvent
{
public Guid EventId { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
}
The important property is:
public Guid EventId { get; set; }
Every logical event should get a stable unique ID.
10. Consumer Example
Suppose Inventory Service consumes OrderCreated.
public async Task HandleAsync(
OrderCreatedIntegrationEvent message)
{
const string consumerName =
"InventoryOrderCreatedConsumer";
bool alreadyProcessed =
await _dbContext.ProcessedMessages
.AnyAsync(x =>
x.MessageId == message.EventId &&
x.ConsumerName == consumerName);
if (alreadyProcessed)
{
Console.WriteLine(
$"Duplicate ignored: {message.EventId}");
return;
}
var product =
await _dbContext.Products
.SingleAsync(x =>
x.Id == message.ProductId);
product.Stock -= message.Quantity;
_dbContext.ProcessedMessages.Add(
new ProcessedMessage
{
MessageId = message.EventId,
ConsumerName = consumerName,
ProcessedAtUtc = DateTime.UtcNow
});
await _dbContext.SaveChangesAsync();
}
Now:
ABC123 arrives
↓
Not found
↓
Stock 100 → 95
↓
Record ABC123
ABC123 arrives again
↓
Found
↓
Ignore
↓
Stock remains 95 ✓
11. But There Is Still a Race Condition
The previous example checks:
AnyAsync(...)
before processing.
Suppose two instances receive the same message nearly simultaneously:
Consumer Instance A
│
└── ABC123 not found
Consumer Instance B
│
└── ABC123 not found
Both could try processing.
Therefore:
Do not rely only on "check first" logic.
The database should enforce uniqueness too.
That's why this is important:
PRIMARY KEY (MessageId, ConsumerName)
or a unique index.
The database becomes the final guard against duplicate processed-message records.
12. Most Important Issue — Atomicity
Consider this:
await UpdateInventoryAsync(message);
await MarkAsProcessedAsync(message.EventId);
What happens if:
Update Inventory
↓
SUCCESS ✓
Application crashes
↓
MarkAsProcessed never happens ❌
The message gets redelivered:
Same Message
↓
No ProcessedMessages record
↓
Update Inventory AGAIN ❌
So this is not sufficiently reliable.
13. Use a Database Transaction
The business operation and the processed-message record should be committed atomically when they use the same transactional database.
BEGIN TRANSACTION
│
├── Update Inventory
│
└── Insert ProcessedMessage
│
▼
COMMIT
Now either:
Inventory Update ✓
ProcessedMessage ✓
or:
Inventory Update ✗
ProcessedMessage ✗
14. Transaction Example
public async Task HandleAsync(
OrderCreatedIntegrationEvent message)
{
const string consumerName =
"InventoryOrderCreatedConsumer";
await using var transaction =
await _dbContext.Database
.BeginTransactionAsync();
try
{
bool alreadyProcessed =
await _dbContext.ProcessedMessages
.AnyAsync(x =>
x.MessageId == message.EventId &&
x.ConsumerName == consumerName);
if (alreadyProcessed)
{
await transaction.RollbackAsync();
return;
}
var product =
await _dbContext.Products
.SingleAsync(x =>
x.Id == message.ProductId);
product.Stock -= message.Quantity;
_dbContext.ProcessedMessages.Add(
new ProcessedMessage
{
MessageId = message.EventId,
ConsumerName = consumerName,
ProcessedAtUtc = DateTime.UtcNow
});
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
The key idea is not the exact code—it is:
Business Change
+
Processed Message Record
↓
Same Transaction
15. When Should We ACK the Broker Message?
Only after the database transaction has successfully committed.
Correct order:
Receive Message
↓
Check duplicate
↓
BEGIN TRANSACTION
↓
Business Operation
↓
Insert ProcessedMessage
↓
COMMIT
↓
ACK / Complete Message
For Azure Service Bus:
await handler.HandleAsync(message);
await args.CompleteMessageAsync(
args.Message);
So:
Database commit
↓
CompleteMessageAsync()
16. What If ACK Fails?
This is important.
Suppose:
Process message ✓
Commit DB ✓
CompleteMessageAsync ✗
The broker may redeliver the message.
But now:
MessageId = ABC123
↓
ProcessedMessages
↓
Already exists
↓
Ignore business operation
↓
Complete message
That's exactly why idempotency is needed.
17. Full Failure-Safe Flow
Message Broker
│
│ ABC123
▼
Consumer
│
▼
Check ProcessedMessages
│
┌──────┴──────┐
│ │
Found Not Found
│ │
▼ ▼
Ignore BEGIN TRANSACTION
│ │
│ Business Operation
│ │
│ Insert ABC123
│ │
│ COMMIT
│ │
└──────┬──────┘
▼
ACK / Complete
This is a common robust pattern.
18. Azure Service Bus Example
With Azure Service Bus:
private async Task ProcessMessageAsync(
ProcessMessageEventArgs args)
{
var json =
args.Message.Body.ToString();
var message =
JsonSerializer.Deserialize<
OrderCreatedIntegrationEvent>(json);
if (message is null)
{
await args.DeadLetterMessageAsync(
args.Message,
"InvalidMessage",
"Unable to deserialize message.");
return;
}
await _handler.HandleAsync(message);
await args.CompleteMessageAsync(
args.Message);
}
The handler performs the idempotency check and transaction.
19. Broker Duplicate Detection vs Consumer Idempotency
Some brokers provide duplicate-detection features.
For example, Azure Service Bus can perform broker-side duplicate detection when configured.
Conceptually:
Publisher
│
│ MessageId = ABC123
▼
Broker
ABC123 first time → Accept
ABC123 again → Detect duplicate
This is useful.
But:
Broker-side duplicate detection should not replace consumer idempotency for critical business operations.
Why?
Because duplicates can arise during consumer processing/redelivery, not only when producers send the same message twice.
20. Producer Duplicate vs Consumer Duplicate
There are two related problems.
Producer sends twice
Publisher
├── ABC123
└── ABC123
Broker duplicate detection may help.
Consumer processes twice
Broker
↓
ABC123
↓
Consumer
↓
Business update ✓
↓
ACK fails
↓
ABC123 redelivered
Consumer idempotency protects this case.
21. Natural Idempotency
Not every operation requires a ProcessedMessages table.
Some operations are naturally idempotent.
For example:
UPDATE Orders
SET Status = 'Paid'
WHERE Id = 1001;
Repeating:
Paid → Paid → Paid
may not cause an incorrect business effect.
But this:
UPDATE Products
SET Stock = Stock - 5
WHERE Id = 10;
is not naturally idempotent.
Run twice:
100
↓
95
↓
90 ❌
Similarly:
Balance = Balance - ₹500
is dangerous when duplicated.
22. Business-Key Idempotency
Sometimes the business operation itself has a unique key.
For example:
PaymentId = PAY-1001
Database:
CREATE UNIQUE INDEX UX_Payments_PaymentId
ON Payments(PaymentId);
If the same payment request arrives again:
PAY-1001
↓
Already exists
↓
Do not charge/create again
This can be stronger than purely technical message-level deduplication.
23. Message ID vs Business Idempotency Key
They solve slightly different problems.
| Key | Example | Purpose |
|---|---|---|
| Message ID | EVT-ABC123 |
Detect same message |
| Business key | PAY-1001 |
Prevent same business operation |
| Consumer + Message ID | Inventory + EVT-ABC123 |
Track per-consumer processing |
For critical operations such as payment, a business idempotency key is especially useful.
24. Redis for Duplicate Detection?
You could store processed IDs in Redis:
processed:Inventory:ABC123
and check:
Exists?
↓
YES → Ignore
NO → Process
This can be useful for performance or short-lived deduplication.
However, there's an important limitation.
If:
Business data → SQL Server
Processed IDs → Redis
you cannot easily commit both as one normal local SQL transaction.
You may end up with:
SQL update succeeded
Redis write failed
Therefore, when the business operation is in SQL Server, storing the idempotency record in the same SQL database/transaction is often much easier to reason about for correctness-critical operations.
Redis can still be useful when its tradeoffs fit the requirement.
25. Inbox Pattern
The pattern of storing incoming messages before/while processing is often called the Inbox Pattern.
Conceptually:
Message Broker
↓
Consumer
↓
Inbox / ProcessedMessages
↓
Business Processing
The Inbox provides durable tracking of received/processed messages.
You may encounter:
Producer side
↓
Transactional Outbox
Consumer side
↓
Inbox / Idempotent Consumer
These patterns complement each other.
26. Outbox + Inbox
A robust architecture can look like:
Order Service
│
├── Orders
└── OutboxMessages
│
▼
Outbox Worker
│
▼
Message Broker
│
▼
Inventory Service
│
├── Inbox / ProcessedMessages
│
└── Inventory
Producer
Business Data
+
Outbox Event
↓
Same Transaction
Consumer
Business Update
+
Processed Message
↓
Same Transaction
This gives you a much more reliable end-to-end design.
27. Don't Try to Guarantee "Exactly Once" Too Casually
You'll often hear:
Exactly-once processing
In distributed systems, end-to-end exactly-once business effects are difficult.
A practical design is often:
At-least-once delivery
+
Idempotent consumer
↓
Effectively-once business effect
Meaning:
The broker may deliver:
ABC123
ABC123
ABC123
but your application produces:
Business effect
↓
ONCE
That's usually the important business requirement.
28. Example: Payment Service
Imagine:
ProcessPayment
PaymentId = PAY-1001
Amount = ₹5,000
The message arrives twice.
Without idempotency:
PAY-1001
↓
Charge ₹5,000
PAY-1001 again
↓
Charge ₹5,000 again ❌
With idempotency:
PAY-1001
↓
Already processed?
↓
NO
↓
Charge
↓
Save PAY-1001
PAY-1001 again
↓
Already processed?
↓
YES
↓
Return previous result / ignore
For payment-like operations, business-level idempotency is essential.
29. Example: Inventory
Event:
EventId = EVT-5001
ProductId = 100
Quantity = 5
Initial:
Stock = 100
First processing:
100 - 5
↓
95
Save EVT-5001
Duplicate:
EVT-5001
↓
Already processed
↓
Do not subtract again
Stock remains 95 ✓
Without idempotency:
100 → 95 → 90 ❌
30. Cleanup of Processed Messages
The ProcessedMessages table can grow indefinitely.
1 million
10 million
100 million
...
Therefore, define a retention policy based on:
- Maximum possible redelivery window
- Broker retention
- Audit requirements
- Business requirements
- Regulatory requirements
- Storage cost
For example, old processed-message records might be archived or deleted after an appropriate period.
Don't arbitrarily delete them too soon, because an old message could potentially be redelivered or replayed depending on the system.
31. Advantages
Idempotent consumers provide:
- Protection against duplicate processing
- Safer message redelivery
- Reliable retries
- Better fault tolerance
- Safer financial/inventory operations
- Better recovery after crashes
- Support for at-least-once delivery models
32. Disadvantages
There is additional complexity:
- Extra database table/storage
- Extra lookup/write per message
- Transaction management
- Cleanup/retention requirements
- Concurrency/race-condition handling
- Unique constraints required
- Business-specific idempotency design
But for important side effects, this complexity is usually justified.
33. Key Points
For interview purposes, remember:
- Duplicate messages are expected possibilities in distributed messaging.
- The main solution is an Idempotent Consumer.
- Every logical message/event should have a stable unique
MessageIdorEventId. - Store processed IDs where appropriate.
- Check whether a message was already processed.
- Use a unique constraint as a database-level guard.
- Business operation + processed-message record should ideally be committed atomically when possible.
- ACK/Complete only after successful business processing/commit.
- A crash after DB commit but before ACK can cause redelivery.
- Idempotency makes that redelivery safe.
- Broker duplicate detection is useful but does not replace consumer idempotency.
- Natural idempotency can sometimes avoid explicit deduplication records.
- Business idempotency keys are important for operations such as payments.
- Inbox Pattern is commonly associated with reliable consumer-side processing.
- Transactional Outbox addresses producer-side reliable publication.
Outbox + Inbox/Idempotent Consumeris a strong production combination.- Be cautious with claims of true end-to-end "exactly once."
34. Interview Questions and Answers
Q1. How do you handle duplicate messages?
Answer:
Use an idempotent consumer. Assign each message a unique ID, record successfully processed IDs, and check that record before applying the business operation again.
For critical operations, the business update and processed-message record should be committed atomically.
Q2. Why can duplicate messages occur?
Answer:
A common scenario is that the consumer successfully performs the business operation but crashes before acknowledging the message. The broker then redelivers it because it cannot know whether processing completed successfully.
Q3. What is an idempotent consumer?
Answer:
An idempotent consumer can process the same message multiple times without causing duplicate business effects.
ABC123 → Process ✓
ABC123 → Ignore duplicate
ABC123 → Ignore duplicate
Q4. Why isn't checking MessageId enough?
Answer:
A simple check can have race conditions, and the application can crash between performing the business operation and recording the MessageId.
A stronger design uses:
Unique constraint
+
Database transaction
so the business change and deduplication record are consistent.
Q5. When should the consumer ACK the message?
Answer:
After the important business processing has successfully completed and its transaction has committed.
Process
↓
Commit
↓
ACK
Q6. What if ACK fails after the database commits?
Answer:
The broker may redeliver the message. The idempotency record allows the consumer to recognize it as already processed and avoid repeating the business operation.
Q7. What is the Inbox Pattern?
Answer:
The Inbox Pattern stores/tracks incoming message IDs or messages so the consumer can safely detect duplicate processing and coordinate message handling with its local business transaction.
Q8. Outbox vs Inbox?
Answer:
| Outbox | Inbox |
|---|---|
| Producer side | Consumer side |
| Reliable publishing | Reliable/idempotent consumption |
| Prevents lost outgoing events | Prevents duplicate business effects |
| Stores events to publish | Stores/tracks received/processed messages |
Q9. Can Redis be used for duplicate detection?
Answer:
Yes, particularly for fast or temporary deduplication. However, if your business data is stored in SQL Server, coordinating a Redis idempotency marker atomically with a SQL business update is harder than storing both in the same SQL transaction.
Q10. Does Azure Service Bus duplicate detection solve everything?
Answer:
No. Broker duplicate detection can help prevent repeated sends with the same message identity, but consumer redelivery can still occur because of processing or acknowledgement failures.
Consumer idempotency is still important for critical side effects.
Interview-Friendly Final Answer
Duplicate messages are normally handled using the Idempotent Consumer Pattern. Each message has a unique
MessageIdorEventId. Before applying the business operation, the consumer checks whether that message was already processed. For critical operations, the business update and processed-message record are stored in the same database transaction, protected by a unique constraint. The broker message is acknowledged only after that transaction commits. Therefore, if the consumer crashes after committing but before acknowledgement and the broker redelivers the message, the consumer recognizes the duplicate and does not repeat the business operation. On the producer side, Transactional Outbox handles reliable publication; on the consumer side, Inbox/Idempotent Consumer handles duplicate processing.