← Back to articles
Technology

Design for Idempotency Before You Ship

Design for Idempotency Before You Ship

"I clicked it once and it charged twice" is a design-phase problem

Some production bugs aren't implementation mistakes — they're things nobody decided at design time. Double execution is a classic example. A user double-clicks a payment button, a flaky connection triggers an automatic retry, or a webhook delivers the same event twice. Whatever the trigger, the same operation runs twice: an order gets created twice, a balance gets deducted twice, a notification gets sent twice.

What makes this class of bug painful is that by the time you notice it in the implementation phase, fixing it often means touching the database schema or the API contract itself. Idempotency isn't a control you can bolt on later — it's a constraint you need to decide during design.

First, identify which operations actually need it

Not every API needs idempotency. The test is simple: if the same request arrives twice, is it unacceptable for state to change twice?

  • Needs it: payment execution, order creation, point/credit issuance or consumption, sending emails or notifications, processing incoming webhooks
  • Usually safe already: reads (GET), PUT that overwrites with the same value (sending it twice produces the same result), DELETE (if designed so deleting a nonexistent record isn't an error, it's naturally idempotent)
  • Easy to miss: aggregation/increment logic inside batch jobs, and any async job that your queue might retry (job queues like Solid Queue are often built assuming retries will happen)

A failure pattern: retries enabled with no dedup check

In one payment API, the frontend was designed to automatically retry on timeout. The backend, however, was built on the assumption that "one request equals one charge," with no dedup check in place. On an unstable connection, a timeout and a retry overlapped, and the same order got charged twice. The root cause wasn't the frontend's retry logic — it was a backend design that never accounted for idempotency in the first place.

Three implementation patterns, and when to use each

1. Idempotency keys

The client generates a unique key (a UUID, typically) per logical request, and the server returns the original result for any repeat of that key. This is the standard pattern for payment APIs.

POST /api/v1/payments
Idempotency-Key: 3f9a2b1c-...

# Server-side pseudocode
if IdempotencyKey.exists?(key)
  return IdempotencyKey.find(key).cached_response
end
result = process_payment(params)
IdempotencyKey.create!(key: key, cached_response: result)
return result

Store the key and its result together for a defined window (say, 24 hours), and return the stored response instead of re-running the operation when the same key shows up. If key generation is left to the client, validate that it's actually collision-resistant (a real UUID, not a timestamp).

2. Database unique constraints

When a business rule implies uniqueness — "at most one successful payment per order" — trust a DB constraint over an application-level check. Race conditions routinely slip past application code; the database constraint is your last line of defense.

# Migration example
add_index :payments, [:order_id], unique: true, where: "status != 'failed'"

Because another request can slip in between your check and your insert, "we checked first, so it's safe" doesn't actually hold. The safe pattern is to attempt the write, catch the unique-constraint violation, and treat it as "already processed."

3. UI-level duplicate-submit prevention

Beyond server-side safeguards, prevent the double-click itself on the frontend — disable the submit button immediately on click, show a loading state while the request is in flight. It's a small thing, but it works. That said, this is a supplement, not the primary defense. It doesn't help with JavaScript-disabled clients, simultaneous action across multiple tabs, or automatic retries — which is exactly why server-side idempotency has to be the real safety net.

A design-review checklist

  • If this endpoint is called twice, does the result change? Should it?
  • If using idempotency keys, are the retention window and scope (per-project? per-user?) explicitly decided?
  • Does every combination that needs uniqueness have a DB unique constraint behind it, not just an application check?
  • If a webhook delivers the same event ID twice, does the side effect still happen exactly once?
  • If an async job gets retried by the queue, is running it twice harmless?

Putting this into practice with Bugoon

Double-execution bugs are notoriously hard to pin down from a report alone — "I clicked it twice" and "I hit back and resubmitted" look identical from the outside, and it's often unclear which action actually triggered the duplicate. Bugoon's widget captures the interaction steps leading up to a report, so you can see the actual click sequence and page transitions before the issue was flagged. That history is useful for telling apart "the user really did click twice" from "one click triggered a duplicate on the server side."

Having request-level details captured alongside the reproduction steps would likely make that distinction even easier to draw.

Streamline bug reporting for your team.

Bugoon is free to get started. Add one line of code to your site and transform how your team handles bugs.

Get Started