Mert Özen Further With Every Line
Software Design Solution Patterns — #19

Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries

Mert Özen Nov 7, 2026 18 min 4 views
Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries

The reliable way to stop the same order from being created twice on double clicks and retries is for the server to store a client-generated Idempotency-Key per user together with a request hash, and to write the work and the 'completed' record in the same transaction. This post covers the current status of the IETF draft (draft-08, not an RFC), the 400/409/422 rules and an ASP.NET Core endpoint filter + SQL Server implementation with real output.

Version note: The C# code in this post was tested with .NET 10 and EF Core 10, and the SQL examples with SQL Server 2025.

Guess First: "Reject the Same Order If It Arrives Within a Minute"

HR Portal's purchasing module has a complaint: some orders are created twice. Someone on the team writes a quick fix: if an order to the same supplier for the same amount was created in the last minute, reject the new one.

app.MapPost("/naive/purchase-orders", async (CreatePurchaseOrderRequest request, HrPortalDbContext db,
        ISupplierGateway suppliers, TimeProvider time, CancellationToken ct) =>
    {
        DateTimeOffset oneMinuteAgo = time.GetUtcNow().AddMinutes(-1);
        bool duplicate = await db.PurchaseOrders.AnyAsync(o =>
            o.SupplierId == request.SupplierId && o.Amount == request.Amount && o.CreatedAt > oneMinuteAgo, ct);
        if (duplicate)
        {
            return Results.Conflict();
        }

        await suppliers.EnsureActiveAsync(request.SupplierId, ct);
        PurchaseOrder order = new()
        {
            CompanyId = 7, SupplierId = request.SupplierId, Amount = request.Amount,
            Description = request.Description, CreatedAt = time.GetUtcNow()
        };
        db.PurchaseOrders.Add(order);
        await db.SaveChangesAsync(ct);
        return Results.Created($"/purchase-orders/{order.Id}", new PurchaseOrderDto(order.Id, order.Amount));
    });

The supplier check (EnsureActiveAsync) takes 400 ms, just like in real life. Now two requests arrive at the same moment (the user clicked "Save" twice), and a few seconds later a third one arrives (the user really wants to open a second order):

var naiveOrder = new { supplierId = 31, amount = 1500.00m, description = "Laptop bags" };
await Task.WhenAll(Send("naive A", null, naiveOrder, "/naive/purchase-orders"),
    Send("naive B", null, naiveOrder, "/naive/purchase-orders"));
await Send("naive C", null, naiveOrder, "/naive/purchase-orders");

How many orders are created? What does the third request get? In which scenario does this rule work, and in which does it make two wrong decisions?

Context and Problem: The Network Doesn't Tell You Whether It Happened

A client that sends an HTTP request and gets no response cannot tell which of three situations it is in: the request never reached the server, it reached the server and was processed but the response was lost on the way, or it is still being processed. When a purchasing specialist on a mobile network sends an order and presses "try again" after a 30-second timeout, we have pushed that uncertainty onto the user. The same uncertainty exists on the user's side: clicking "Save" a second time when the page reacts slowly is a very human thing to do.

That is why RFC 9110 separates methods into idempotent and non-idempotent ones: repeating GET, PUT and DELETE with the same request does not change their effect on the server, while there is no such guarantee for POST and PATCH. The POST /purchase-orders endpoint that creates an order produces a new order and a new id every time it is called. Two orders mean two approval flows, two emails to the supplier and the amount deducted from the budget twice at month end.

The solution pattern is this: the client generates an idempotency key for each logical operation and sends the same key on every attempt. The server remembers the key; if the same key arrives a second time it does not do the work again, it returns the result of the first attempt. The pattern is not new; payment APIs such as Stripe have used an Idempotency-Key header for years, and the IETF HTTP APIs working group (httpapi) is working on a draft to standardize it.

Symptoms

Rows in the database with the same supplier, the same amount and the same description created a few seconds apart. Two POSTs from the same user milliseconds apart in the logs. Support tickets saying "I was charged twice" or "the order landed in approval twice". A frontend ticket opened as "let's disable the button" that did not end the problem. In the code, "is there an identical one in the last minute" checks like the one above, or automatic retry policies in HTTP clients left enabled for POST too.

