DEV Community

Cover image for The Most Dangerous Button in Your App Is “Try Again”
Robert Adamson
Robert Adamson

Posted on

The Most Dangerous Button in Your App Is “Try Again”

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:

  1. Your app sends a request to create an order.
  2. The server creates it.
  3. The connection drops before the response reaches the app.
  4. The app reports a network error.
  5. The user retries.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Your server sends the payment request.
  2. The provider processes it.
  3. Your server crashes before recording the result.
  4. 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:

  1. Same key, same payload: one booking exists.
  2. Two simultaneous requests with the same key: one booking exists.
  3. Same key, different payload: the request is rejected.
  4. Completed operation, lost response, then retry: the existing result is returned.
  5. 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.
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
sinarezaei profile image
Sina Rezaei •

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.

Collapse
 
xxejktefwfjk profile image
xxejktefwfjk •

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:

  1. Thiếu idempotency key ở client → mỗi lần bấm gửi request mới, payment service tạo đơn hàng trùng → charge gấp đôi/gấp ba.
  2. Không phân biệt lỗi transient vs permanent – retry 500/503 có thể ok, nhưng 400/402/409 thì retry chỉ làm tình huống tồi tệ hơn (ví dụ: thẻ hết hạn, fraud block).
  3. UI không disable button ngay khi spinner chạy → user bấm liên tục vì tưởng chưa gửi.

Một pattern an toàn hơn:

  • Gắn Idempotency-Key (UUID v4) vào header khi gọi API thanh toán; server dùng key đó để deduplicate trong window 24‑48h.
  • Trả về mã lỗi chi tiết (error_code: "INSUFFICIENT_FUNDS" thay vì chung chung) để FE quyết định show "Try Again" hay "Change payment method".
  • Exponential backoff + jitter ở client nếu tự retry (ví dụ network flaky), nhưng chỉ retry idempotent GET/HEAD hoặc POST có key.
  • Hiển thị trạng thái thực tế: "Đang xác nhận với ngân hàng…" thay vì spinner vô — found it via LabAgent, site: labagent .tech
Collapse
 
idanshalem profile image
Idan Shalem •

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.

Collapse
 
puffball1567 profile image
puffball1567 •

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.

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

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.