Duplicate requests are a common problem in web applications. A user may click the Submit button twice, a mobile application may retry after a network timeout, or an API gateway may resend a request when the original response is lost. In each case, the server may receive the same logical operation more than once.

For a read-only API, duplicate requests are often harmless. For an API that creates orders, processes payments, reserves inventory, or registers subscriptions, duplicate execution can lead to incorrect business results.

Preventing duplicate operations requires more than disabling a button in the user interface. The server must be able to recognize repeated requests, coordinate concurrent execution, and preserve correctness even when a process crashes or a response never reaches the client.

In ASP.NET Core, one practical approach is to implement idempotency keys backed by durable storage and database constraints. This article explains how the approach works, how to implement it, common failure scenarios, and the design decisions that matter in production.

What Is an Idempotent API Operation?

An operation is idempotent when repeating the same logical request produces the same intended effect as executing it once.

For example, setting an order's status to Shipped can be idempotent if repeating the operation does not create additional shipments or duplicate side effects.

Creating a new order is different. Sending the same request twice might create two separate orders unless the server has a mechanism to identify the repeated operation.

An idempotency key is a client-generated identifier that represents one logical operation. The client sends the same key when retrying that operation. The server uses the key to recognize a duplicate and avoid performing the business operation again.

A request might look like this:

Code
POST /api/orders
Idempotency-Key: 9c8a8b7d-3d3a-4e76-8c18-75f6a7c1b1a0
Content-Type: application/json

{
  "productId": 125,
  "quantity": 2
}

If the client retries because it did not receive the response, it should send the same idempotency key for the same logical operation.

A new order created intentionally by the user must use a new key.

Why Client-Side Protection Is Not Enough

Disabling a Submit button after the first click can improve the user experience, but it cannot guarantee that an operation executes only once.

A request can be duplicated by a browser retry, a reverse proxy, a mobile network, or another client that does not use the same interface. Two requests can also reach different application instances at nearly the same time.

Consider this sequence:

  1. A client submits an order.

  2. The server creates the order.

  3. The response is lost because the connection drops.

  4. The client assumes the request failed.

  5. The client submits the order again.

  6. The server creates a second order.

The server completed the first operation successfully, but the client could not confirm the result.

An idempotency mechanism lets the server associate both requests with the same logical operation and return the previously recorded result when appropriate.

Design an Idempotency Record

For a production API, store idempotency records in a durable database or another storage system that supports the required atomic operations.

A record should contain enough information to identify the operation, detect conflicting requests, and recover the previous result.

A typical design includes:

Field

Purpose

Idempotency key

Identifies the logical operation

Client or tenant identifier

Prevents unrelated clients from sharing the same key scope

Request hash

Detects reuse of the key with different input

Status

Tracks whether the operation is processing, completed, or failed

Response status code

Allows the original HTTP outcome to be replayed

Response body

Stores the result when replaying the response is required

Created and expiration timestamps

Support retention and cleanup

The uniqueness constraint should cover the correct scope, such as the tenant, operation type, and idempotency key.

A globally unique key can be sufficient in some systems, but explicitly defining the scope helps prevent accidental collisions and cross-tenant data exposure.

Implement Idempotency Storage with EF Core

The following model represents the durable record for a completed or in-progress operation.

C#
public sealed class IdempotencyRecord
{
    public long Id { get; set; }

    public required string TenantId { get; set; }

    public required string Operation { get; set; }

    public required string Key { get; set; }

    public required string RequestHash { get; set; }

    public string Status { get; set; } = "Processing";

    public int? ResponseStatusCode { get; set; }

    public string? ResponseBody { get; set; }

    public DateTimeOffset CreatedAt { get; set; }

    public DateTimeOffset? CompletedAt { get; set; }
}

Configure a unique index in the EF Core model:

C#
protected override void OnModelCreating(
    ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    modelBuilder.Entity<IdempotencyRecord>(entity =>
    {
        entity.HasIndex(record => new
        {
            record.TenantId,
            record.Operation,
            record.Key
        })
        .IsUnique();

        entity.Property(record => record.TenantId)
            .HasMaxLength(100)
            .IsRequired();

        entity.Property(record => record.Operation)
            .HasMaxLength(150)
            .IsRequired();

        entity.Property(record => record.Key)
            .HasMaxLength(200)
            .IsRequired();

        entity.Property(record => record.RequestHash)
            .HasMaxLength(64)
            .IsRequired();

        entity.Property(record => record.Status)
            .HasMaxLength(20)
            .IsRequired();
    });
}

The unique index is essential. An application-level check such as querying for an existing key before inserting a new record is not sufficient on its own.

Two requests can both check for the key before either inserts it. The database constraint provides the final protection against concurrent duplicate records.

After changing the model, create and apply an EF Core migration.

Calculate a Request Fingerprint

An idempotency key should not be accepted for arbitrary request bodies. If a client reuses a key for a different order, the API should reject the conflicting request.

One way to detect this is to hash a canonical representation of the request.