The Naive Solution and Why It Breaks

The real output of the opening experiment:

naive B: 201 /purchase-orders/1 {"id":1,"amount":1500.00}
naive A: 201 /purchase-orders/2 {"id":2,"amount":1500.00}
naive C: 409

The rule made two wrong decisions. The two simultaneous requests both passed the AnyAsync check before the other had saved, so both created an order: the classic check-then-act race. The third request really was a new order; the user wanted a second batch of laptop bags and got a 409. Detecting duplicates by content both misses the duplicate and blocks the legitimate repeat, because the server cannot answer "is this request a copy of the previous one, or a new request with the same content" from the content. Only the client has that information.

Disabling the button protects against the first click, but not against a retry after a timeout, a tab refresh, a mobile app's background sync or integrations that call the API directly. We discussed races like this from a security angle in "OWASP A06: Insecure Design — Business Logic Flaws, Race Conditions and Rate Limiting" in the OWASP series.

Options

Closing the Door on the Client

Disabling the Save button while the request is running, locking the form after it has been submitted. Cheap and necessary for the user experience, but it only prevents a double click in a single browser tab. It gives no guarantee on the server side; it is a layer, not a solution on its own.

Detecting Duplicates by Content

The naive solution above. Reject the same content if it arrives within a time window. Open to races, blocks legitimate repeats, and the window length is arbitrary. Even if you close the race with a unique index, the second problem remains.

A Natural Business Key and a Unique Constraint

Some operations already have a natural unique key: the supplier's own invoice number, a bank transaction's reference number, an order number coming from an external system. A unique index on (CompanyId, ExternalReference) in the PurchaseOrders table stops duplicates at the database level without races. The limit: an order a user opens by hand has no such key, and instead of telling the second request "it already exists" you need to give it the first request's response.

The Idempotency-Key Header and a Record Table

The client generates a UUID for each logical operation and sends it in the Idempotency-Key header. The server writes the key to a table together with the user, stores the request's fingerprint (a hash), and when the operation finishes stores the response as well. If the same key comes again: 422 if the fingerprint differs, 409 if the first request is still running, the stored response if it has finished. This is the option we choose.

Implementation: An Endpoint Filter and the IdempotencyRecord Table

What the Draft Says and What We Chose

At the time of writing (November 2026) the current version of the "The Idempotency-Key HTTP Header Field" draft is version 08, dated October 22, 2026 (draft-ietf-httpapi-idempotency-key-header-08). It is a document of the httpapi working group aiming at the Standards Track, but it is not an RFC yet; as an Internet-Draft it expires on April 25, 2027. So the name and behavior can still change; stating the draft version in your own API documentation is a good habit.

The draft's decisions that matter to us: the header value is a Structured Field string (RFC 9651), so it is written in quotes (Idempotency-Key: "8e03978e-..."); a UUID (RFC 9562) is recommended as the key; a fingerprint of the request can be used together with the key. The recommended codes for errors: 400 if the key is missing, 422 if the same key arrives with a different payload, 409 if the first request with that key is still being processed; the body is an RFC 9457 problem details document. How long keys are kept is up to the resource, but that policy has to be documented. On Stripe's side a key is at most 255 characters and kept for at least 24 hours, parameters are compared with the first request, and requests that fail validation or conflict with a concurrent request are not stored.

Our decisions: a key is at least 16 and at most 100 characters; its scope is the company and the user (CompanyId, UserId, Key); retention is 24 hours; only 2xx responses are stored, and on 4xx and 5xx the key is released so a corrected request can be sent again with the same key. Stripe API v1 stores 500 responses too; we don't, because the transaction is rolled back and so there is no side effect either. This decision may differ in your system; what matters is documenting it.

19-idempotency-flow-en.png

The Record Table

namespace HrPortal.Api.Idempotency;

public enum IdempotencyStatus { Started = 0, Completed = 1 }

