I keep seeing the same bug report in signup flows: "I clicked resend, then the old email worked, then the new one failed, and now the UI says verified but support says no." The delivery system may be fine. The frontend model usualy is not.
When a React screen treats every resend like the same event, stale timers and stale responses start to pile up. That is how teams end up arguing over whether the issue lives in the worker, the API, or the browser tab someone left open for ten minutes.
The fix is smaller than it sounds. Give every verification attempt its own id, then let the UI talk about attempts instead of one vague loading state.
Why resend flows break even when delivery works
Most resend flows collapse too much behavior into one shape:
- send an email
- wait for confirmation
- allow resend
- handle an older link click
That looks compact in code, but it hides the most important truth: the first send and the second send are not the same attempt. Once a user asks for a resend, you now have two timelines. If your screen still shows one spinner, your product is already lying a little bit.
This is also why debugging gets messy when QA uses a burner email during manual checks. The inbox can be clean, but the app still may be reading state from the wrong attempt. I have seen teams search logs for tepm mail com and assume the mailbox provider caused the issue, when the real problem was an older promise resolving late.
Give each email attempt its own identity
The simplest useful model is:
- Create an
attemptIdwhen the verification email is sent. - Tie polling, resend actions, and analytics events to that id.
- Ignore responses that come back for an older attempt.
That one move creates a much more honest flow. It is the same discipline behind tracking flaky email runs by id: every run needs a stable label, or your evidence gets blurry fast.
I also like pairing attempt ids with the state ideas in typed verification states in React. One pattern defines what the screen can show, the other defines which send action the screen is talking about. Together they remove a lot of confusing edge cases.
A small React and TypeScript shape that stays honest
Here is the minimal shape I reach for:
type VerifyAttempt = {
id: string;
email: string;
sentAt: number;
status: "sent" | "polling" | "verified" | "expired" | "error";
};
type VerifyState = {
currentAttemptId: string | null;
attempts: Record<string, VerifyAttempt>;
};
Then the resend action always creates a brand new attempt:
async function resendVerification(email: string) {
const res = await fetch("/api/verification/resend", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email }),
});
const data: { attemptId: string } = await res.json();
dispatch({ type: "attempt_started", attemptId: data.attemptId, email });
startPolling(data.attemptId);
}
The important bit is in the poller. If a response comes back for an attempt that is no longer current, drop it:
async function syncAttempt(attemptId: string, signal: AbortSignal) {
const res = await fetch(`/api/verification/${attemptId}`, { signal });
const data = await res.json();
dispatch({
type: "attempt_status_received",
attemptId,
status: data.status,
});
}
In the reducer:
case "attempt_status_received": {
if (event.attemptId !== state.currentAttemptId) return state;
return {
...state,
attempts: {
...state.attempts,
[event.attemptId]: {
...state.attempts[event.attemptId],
status: event.status,
},
},
};
}
This is not fancy, but it is very hard to accidentally mark the wrong resend as verified. That makes the UI calmer and the backend conversation way less anoying.
Show attempt history in the UI, not just a spinner
A lot of frontend teams stop after the reducer cleanup. I think that misses half the value.
If the user can resend, the UI should surface a tiny attempt history:
- "Email sent at 09:41"
- "Resent at 09:43"
- "Waiting for latest link"
You do not need a giant audit panel. A small timeline is enough. It helps users self-correct, and it gives support a screenshot that means something. In practice, this is often more useful than adding another generic toast.
When I do this, I also add two guardrails:
- disable "Resend" for a short cooldown
- clearly mark older attempts as superseded
That keeps people from hammering the button and blaming the API later, which happens more often than anyone wants to admit.
Where temporary inboxes fit without taking over the design
Temporary inboxes are useful here, but only as a test aid. They should not be the architecture.
For QA and manual smoke tests, a disposable email account is handy because it isolates one signup path from a personal inbox. That matters when you want to verify that the latest attempt id is the only one still considered active by the client.
The better product pattern is:
- store the active attempt id in frontend state
- return the same attempt id from the verification status endpoint
- show the latest attempt timestamp in the UI
- use a temporary inbox only to observe delivery behavior
If you do that, temp mail so style testing becomes a clean support tool, not a crutch for unclear product logic. It is a subtle difference, but an important one.
Quick Q&A
Should I cancel old polling requests?
Yes. Abort them when a new resend starts or when the screen unmounts. Even if you ignore stale results in the reducer, canceling old work saves noise and makes debugging cleaner.
What should happen when an older verification link is clicked?
Your API should tell the client that the attempt is no longer current. The UI can then show a helpful message like "That link was replaced by a newer email." It feels a bit stricter, but users understand it prety quickly.
Is this overkill for a small app?
Not really. The code shape is tiny, and it prevents a class of bugs that gets expensive fast once support tickets start coming in. For most React teams, this is one of those small structures that pays back almost imediately.
Top comments (0)