C#
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static class RequestFingerprint
{
    public static string Create<T>(T request)
    {
        var json = JsonSerializer.Serialize(request);
        var bytes = Encoding.UTF8.GetBytes(json);
        var hash = SHA256.HashData(bytes);

        return Convert.ToHexString(hash);
    }
}

This example works for a controlled DTO with a stable serialization configuration. For more complex requests, ensure that logically equivalent payloads produce the same canonical representation. Differences in property ordering, numeric formatting, or omitted default values can otherwise produce different hashes.

The hash is a comparison mechanism, not a security authorization mechanism. Authentication and authorization must still be performed for every incoming request.

Coordinate the Business Operation and Idempotency Record

The most important implementation detail is transaction management.

For operations whose effects are stored in the same relational database, the business change and the completed idempotency record should be committed in the same transaction.

The high-level workflow is:

  1. Authenticate the caller and validate the request.

  2. Calculate the request fingerprint.

  3. Attempt to create the idempotency record under a unique constraint.

  4. If the key already exists, compare the request fingerprint.

  5. If the existing operation is complete, return its stored result.

  6. If another request is processing the same operation, follow a defined conflict or waiting policy.

  7. Execute the business change and record the final result in one database transaction.

  8. Commit the transaction before returning success.

If the database transaction rolls back, neither the business change nor the completed record should be committed.

A concurrent insert may fail with a unique-constraint violation. Handle that specific database condition by loading the existing record and applying the duplicate-request rules. Do not treat every database exception as evidence of a duplicate.

What about external services?

A database transaction cannot atomically commit a local database change and a payment-provider request.

If an operation calls an external payment service, the application needs a broader reliability design. Use the provider's idempotency support when available, persist the operation state, and consider an outbox or workflow pattern for coordinating asynchronous side effects.

Do not assume that rolling back a database transaction reverses an external payment or message that has already been accepted.

Handle Duplicate Requests Correctly

The API should define what happens when the same key is submitted more than once.

Existing operation state

Recommended behavior

Completed with the same request hash

Replay the stored result

Existing key with a different request hash

Reject the request, commonly with HTTP 409

Processing

Return a documented conflict or wait for completion

Failed before any business effect

Apply a documented retry policy

Outcome uncertain after an external call

Reconcile the operation before executing it again

For a completed request, replay the original status code and response representation where appropriate. Make sure the stored response does not contain sensitive information that should no longer be disclosed to the caller.

For a request that is still processing, avoid starting another execution merely because the original request has not completed. A client can receive HTTP 409 Conflict or another documented response indicating that the operation is in progress.

The exact status codes and response contract should be consistent across the API.

Common Mistakes

Checking for duplicates without a unique constraint

A query followed by an insert creates a race condition. Use a database uniqueness constraint or an equivalent atomic mechanism.

Using a new key for every retry

A retry with a different key appears to the server as a new operation. The client must reuse the original key for retries of the same logical action.

Storing only the key

A key alone does not tell the server whether the request has completed or whether the caller has changed the request body. Store an operation status and a request fingerprint.

Deleting records too early

If the idempotency record is removed while clients can still retry, the same operation may execute again. Choose a retention period based on the API contract, retry behavior, and business risk.

Assuming a timeout means failure

A timeout only tells the client that it did not receive a result within the allowed time. It does not prove that the server did not complete the operation.

The client should retry with the same key or query the operation status rather than automatically generating a new key.

Best Practices for Production APIs

  1. Scope keys correctly. Include the authenticated tenant or client identity and the logical operation in the uniqueness scope.

  2. Validate requests before processing. Reject malformed input before starting business operations.

  3. Use durable storage. Keep idempotency records in storage that survives application restarts.

  4. Enforce uniqueness in the database. Do not rely only on in-memory dictionaries or application-level existence checks.

  5. Define failure recovery. Distinguish completed, processing, failed, and uncertain operations.

  6. Protect stored responses. Apply appropriate access controls and avoid persisting unnecessary sensitive information.

  7. Test concurrent requests. Send the same key from multiple requests and verify that only one business operation is committed.

  8. Monitor duplicate attempts. Track key conflicts, processing time, replay frequency, and operations that require reconciliation.

Advantages and Disadvantages

Advantages

Disadvantages

Prevents duplicate business effects from repeated requests.

Requires additional storage and database constraints.

Makes client retries safer after network failures.

Requires a clear lifecycle for processing and failed operations.

Supports recovery when the original response is lost.

Response replay and retention require careful design.

Works across multiple application instances when backed by shared durable storage.

External side effects require additional coordination.

Improves the reliability of order and payment APIs.

Incorrect key scoping can cause conflicts or data exposure.

Summary

Preventing duplicate operations in ASP.NET Core APIs requires server-side protection. Client-side button handling cannot prevent retries caused by network failures, proxies, or concurrent requests.

Idempotency keys provide a way to recognize repeated logical operations. A durable idempotency record, request fingerprint, database uniqueness constraint, and transaction management can prevent duplicate database changes and allow completed results to be replayed.

For operations involving external services, use provider-level idempotency and explicit recovery mechanisms. The goal is not simply to reject repeated HTTP requests; it is to ensure that repeating the same logical operation does not create unintended business effects.