public class IdempotencyRecord
{
    public long Id { get; set; }
    public int CompanyId { get; set; }
    public string UserId { get; set; } = "";
    public string Key { get; set; } = "";
    public byte[] RequestHash { get; set; } = [];
    public IdempotencyStatus Status { get; set; }
    public int? ResponseStatusCode { get; set; }
    public string? ResponseContentType { get; set; }
    public string? ResponseLocation { get; set; }
    public string? ResponseBody { get; set; }
    public DateTimeOffset LockedUntil { get; set; }
    public DateTimeOffset ExpiresAt { get; set; }
}

// inside HrPortalDbContext.OnModelCreating
modelBuilder.Entity<IdempotencyRecord>(e =>
{
    e.HasIndex(r => new { r.CompanyId, r.UserId, r.Key }).IsUnique();
    e.HasIndex(r => r.ExpiresAt);
    e.Property(r => r.UserId).HasMaxLength(64);
    e.Property(r => r.Key).HasMaxLength(100).IsUnicode(false);
    e.Property(r => r.RequestHash).HasMaxLength(32).IsFixedLength();
    e.Property(r => r.ResponseContentType).HasMaxLength(100);
    e.Property(r => r.ResponseLocation).HasMaxLength(200);
});

The unique index does two jobs: it stops the same key from being written twice, and it lets the database decide which of two concurrent requests is the "owner". The ExpiresAt index is for the cleanup job. LockedUntil is a lease: if the first request crashes halfway, the record must not stay in Started forever.

The Filter, Step One: Claiming the Key

Endpoint filters for minimal APIs arrived with .NET 7; we saw their mechanics in "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching". The filter is generic: for the fingerprint it needs to know which argument is the request body.

public sealed class IdempotencyFilter<TRequest>(HrPortalDbContext db, TimeProvider time) : IEndpointFilter
{
    private static readonly TimeSpan Lease = TimeSpan.FromSeconds(30);
    private static readonly TimeSpan Retention = TimeSpan.FromHours(24);

    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        HttpContext http = context.HttpContext;
        string? key = ParseKey(http.Request.Headers["Idempotency-Key"].ToString());
        if (key is null)
        {
            return Problem(400, "Idempotency-Key header is required",
                "Send a unique key (for example a UUID) with every POST /purchase-orders request.");
        }

        CurrentUser user = CurrentUser.From(http);
        TRequest request = context.Arguments.OfType<TRequest>().Single();
        byte[] hash = Fingerprint(http.Request.Method, http.Request.Path, request);
        DateTimeOffset now = time.GetUtcNow();

        IdempotencyRecord record = new()
        {
            CompanyId = user.CompanyId, UserId = user.UserId, Key = key, RequestHash = hash,
            Status = IdempotencyStatus.Started, LockedUntil = now + Lease, ExpiresAt = now + Retention
        };

        // step 1: claim the key (the unique index acts as the lock)
        if (!await TryClaimAsync(record, http.RequestAborted))
        {
            IdempotencyRecord existing = await db.IdempotencyRecords.AsNoTracking()
                .SingleAsync(r => r.CompanyId == user.CompanyId && r.UserId == user.UserId && r.Key == key,
                    http.RequestAborted);

            if (!existing.RequestHash.SequenceEqual(hash))
            {
                return Problem(422, "Idempotency-Key was used with a different payload",
                    "Use a new key for a new request.");
            }

            if (existing.Status == IdempotencyStatus.Completed)
            {
                return new ReplayResult(existing);
            }

            if (existing.LockedUntil > now || !await TryTakeOverAsync(existing.Id, now, http.RequestAborted))
            {
                return Problem(409, "A request with this Idempotency-Key is in progress",
                    "Retry after the original request completes.");
            }

            record = await db.IdempotencyRecords.SingleAsync(r => r.Id == existing.Id, http.RequestAborted);
        }

The first request performs the INSERT and commits it in its own transaction. If a second request arrives with the same key, its INSERT fails with 2601 (unique index violation) and the filter looks at the existing record. The order of checks matters: first the fingerprint, then the status. Returning the stored response to a client that sent a different amount with the same key would be lying to it that "your request was processed".

The Filter, Step Two: The Work and the Record in the Same Transaction

