Handling Duplicate Events Safely with Idempotency
In the previous article, we looked at retries and DLQ. Retries help you recover from temporary failures. But they also create a new risk:
The same event may be delivered, retried, or processed more than once.
That is normal in event-driven systems.
The real problem is not duplicate delivery. The real problem is duplicate business impact.
If the same event charges a customer twice, reserves inventory twice, or sends the same email again, the consumer is not safe.
That is where idempotency comes in - Idempotency makes repeated processing safe
The Core Problem
Many teams assume this: One event published = one event processed
That assumption is dangerous. In real systems, this can happen:
Event published once
Consumer processes it
Consumer crashes before acknowledging
Broker redelivers the same event
Consumer processes it again
The broker is not necessarily wrong. The system is behaving as designed.
Your consumer must be ready. Consider this payment consumer:
public async Task Handle(OrderCreatedEvent evt)
{
await paymentService.ChargeAsync(evt.OrderId, evt.Amount);
await orderRepository.MarkPaymentStartedAsync(evt.OrderId);
}
If the same OrderCreated event is delivered twice, the customer may be charged twice.
That is not a messaging problem anymore. That is a business problem.
What Idempotency Means
Idempotency means processing the same operation multiple times produces the same final result as processing it once.
Process once -> correct state
Process twice -> same correct state
Process ten times -> same correct state
For event consumers, duplicate events should not create duplicate business effects.
| Operation | Duplicate-Safe? | Why |
|---|---|---|
| Add 100 loyalty points every time event arrives | No | Duplicate event adds extra points |
| Set order status to PaymentStarted | Usually yes | Setting the same status again does not change meaning |
| Charge customer card | No | Duplicate event may charge twice |
| Insert audit record blindly | No | Duplicate records may be created |
| Upsert customer profile by customer ID | Usually yes | Same data replaces the same record |
The risk is not duplicate delivery - The risk is duplicate business impact
Why Duplicate Events Happen
Duplicate processing can happen for many reasons:
Broker redelivery after consumer crash
Consumer timeout before acknowledgement
Retry after uncertain failure
Manual replay from DLQ
Outbox publisher publishes the same message again
Network issue between consumer and broker
Deployment restart during processing
Many brokers use at-least-once delivery.
That means: The message may be delivered one or more times
Not: The message will be processed exactly once by your business logic
Exactly-once delivery should never be treated as your business safety strategy.
The Most Common Pattern: Processed Message Table
The simplest approach is to store processed event IDs.
Before processing an event, check whether it has already been processed.
A basic table may look like this:
CREATE TABLE ProcessedMessages
(
MessageId UNIQUEIDENTIFIER NOT NULL,
ConsumerName NVARCHAR(200) NOT NULL,
ProcessedAt DATETIME2 NOT NULL,
PRIMARY KEY (MessageId, ConsumerName)
);
ConsumerName matters because the same event may be processed by multiple consumers independently.
Example:
public async Task Handle(OrderCreatedEvent evt)
{
var alreadyProcessed = await dbContext.ProcessedMessages.AnyAsync(x =>
x.MessageId == evt.MessageId &&
x.ConsumerName == "PaymentConsumer");
if (alreadyProcessed)
{
return;
}
using var transaction = await dbContext.Database.BeginTransactionAsync();
await paymentService.ChargeAsync(evt.OrderId, evt.Amount);
dbContext.ProcessedMessages.Add(new ProcessedMessage
{
MessageId = evt.MessageId,
ConsumerName = "PaymentConsumer",
ProcessedAt = DateTime.UtcNow
});
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
Application checks are not enough.
Two consumers can race and process the same message.
Use a unique key as the safety net. Save local changes and the processed marker in one transaction.
For external calls, use an idempotency key too
Idempotency Key for External Calls
A processed message table protects your local database.
It may not protect an external call if the process crashes after the external side effect but before saving the processed marker.
For payments, refunds, invoices, and provisioning, pass a stable idempotency key to the external system.
public async Task Handle(OrderCreatedEvent evt)
{
var idempotencyKey = $"payment-{evt.OrderId}";
await paymentGateway.ChargeAsync(new PaymentRequest
{
OrderId = evt.OrderId,
Amount = evt.Amount,
IdempotencyKey = idempotencyKey
});
}
Conceptually:
First call with key payment-O-1001 -> charge created
Second call with same key -> same result returned, no duplicate charge
For external side effects, idempotency keys are not optional.
Use Natural Business Keys Where Possible
Sometimes the business entity itself gives you the idempotency boundary.
Example:
OrderId = O-1001 Payment should be created once for O-1001
Instead of blindly inserting a payment record every time, protect the business rule with a uniqueness constraint:
CREATE UNIQUE INDEX UX_Payments_OrderId
ON Payments(OrderId);
Then write the consumer so duplicate events do not create duplicate payment records.
var existingPayment = await dbContext.Payments
.SingleOrDefaultAsync(x => x.OrderId == evt.OrderId);
if (existingPayment != null)
{
return;
}
await dbContext.Payments.AddAsync(new Payment
{
Id = Guid.NewGuid(),
OrderId = evt.OrderId,
Amount = evt.Amount,
Status = "Started"
});
await dbContext.SaveChangesAsync();
The database constraint is your final guard against concurrency mistakes.
Idempotency and Outbox Solve Different Problems
Outbox solves this:
Business data committed -> event should not be lost before publishing
Idempotency solves this:
Same event delivered again -> consumer should not apply side effect twice
They are complementary.
| Problem | Pattern |
|---|---|
| Event lost after DB commit | Outbox |
| Event processed more than once | Idempotent Consumer |
| Temporary consumer failure | Retry with backoff |
| Repeated processing failure | DLQ |
Do not expect one pattern to solve all reliability problems.
Exactly-Once Is Not a Business Strategy
You may hear claims about exactly-once delivery or exactly-once processing.
Even when infrastructure provides strong guarantees, your application logic can still create duplicate side effects.
Example:
Consumer receives event
Calls external payment API
Crashes before saving local state
Message is redelivered
Consumer calls payment API again
From the broker perspective, things may be working as designed.
From the customer perspective, they may be charged twice.
That is why idempotency belongs in business logic, not only in broker configuration.
Design Events for Idempotency
At minimum, every event should carry stable identifiers.
| Field | Purpose |
|---|---|
| MessageId | Detect duplicate messages |
| EventType | Route and process the event |
| OccurredAt | Know when the event happened |
| CorrelationId | Trace across services |
| AggregateId | Identify the business entity, such as OrderId |
Without stable identifiers, idempotency becomes guesswork.
Monitoring Idempotency
A duplicate event is not always bad.
A duplicate business side effect is bad.
At minimum, track duplicate message count, skipped duplicate count, idempotency key conflicts, failed replays, and processing failures after side effects.
If you cannot see duplicate behavior, you cannot prove your idempotency design is working.
Practical Idempotency Decision Matrix
| Scenario | Recommended Approach |
|---|
| Scenario | Recommended Approach |
|---|---|
| Local DB update only | ProcessedMessages table + unique key |
| Creating one record per business entity | Unique constraint on business key |
| Calling payment/refund API | External idempotency key |
| Sending email | Store notification history or use message key |
| Replaying DLQ messages | Require idempotent consumers first |
| Multiple consumers for same event | Track processed message per consumer |
| High-volume stream processing | Use state store or compacted key-based processing |
Final Takeaway
Duplicate events are not rare accidents in Event-Driven Architecture.
Retries, redelivery, DLQ replay, consumer crashes, and network failures can all cause the same event to be processed again.
Your system is reliable only when repeated processing is safe.
That is what idempotency gives you.
One-Line Truth
In event-driven systems, duplicate delivery is normal. Duplicate business impact is a design failure.
Series Closing Thought
At this point, the minimum serious EDA foundation is clear:
Use EDA only when the business flow needs it.
Understand request vs event-driven behavior.
Protect event publishing with Outbox.
Control retries and DLQ.
Make consumers idempotent.
If any one of these is missing, your event-driven system may work in demos but fail under real production conditions.
