DEV Community

ryanlee
ryanlee

Posted on

React Resend Buttons Without Duplicate Emails

Resend buttons look tiny in the UI, but they can create a surprisng amount of product mess. I have seen otherwise solid React flows send two verification emails, keep the spinner alive too long, or show "email sent" even though the backend rejected the retry. None of those bugs are dramatic alone, but together they make the whole signup experience feel cheap.

When I build this flow now, I treat the resend action like a small distributed system. React owns the interaction and timing feedback. Node.js owns whether a new email is actually allowed, which attempt number it is, and whether the request should collapse into an existing pending send. That split has made the feature a lot less fragile for me.

Why resend flows break more than teams expect

The usual bug is not "email failed." It is that three states drift apart:

  • the button state in React
  • the resend rule on the server
  • the inbox a tester is watching

That drift shows up in boring but expensive ways. A user clicks resend twice because the first click felt ignored. React optimistically says the message was sent. Then Node.js retries a worker job and now two emails land. Support gets a screenshot, engineering sees one successful API call, and QA says they got duplicates. Everyone is technically right, which is why this bug wastes so much time.

I also try not to test these flows with shared inboxes anymore. For staging checks, a disposable inbox from tempmailso gives each run its own mailbox, which makes resend timing easier to reason about. That matters even more when your team writes rough notes like tempail or tempail mail in issue comments and accidentally turns one fuzzy test case into three different meanings.

If you already have patterns for stable approval email handling, reuse them here. The core problem is similar: UI events should not directly imply message delivery success.

The React and Node.js contract that keeps resends clean

The cleanest version I have found is to let the backend return resend metadata every time the client asks for account status. React should render from that server truth instead of maintaining its own countdown guesses for too long.

I normally return something like:

type ResendState = {
  canResend: boolean;
  cooldownSeconds: number;
  attemptCount: number;
  pendingMessageId?: string;
};
Enter fullscreen mode Exit fullscreen mode

On click, the client posts a resend request, but the server decides one of three outcomes:

  1. Accept and enqueue exactly one new message.
  2. Reject because the cooldown window is still active.
  3. Reuse the existing pending message if the previous attempt is still in flight.

That third case is the one teams forget a bit too often. If you only think in terms of accept or reject, retries from flaky networks can produce duplicate sends even when the user clicked once. I want idempotency on the API boundary and dedupe in the job layer. Otherwise the product looks okay in demos and weird in prod.

Here is the kind of React handler I like:

async function handleResend() {
  setSubmitting(true);
  const result = await resendVerification();
  setBanner(result.message);
  const freshState = await fetchResendState();
  setResendState(freshState);
  setSubmitting(false);
}
Enter fullscreen mode Exit fullscreen mode

It is intentionally not fancy. I do not try to maintain a perfect local model after the mutation. I re-fetch because the backend knows if the cooldown changed, if attemptCount incremented, and if a pending send already exists. That extra request is usually worth it, even if it feels mildly unsexy.

For Node.js, I like storing a resend key shaped from userId, template, and a short time bucket. That makes avoiding duplicate email sends much easier across queue retries.

How I test resend behavior without noisy inboxes

My test pass is small on purpose. I do not need ten scenarios before shipping the first version. I need a few checks that prove the contract works:

  • first click sends one email
  • second immediate click does not send another
  • cooldown expiry allows exactly one more email
  • success text in React matches the actual backend outcome

I often keep the inbox assertion outside the browser test and pair it with an API assertion for send count. That way I can tell whether the bug lives in rendering, server policy, or delivery plumbing. If I only check the UI, I miss too much.

One useful benchmark from the product side: the Nielsen Norman Group has long noted that users start feeling delay once responses move past roughly 1 second, and attention gets strained much more around 10 seconds (https://www.nngroup.com/articles/response-times-3-important-limits/). For resend flows, that is a good reminder that cooldown feedback has to be explicit. If the user does not understand whether the first action worked, they click again, and your backend suddenly has a duplicate-email problem that started as a clarity problem.

A short checklist before shipping

  • disable the button only while the request is in flight, not for mystery reasons
  • render cooldown time from server data, not a guessed client timer alone
  • record an idempotency key for each resend request
  • assert message count at the job or event layer
  • keep QA inboxes isolated per run

This is one of those features that rewards boring engineering. A resend flow does not need a giant abstraction, just a clear contract between React and Node.js and a test setup that can prove what really happened. When that contract is sharp, the whole signup path feels calmer and way more trustworthy.

Q&A

Should I debounce the button on the client?

Yes, but only as a courtesy. Debounce helps with accidental double clicks, but it should never be your real protection.

Is a queue required?

Nope. A synchronous sender can work fine at smaller scale. The important part is still idempotency and honest state coming back to React.

What usually causes the ugliest bug?

Stale success UI. The email send fails or gets reused, but the page still says "sent again" like everything was perfect. Users notice that stuff fast.

Top comments (0)