        // step 2: the work and the "completed" record in the same transaction
        await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(http.RequestAborted);
        Stream originalBody = http.Response.Body;
        using MemoryStream buffer = new();
        try
        {
            if (await next(context) is not IResult inner)
            {
                throw new InvalidOperationException("Idempotent endpoints must return IResult.");
            }

            http.Response.Body = buffer;
            await inner.ExecuteAsync(http);
        }
        catch
        {
            await tx.RollbackAsync();
            await ReleaseAsync(record);
            throw;
        }
        finally
        {
            http.Response.Body = originalBody;
        }

        if (http.Response.StatusCode is >= 200 and < 300)
        {
            record.Status = IdempotencyStatus.Completed;
            record.ResponseStatusCode = http.Response.StatusCode;
            record.ResponseContentType = http.Response.ContentType;
            record.ResponseLocation = http.Response.Headers.Location.ToString();
            record.ResponseBody = Encoding.UTF8.GetString(buffer.ToArray());
            await db.SaveChangesAsync(http.RequestAborted);
            await tx.CommitAsync(http.RequestAborted);
        }
        else
        {
            // validation and business-rule errors are not cached: the key is released
            await tx.RollbackAsync(http.RequestAborted);
            await ReleaseAsync(record);
        }

        buffer.Position = 0;
        await buffer.CopyToAsync(originalBody, http.RequestAborted);
        return Results.Empty;
    }

This is where the pattern's most important decision lives. The order's INSERT and the idempotency record's Completed update are in the same transaction. Because the handler uses the same scoped DbContext, its SaveChangesAsync call joins the transaction the filter opened (see "Transactions in EF Core: SaveChanges, BeginTransaction and Savepoints" in the EF Core series). If the order were committed and the record stayed Started, a retry would create a second order; if the record became Completed and the order were not committed, a retry would return the response of an order that does not exist. The single transaction makes both impossible.

To store the response we execute the IResult returned by the handler into a MemoryStream; that way the status code, the Location header and the body are captured generically. The body is copied to the real response stream after the commit.

Helper Methods and Replay

    private async Task<bool> TryClaimAsync(IdempotencyRecord record, CancellationToken ct)
    {
        db.IdempotencyRecords.Add(record);
        try
        {
            await db.SaveChangesAsync(ct);
            return true;
        }
        catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 })
        {
            db.Entry(record).State = EntityState.Detached;
            return false;
        }
    }

    private async Task<bool> TryTakeOverAsync(long id, DateTimeOffset now, CancellationToken ct)
    {
        // if a half-finished (crashed) request's lease has expired, take over ownership
        int rows = await db.IdempotencyRecords
            .Where(r => r.Id == id && r.Status == IdempotencyStatus.Started && r.LockedUntil <= now)
            .ExecuteUpdateAsync(s => s.SetProperty(r => r.LockedUntil, now + Lease), ct);
        return rows == 1;
    }

    private async Task ReleaseAsync(IdempotencyRecord record)
    {
        db.ChangeTracker.Clear();
        await db.IdempotencyRecords.Where(r => r.Id == record.Id).ExecuteDeleteAsync();
    }

    internal static string? ParseKey(string raw)
    {
        // the draft asks for an sf-string: "..."; we also accept clients that send it unquoted
        string key = raw.Length >= 2 && raw[0] == '"' && raw[^1] == '"' ? raw[1..^1] : raw;
        return key.Length is >= 16 and <= 100 && key.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')
            ? key
            : null;
    }

    internal static byte[] Fingerprint(string method, string path, TRequest request)
    {
        string canonical = $"{method} {path} {JsonSerializer.Serialize(request, JsonSerializerOptions.Web)}";
        return SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
    }

    private static IResult Problem(int status, string title, string detail)
        => Results.Problem(statusCode: status, title: title, detail: detail);
}

