Why disabling the button did not stop the duplicate request


Sooner or later, every product that takes bookings or payments sees the same thing happen: one action, two records. A customer gets the confirmation email twice, and the admin view shows two bookings, even though they’re sure they only pressed the button once.
The quick fix is to disable the button after the first click. That’s worth doing, because it improves feedback and removes one source of duplicates. But it doesn’t explain the rest. Servers receive the same request twice after a timeout, queues redeliver messages and payment providers resend webhooks. None of that is a user mistake. It’s normal behaviour for systems that would rather retry than lose work.
That’s where idempotency comes in. Instead of asking how to stop someone clicking twice, it asks what must stay true when the same operation arrives more than once.
In this article, we’ll follow the second request from the button to the database and look at how to make a repeated operation safe.
How the second request still arrives
Even with the button disabled, this sequence produces a second request:
From the user’s perspective, the first attempt may have failed; from the server’s perspective, it may already have succeeded. The same problem appears with integrations:
In a system that prefers retrying work to silently losing it, that duplicate is expected behaviour rather than a mistake.
Delivery and effect are different things
A request can be delivered multiple times; a business effect should occur only when it is valid. That distinction matters for operations such as:
The transport layer may only be able to promise at least once delivery. The application needs to make the business operation idempotent:
For example:
The first valid confirmation should move the booking into `confirmed`; a repeat confirmation should return the already-confirmed result, and it should not create another booking, trigger another entitlement, or send another “booking confirmed” email.
What identifies this operation?
The system needs a durable identity for the attempted operation; for a client-created mutation, that may be an idempotency key:
POST /api/bookings/B-1042/confirmIdempotency-Key: 7ccd5c5a-3e81-4ac1-8b20-1d1e9ea81f44For a provider webhook, it is commonly the provider’s immutable event ID. The application persists the identity before—or atomically with—performing the important work.
The next request with the same key can be handled safely:
The in-progress branch assumes the first attempt will finish. If the process that claimed the key crashes before completing, every retry is told the operation is still pending, indefinitely. An in-progress record therefore needs a recovery boundary: a lease or expiry after which the operation can be retried or reconciled against what actually happened. The right boundary depends on how long the operation can legitimately take, and it has a risk of its own, because a slow attempt that is still running can overlap with the retry. The atomic guarantee described below still has to hold. Records that stay in progress long past their boundary are worth surfacing to operators, since each one is an outcome nobody is producing.
The key turns “I think this is the same request” into something the system can verify.
Why an in-memory check fails
It can be tempting to track recent request IDs in memory:
if (processedRequests.has(key)) { return previousResult;}That may work during a local test, but it fails under exactly the conditions idempotency is meant to address. A process can restart, a second application instance can receive the retry, a deployment can move traffic, or a concurrent request can arrive before the first process has stored the result. A serverless function may not share memory with the function that handled the previous delivery. The idempotency state must live in a shared, durable system of record. For an operation that changes database state, the database is often the right authority.
The database must resolve the race
A check followed by an insert is not enough if two requests arrive at once:
Both requests saw the same world before either committed a result. The protection needs an atomic boundary:
unique (provider, provider_event_id)or:
unique (idempotency_key)Then the database resolves the concurrency; one request creates the record. The other discovers that the operation already exists. That is a more important guarantee than the client-side loading state.
The difficult gap: work succeeded but the response did not
Idempotency matters most on the uncertain path rather than the happy one. Imagine a request that successfully updates the database, but the process crashes before it replies. The client sees a timeout and retries. Without a stored operation identity and result, the server cannot distinguish:
from:
The safe response is to make both cases converge on the same final state. That requires the system to store enough information to explain what happened later:
That chain is useful not only for duplicate safety but also for support, auditing, incident response, and recovery.
Retrying needs a policy too
Retries are not inherently safe. A temporary network failure may justify a retry, but a validation error, invalid signature, permission denial or malformed request generally does not. A useful failure model distinguishes:
The application should define:
- Which failures are retryable
- How many attempts are allowed
- How backoff is applied
- When an operation becomes visible for manual review
- What stops a retry from creating a second effect
A retry loop without a durable operation identity is just a more efficient way to duplicate work.
Testing for duplicate safety
The most valuable test goes beyond “the endpoint returns 200”:
For a webhook:
A disabled button improves the interface, while idempotency protects the business operation. When the workflow matters, build both, and do not mistake one for the other.