A user clicks Pay.
The spinner runs for ten seconds.
Then your app displays:
Something went wrong. Please try again.
So they do.
A few minutes later, they find two charges.
Your frontend saw a failed request. Your backend may have completed a successful payment.
Those two things can happen at the same time.
That gap is where a harmless retry becomes an expensive bug.
And it affects more than payments: orders, bookings, emails, inventory updates, and anything else that changes state.
A timeout does not mean nothing happened
Imagine this sequence:
- Your app sends a request to create an order.
- The server creates it.
- The connection drops before the response reaches the app.
- The app reports a network error.
- The user retries.
- The server creates another order.
Both requests worked on the server.
The client only received one successful response.
A timeout means the client stopped waiting. It does not prove the server stopped working.
After a connection failure, the outcome may be unknown.
Treating “unknown” as “failed” is the mistake.
Disabling the button helps—but it isn’t enough
Disabling the submit button while a request is running is good UX.
It prevents some accidental double clicks.
But it cannot protect you from every duplicate:
- The user refreshes the page.
- A mobile connection drops.
- Two tabs submit the same action.
- A client automatically retries a request.
- A background job runs again after a worker crashes.
A frontend safeguard cannot enforce a backend guarantee.
If doing the same action twice would cause harm, the server needs a way to recognize the repeat.
Give the action an identity
Suppose a user is confirming a booking.
Instead of sending only the booking details, the client also sends an identifier for that particular confirmation attempt:
POST /bookings
Idempotency-Key: 7f09c2e1-...
Content-Type: application/json
The server can use that key to recognize another delivery of the same operation.
The intended behavior is:
First request:
Create the booking and record the result.
Retry with the same key and same payload:
Return the recorded result.
Same key with a different payload:
Reject the request.
This is an idempotency key.
The practical goal is simple:
Repeating the same operation should not create another effect.
The key identifies an operation, not the user forever. A genuinely new booking needs a new key.
The easy mistake: generating a new key on every retry
This looks reasonable:
async function submitBooking(payload) {
return fetch("/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
}
But every call creates a new key.
If you call this function again after a timeout, the server sees a new operation. The duplicate protection cannot help.
Instead, create the key once for the intended action and reuse it when retrying that same action:
const operation = {
key: crypto.randomUUID(),
payload: bookingDetails,
};
async function sendBooking(operation) {
return fetch("/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": operation.key,
},
body: JSON.stringify(operation.payload),
});
}
This is a simplified example. If retries must survive a refresh or app restart, the operation identity needs to survive too.
And if the user changes the booking details, decide explicitly whether that is a new operation. Do not quietly reuse the old key with different data.
The server must handle simultaneous requests
There is another trap.
You write:
Check whether the key exists.
If it does not exist, create the booking.
Save the key.
Now two requests arrive together:
Request A checks → no key found
Request B checks → no key found
Request A creates a booking
Request B creates a booking
The check exists. The duplicate still happens.
Checking and claiming an operation must be safe under concurrency.
For a database-backed operation, a common design includes:
- A unique constraint on the key within the authenticated account and operation scope.
- An atomic claim of the operation.
- A stored fingerprint of the relevant request data.
- A transaction connecting the local business change and recorded result.
- Defined behavior when the operation is still in progress.
The exact implementation depends on your system, but “check, then insert” without concurrency protection is not enough.
Also, an idempotency key does not replace authentication or authorization. A replayed result must remain accessible only to the appropriate caller.
External side effects need their own protection
Creating a database row and charging a payment provider are different kinds of work.
Your database transaction cannot roll back a charge that already happened elsewhere.
Consider this failure:
- Your server sends the payment request.
- The provider processes it.
- Your server crashes before recording the result.
- Your worker retries.
For providers that support idempotency, reuse the corresponding stable operation key according to their documented rules.
For other effects, you may need a durable job record, deduplication, or reconciliation.
Be precise about the guarantee:
Preventing duplicate database rows does not automatically prevent duplicate emails or charges.
Every side effect deserves its own retry story.
Your error message should reflect what you know
When an outcome is uncertain, this message can push users toward a duplicate:
Payment failed. Try again.
A more accurate message might be:
We couldn’t confirm the payment status. We’re checking it now.
Your app can then query the operation’s status before inviting another submission.
You still need backend protection. Better wording alone will not solve the bug.
But the interface should help users understand what is happening instead of encouraging repeated clicks.
Test the failure after success
Most happy-path tests check:
Send request → receive success.
The interesting test checks:
Complete the action → lose the response → retry.
For a booking endpoint, test these cases:
- Same key, same payload: one booking exists.
- Two simultaneous requests with the same key: one booking exists.
- Same key, different payload: the request is rejected.
- Completed operation, lost response, then retry: the existing result is returned.
- Operation still running: the retry follows your documented in-progress behavior.
Also define what happens when stored keys expire. Deduplication usually has a retention window; it is not necessarily a forever guarantee.
These tests check the behavior that ordinary “endpoint returns 201” tests miss.
A useful prompt for your AI assistant
Before asking AI to add automatic retries, try this:
Review this state-changing operation for duplicate effects.
Do not modify code yet.
Identify:
1. What happens if the server completes the action
but the client never receives the response.
2. Whether concurrent requests can duplicate the effect.
3. How the same operation is identified across retries.
4. Whether that identity survives a refresh or restart.
5. Which external side effects need separate protection.
6. What happens when an operation is in progress
or its deduplication record expires.
Then propose the smallest safe change
and tests that simulate success followed by response loss.
State any assumptions or missing evidence.
This gives the assistant a concrete failure model to inspect.
“Add retry logic” leaves too much unsaid.
Before you ship “Try Again”
Ask one question:
If the first attempt actually succeeded, what will the second attempt do?
If the answer is “charge again,” “book again,” or “decrease stock again,” the button needs more than a loading spinner.
Retries are useful. Make the operation safe to repeat before making it easy to repeat.
Have you encountered a duplicate-action bug? What got repeated—and where did you add the protection?
Top comments (6)
One thing worth emphasizing is that idempotency solves duplicate effects, but it doesn't solve retry amplification. If the frontend, API client, and background worker all retry independently, a single upstream failure can multiply the load on an already struggling service. Exponential backoff with jitter, a bounded retry budget, and a clear decision about which layer owns retries are just as important as the idempotency key itself. Otherwise, the system may avoid duplicate bookings and still take itself down during an outage.
Bài viết chạm đúng điểm đau: nút "Try Again" vô thức khuyến khích người dùng spam request khi backend đang 과부하 hoặc đang trong trạng thái nửa vời (network timeout, DB deadlock, third‑party gateway chậm).
Thực tế gặp nhiều case:
Một pattern an toàn hơn:
Idempotency-Key(UUID v4) vào header khi gọi API thanh toán; server dùng key đó để deduplicate trong window 24‑48h.error_code: "INSUFFICIENT_FUNDS"thay vì chung chung) để FE quyết định show "Try Again" hay "Change payment method".Good point that the server has to be the real guard. One thing I'd add on the client for the "two tabs" case: if you persist the pending operation (key + payload) to localStorage so it survives a refresh, a second tab will generate its own key and still double-submit. Keying the pending operation by the user action (e.g. cart or booking id) rather than per tab, and checking for an in-flight one first, helps a lot. The Web Locks API is handy for the "only one tab submits" part. And then the idempotency key on the server catches whatever slips through.
This article addresses realistic use cases regarding duplicate submissions, and I feel that gaining a deeper understanding from this perspective is crucial.
Personally, when considering countermeasures against duplication, I make it a habit to evaluate the frontend and backend separately.
The "button disabling" technique you mentioned serves as a prime example of why frontend-only measures are insufficient.
When strict avoidance of data duplication is required, backend measures are essential alongside frontend ones. Database-level locking is one effective option. It is also necessary to verify the existence of a record during processing, and utilizing Redis is another viable, more lightweight solution. Ultimately, one must determine the necessary level of prevention and the required performance standards. It is important to identify these factors, clarify the requirements, and then translate them into a concrete design.
This is an interesting article that offers valuable insights into points requiring careful consideration.
Loved how practical this is. So many LLM posts stay at surface level, but addressing real implementation trade-offs is where the value is.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.