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:
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:
A client submits an order.
The server creates the order.
The response is lost because the connection drops.
The client assumes the request failed.
The client submits the order again.
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.
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:
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.
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:
Authenticate the caller and validate the request.
Calculate the request fingerprint.
Attempt to create the idempotency record under a unique constraint.
If the key already exists, compare the request fingerprint.
If the existing operation is complete, return its stored result.
If another request is processing the same operation, follow a defined conflict or waiting policy.
Execute the business change and record the final result in one database transaction.
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
Scope keys correctly. Include the authenticated tenant or client identity and the logical operation in the uniqueness scope.
Validate requests before processing. Reject malformed input before starting business operations.
Use durable storage. Keep idempotency records in storage that survives application restarts.
Enforce uniqueness in the database. Do not rely only on in-memory dictionaries or application-level existence checks.
Define failure recovery. Distinguish completed, processing, failed, and uncertain operations.
Protect stored responses. Apply appropriate access controls and avoid persisting unnecessary sensitive information.
Test concurrent requests. Send the same key from multiple requests and verify that only one business operation is committed.
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.
Join the conversation! Your thoughts help the community grow.