Idempotency: The Property That Makes Retries Safe

Retries, double-clicks, and at-least-once delivery mean almost every operation in a web app eventually runs twice. What idempotency actually means, where each layer of the stack gets it from, and how to test for it.

Idempotency: the property that makes retries safe

An operation is idempotent when running it twice leaves the same result as running it once. That sounds like a library-science definition until you count how many ways a web app runs things twice: the user double-clicks Submit, the network times out after the server did the work but before the client saw the response, a queue retries a job whose worker died halfway, a payment provider redelivers a webhook. None of those are anomalies. They are what distributed systems do on an ordinary Tuesday.

The bugs idempotency prevents are expensive and specific: a card charged twice, a subscriber granted a month twice, an inventory reduced by two. The unhandled second write is the classic "we don't know how that happened" support ticket.

Where idempotency comes from, layer by layer

Natural idempotency

Some writes are safe by construction. SET status = 'paid' is idempotent no matter how many times it runs; increment('counter', 1) is not. firstOrCreate and upserts are idempotent; blind inserts are not. A large share of the fix costs nothing: state-based writes instead of delta-based writes wherever the model allows it.

// delta write: two runs subtract twice
$account->decrement('balance', $amount);

// state write: the second run changes nothing
$payment->update(['status' => 'paid', 'paid_at' => now()]);

Idempotency keys: the client supplies the memory

When the operation isn't naturally safe to repeat, the fix is a stable identifier for the attempt that the server deduplicates on. The client generates a UUID once per logical operation and sends it with the request (Stripe popularized this as the Idempotency-Key header). The server stores the key against the outcome and returns the same result for replays:

$result = Cache::get("idem:{$key}");
if ($result) {
    return $result; // same response as the first, successful attempt
}

$result = DB::transaction(fn () => $this->charge($request->validated()));
Cache::put("idem:{$key}", $result, now()->addHours(24));
return $result;

Three details make or break it. The key has to survive the retry path end-to-end: generated once at the start of the user action, not regenerated by a retry interceptor that "helpfully" makes a fresh one per attempt. The stored outcome should include the response status and body, so replays return what the first attempt returned. And the lookup-plus-write needs its own uniqueness guard (a unique index on the key column beats a cache check), or two concurrent first attempts both miss the cache.

Constraints: the database holds the contract

The most reliable idempotency mechanism is one that stays true under concurrency: a unique index. An idempotency key stored in a UNIQUE column, a (coupon_id, customer_id) pair, a deduplicated provider event ID: when the second insert loses, the constraint makes the duplicate a caught violation instead of a second row. Application-level checks are a friendlier error message on top; the constraint is the thing that's true. We covered the concurrency mechanics in our piece on race conditions, and idempotency is the design-level answer to the same problem.

The webhook and queue cases

External systems redeliver and retry by design, so their handlers are the most tested idempotency code in your app:

  • Deduplicate on the sender's event ID, stored in the same transaction that applies the work, so a concurrent duplicate delivery can't slip between check and apply.
  • Return 2xx for a duplicate instead of treating it as an error, or the sender's retry logic will turn your dedupe problem into a retry storm.
  • Queue jobs should carry their own guards: check the flag that says the work is done (if ($invoice->receipt_sent_at) return;) before doing it, and expect the job to run twice. The job that "only runs once" is the job that runs its most expensive side effect twice.

Our pieces on receiving webhooks and the outbox pattern go deeper on both sides of that; the shared idea is the same: store evidence of what's been done before relying on any code's memory.

Testing for idempotency

If nothing is idempotent until it's tested, the test is the deliverable. Per sensitive endpoint or job, run the same operation twice and assert equivalence rather than a clean result:

it('charges once for duplicate uploads', function () {
    $key = (string) Str::uuid();
    [$a, $b] = [
        $this->withHeader('Idempotency-Key', $key)->post('/payments', $payload),
        $this->withHeader('Idempotency-Key', $key)->post('/payments', $payload),
    ];

    expect($a->status())->toBe($b->status())
        ->and(Payment::where('idempotency_key', $key)->count())->toBe(1);
});

For queue jobs, dispatch the same job twice, assert the side effect happened once. For webhook handlers, forward the same event twice, then the out-of-order variant we describe in the webhook article. The test suite that has never made an operation fight itself will keep a "processed twice" bug hidden for exactly as long as it takes to reach a paying customer.

The checklist

  • Prefer state-based writes over delta writes; reserve counters and decrements for paths that can't repeat.
  • Assign an idempotency key to every money path and every long-running mutation; store full outcomes rather than seen flags.
  • Enforce duplication rules with unique constraints, and keep them even where the app already checks.
  • Webhook and queue handlers dedupe on stable IDs inside the applying transaction.
  • Every idempotent path has a test that runs the operation twice and asserts one effect.

Idempotency is one of those properties that costs minutes per endpoint while you're building and saves afternoons of reconciliation every quarter after. The flows that deserve it most are easy to name: charge a card, send a code, create a resource, deliver an event. Make those four safe to repeat and the retry mechanisms everywhere else in the stack become assets instead of liabilities.