public sealed class ReplayResult(IdempotencyRecord record) : IResult
{
    public async Task ExecuteAsync(HttpContext http)
    {
        http.Response.StatusCode = record.ResponseStatusCode!.Value;
        http.Response.ContentType = record.ResponseContentType;
        if (!string.IsNullOrEmpty(record.ResponseLocation))
        {
            http.Response.Headers.Location = record.ResponseLocation;
        }
        http.Response.Headers["Idempotent-Replayed"] = "true";
        await http.Response.WriteAsync(record.ResponseBody ?? "");
    }
}

ReplayResult returns the stored response with the same status code and Location, and adds an Idempotent-Replayed: true header. This header is not in the draft; like some other APIs, it is our own choice to tell the client and the logs "this is a repeat".

The Endpoint and the Scenarios

app.MapPost("/purchase-orders", async (CreatePurchaseOrderRequest request, HttpContext http,
        HrPortalDbContext db, ISupplierGateway suppliers, TimeProvider time, CancellationToken ct) =>
    {
        if (request.Amount <= 0)
        {
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                ["amount"] = ["Amount must be greater than zero."]
            });
        }

        await suppliers.EnsureActiveAsync(request.SupplierId, ct);

        PurchaseOrder order = new()
        {
            CompanyId = CurrentUser.From(http).CompanyId,
            SupplierId = request.SupplierId,
            Amount = request.Amount,
            Description = request.Description,
            CreatedAt = time.GetUtcNow()
        };
        db.PurchaseOrders.Add(order);
        await db.SaveChangesAsync(ct);

        return TypedResults.Created($"/purchase-orders/{order.Id}", new PurchaseOrderDto(order.Id, order.Amount));
    })
    .AddEndpointFilter<IdempotencyFilter<CreatePurchaseOrderRequest>>();

We ran six scenarios in the same demo program:

string key1 = "\"8e03978e-40d5-43e8-bc93-6894a57f9324\"";
var order1 = new { supplierId = 12, amount = 4250.00m, description = "Ergonomic chairs" };

await Send("no key", null, order1);
await Send("first", key1, order1);
await Send("retry", key1, order1);
await Send("same key, other body", key1, order1 with { amount = 5250.00m });

string key2 = "\"0b5f1c1e-6d0a-4f53-9a43-2c1f1b7f0d11\"";
var order2 = new { supplierId = 12, amount = 980.00m, description = "Monitor arms" };
await Task.WhenAll(Send("parallel A", key2, order2), Send("parallel B", key2, order2));

string key3 = "\"5d2a9c40-31e7-4f0e-b2a9-77d0e8a1c3f4\"";
await Send("invalid", key3, new { supplierId = 12, amount = 0m, description = "Desk lamps" });
await Send("corrected", key3, new { supplierId = 12, amount = 310.00m, description = "Desk lamps" });
no key: 400  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Idempotency-Key header is required","status":400,"detail":"Send a unique key (for example a UUID) with every POST /purchase-orders request.","traceId":"0HNPSVE9CFEMB:00000003"}
first: 201 /purchase-orders/3 {"id":3,"amount":4250.00}
retry: 201 replayed /purchase-orders/3 {"id":3,"amount":4250.00}
same key, other body: 422  {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Idempotency-Key was used with a different payload","status":422,"detail":"Use a new key for a new request.","traceId":"0HNPSVE9CFEMB:00000006"}
parallel A: 409  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"A request with this Idempotency-Key is in progress","status":409,"detail":"Retry after the original request completes.","traceId":"0HNPSVE9CFEMB:00000007"}
parallel B: 201 /purchase-orders/4 {"id":4,"amount":980.00}
invalid: 400  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"amount":["Amount must be greater than zero."]},"traceId":"0HNPSVE9CFEMC:00000003"}
corrected: 201 /purchase-orders/5 {"id":5,"amount":310.00}

On the double click, the first request created the order, the second got 409, and there is a single order in the table. The retry came back with the same Location and the same body. The same key with a different amount got 422. Because the validation error was not stored, the corrected request got 201 with the same key. We also tested the lease and the user scope:

