Skip to main content

Command Palette

Search for a command to run...

Handling Duplicate Events Safely with Idempotency

Updated
7 min readView as Markdown
K
Software Architect focused on software development, architecture, engineering processes, and team leadership. I write about practical architecture decisions, system design, distributed systems, database engineering, backend development, and the real tradeoffs teams face while building and maintaining software systems.

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.

More from this blog

A

Architecture in Practice | Software Architecture, System Design & Engineering Tradeoffs

6 posts

Practical software architecture, system design and engineering tradeoffs from real-world delivery experience

This blog focuses on architecture decisions, distributed systems, database engineering, backend development and the practical problems teams face while building and operating software systems