// test: a crashed request's Started record (lease expired 1 s ago) and one that is still running
db.IdempotencyRecords.AddRange(
    new IdempotencyRecord { CompanyId = 7, UserId = "ada", Key = "c3f0a8d2-9b1e-4c57-8a6d-1f2e3d4c5b6a",
        RequestHash = crashedHash, Status = IdempotencyStatus.Started, LockedUntil = now.AddSeconds(-1), ExpiresAt = now.AddHours(24) },
    new IdempotencyRecord { CompanyId = 7, UserId = "ada", Key = "d4e1b9c3-0a2f-4d68-9b7e-2a3f4e5d6c7b",
        RequestHash = crashedHash, Status = IdempotencyStatus.Started, LockedUntil = now.AddSeconds(30), ExpiresAt = now.AddHours(24) });
await db.SaveChangesAsync();
// ...
await Send("expired lease", "\"c3f0a8d2-9b1e-4c57-8a6d-1f2e3d4c5b6a\"", crashedBody);
await Send("live lease", "\"d4e1b9c3-0a2f-4d68-9b7e-2a3f4e5d6c7b\"", crashedBody);
await Send("other user", key1, order1, userId: "alan");
expired lease: 201 /purchase-orders/6 {"id":6,"amount":75.00}
live lease: 409  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"A request with this Idempotency-Key is in progress","status":409,"detail":"Retry after the original request completes.","traceId":"0HNPSVE9CFEMC:00000006"}
other user: 201 /purchase-orders/7 {"id":7,"amount":4250.00}
Orders in DB: 7
ada 8e03978e Completed 201
ada 0b5f1c1e Completed 201
ada 5d2a9c40 Completed 201
ada c3f0a8d2 Completed 201
ada d4e1b9c3 Started
alan 8e03978e Completed 201

The Started record whose lease had expired was taken over and the order was created; the one with a live lease got 409. Alan created his own order even though he used the same key text, because the key is scoped to the user. Without the scope, Alan would have received Ada's order response, that is, another user's data.

Cleanup

Keys are not kept forever. A daily background job deletes expired records; in the demo we simulated two days later:

int purged = await db.IdempotencyRecords
    .Where(r => r.ExpiresAt < DateTimeOffset.UtcNow.AddDays(2))
    .ExecuteDeleteAsync();
Console.WriteLine($"Purged (simulated two days later): {purged}");
Purged (simulated two days later): 6

The Angular Side

The client generates the key and the client keeps it. It is generated once when the form opens, sent again with the same key after an error or a timeout, and renewed only after a successful save:

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, retry, throwError, timer } from 'rxjs';

export interface CreatePurchaseOrder { supplierId: number; amount: number; description: string; }
export interface PurchaseOrderDto { id: number; amount: number; }

@Injectable({ providedIn: 'root' })
export class PurchaseOrderApi {
  private readonly http = inject(HttpClient);

  create(request: CreatePurchaseOrder, idempotencyKey: string): Observable<PurchaseOrderDto> {
    return this.http
      .post<PurchaseOrderDto>('/api/purchase-orders', request, {
        headers: { 'Idempotency-Key': `"${idempotencyKey}"` }
      })
      .pipe(
        retry({
          count: 3,
          // retry with the same key only on network errors, 5xx and 409
          delay: (error: HttpErrorResponse, attempt: number) =>
            error.status === 0 || error.status === 409 || error.status >= 500
              ? timer(attempt * 1000)
              : throwError(() => error)
        })
      );
  }
}

export class PurchaseOrderForm {
  private readonly api = inject(PurchaseOrderApi);
  // generated once when the form opens; stays the same however many times the user clicks "Save"
  private idempotencyKey = crypto.randomUUID();
  saving = false;

  save(request: CreatePurchaseOrder): void {
    this.saving = true;
    this.api.create(request, this.idempotencyKey).subscribe({
      next: () => {
        this.saving = false;
        this.idempotencyKey = crypto.randomUUID(); // new order, new key
      },
      error: () => (this.saving = false) // the key stays: if the user tries again it is the same request
    });
  }
}

Retries happen only for network errors, 5xx and 409; 400 and 422 are not retried because, as the draft also says, the client must fix the request first. crypto.randomUUID() works in modern browsers only in a secure context (HTTPS).

Trade-off Table

CriterionClient-side lockContent detectionNatural key + uniqueIdempotency-Key + table
Data integrityOnly within one tabOpen to races, blocks legitimate repeatsComplete (if a key exists)Complete, with a single transaction
Query complexityNoneLowLowMedium (two steps, lease)
PerformanceNo effectOne EXISTS per requestIndex costOne INSERT + UPDATE per request, stored responses
ReversibilityEasyEasySchema changeEasy, enabled per endpoint
Team cognitive loadLowLow but misleadingLowMedium (draft, scope, retention policy)

When Not to Use It

GET, PUT and DELETE are already idempotent; adding an idempotency key to them gains nothing. Stripe also notes that the header has no effect on these methods. Using PUT instead of updating the same resource twice with the same value is often the simplest solution.

If there is a natural unique key, use it first. A bank transaction's reference number or a supplier's invoice number already is an idempotency key; adding a separate table creates two sources of truth.

Idempotency for consumers reading from a message queue is a different pattern. There the key comes from the message, not the client, and the inbox table is designed together with the outbox; that boundary belongs to the Messaging and Event-Driven Architecture series.

On endpoints that handle thousands of requests per second with large response bodies, storing every response in SQL Server can be expensive. There you can consider a distributed cache with a TTL, or storing only the id of the created resource and regenerating the response.

Checklist

  • Every POST (and, if needed, PATCH) endpoint with side effects has a documented idempotency policy: whether the key is required, the retention period, which responses are stored.
  • The key is unique within the user and tenant scope; another user's response is never returned.
  • Same key + different fingerprint returns 422, a key still being processed returns 409, a missing key returns 400.
  • The work and the "completed" record are committed in the same transaction.
  • There is a lease for half-finished requests; a record stuck in Started does not lock forever.
  • Validation errors are not stored; the client can fix the request and send it again with the same key.
  • The client generates the key once per form, reuses it on retries and only retries automatically on 0/409/5xx.
  • A cleanup job deletes expired records and ExpiresAt is indexed.

The next post in the series, "Optimistic Concurrency End to End: From rowversion to ETag, If-Match, 412 and a Conflict UI", is the other face of the same family: here we stop the same request from running twice, there we stop two different users from overwriting each other's changes. The last post of the series, "Bulk Import with a Validation Report: Upload Excel/CSV, Staging Table, Row-Level Error File", uses the same idea with a file hash against uploading the same file twice. For transaction mechanics see "Transactions in EF Core: SaveChanges, BeginTransaction and Savepoints" in the EF Core series, for endpoint filters "What's New in ASP.NET Core with .NET 7: MapGroup, TypedResults, Rate Limiting and Output Caching" in the ASP.NET Core Web API series, and for ProblemDetails "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails". Idempotency for message consumers (Idempotent Consumer, Transactional Outbox) belongs to the planned Messaging and Event-Driven Architecture series. Sources: the IETF "The Idempotency-Key HTTP Header Field" draft (draft-ietf-httpapi-idempotency-key-header-08, October 2026, not an RFC); "Idempotent requests" in the Stripe API documentation; the definition of idempotent methods in RFC 9110; RFC 9457 for problem details; the Idempotent Consumer pattern on microservices.io; and the idempotency key note in the "Asynchronous Request-Reply" pattern of the Azure Architecture Center.

A Small Experiment

★☆☆ Lower Retention to 1 minute, create an order and send it again with the same key 2 minutes later. What happens? How would you describe this behavior in the API documentation?

★★☆ Add to the filter, next to the stored response, which user agent the request came from and how many times it was repeated (ReplayCount). Increment the counter on replays and find the clients that retry the most with a report query.

★★★ Kill the process right after SaveChangesAsync in the handler (Environment.FailFast), so the order INSERT has run but the transaction has not been committed. Restart the application and send the request again with the same key. How many orders are there? Looking at the tables in SQL Server, explain what retries get before and after the lease expires, and how the transaction protects you.

We stopped the same request from running twice. But when two different users open the same employee card and both save, the last one can still silently overwrite the first. In the next post we carry EF Core's concurrency token into HTTP's ETag, If-Match and 412 semantics, and from there into a conflict screen in Angular.