To prevent stale email-validation results, invalidate the current check whenever the field changes, abort obsolete requests, and attach a monotonically increasing generation to each check. Apply a response only if its generation still matches. Cancellation alone is not enough: an earlier response may already be decoded or queued for application when the user edits the field.
This is a correctness problem, not just a spinner problem. Showing yesterday's answer is inconvenient. Showing a correction for the wrong address can replace a valid contact with one the user never intended.
The race in three events
- The user types
user@gmai.comand blurs. Check A starts. - They notice the typo and change the field to
user@gmail.com. Check B starts. - B returns first. A returns later with
suggestion: "user@gmail.com".
Without ownership tracking, A overwrites B's state. Worse, the user might have changed the local part too: a stale suggestion can now offer the wrong identity, not merely an unnecessary domain repair.
A two-second timeout bounds waiting but does not establish response order. Nor does comparing the current text solve every case: the user can change A to B and back to A while an old request remains active. Track the generation, not only string equality.
Keep the transport behind your server
The browser calls your own /api/validate-email route. That route makes the authenticated request to nobounce, validates the response shape, and maps it into an application action. The key never reaches the browser.
curl -X POST https://nobounce.dev/v1/check \
-H "Authorization: Bearer $NOBOUNCE_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"user@gmai.com"}'
Live checks require a paid key. There is no free tier: hobby is $1/mo for 1,000 checks/mo, pro $19/mo for 100,000, and scale $99/mo for 1,500,000. Obtain a key by redeeming an operator-minted access code or paying self-serve through Stripe cards or usevig stablecoins; both rails are live.
Your route's action vocabulary can be suggest, reject, accept, or unchecked. These are application states, not additional API verdicts. Offer any non-null suggestion first, including risky/likely_typo and unknown results carrying a correction. Reject only undeliverable without a suggestion; otherwise accept and preserve check metadata. React verdict handling covers that mapping.
Invalidate on edit, not only on the next request
Here is a browser-side coordinator, independent of a UI framework. check accepts an address and an abort signal and returns your server's action object. publish renders the resulting state.
export function emailCoordinator(check, publish) {
let generation = 0;
let active = null;
let current = "";
function edited(email) {
current = email;
generation += 1;
active?.abort();
active = null;
publish({ status: "idle", email, generation });
}
async function validate() {
const token = ++generation;
const email = current;
active?.abort();
const controller = new AbortController();
active = controller;
publish({ status: "checking", email, generation: token });
let action;
try {
action = await check(email, AbortSignal.any([
controller.signal,
AbortSignal.timeout(2500),
]));
} catch {
action = { action: "unchecked", mxChecked: false };
}
if (token !== generation) return;
active = null;
publish({ status: "result", email, generation: token, action });
}
function useSuggestion(state) {
if (state.status !== "result" || state.generation !== generation ||
state.action.action !== "suggest") return false;
edited(state.action.suggestion);
return true;
}
return { edited, validate, useSuggestion };
}
Call edited on every input change and validate on blur or submit. Local invalidation does not make a paid request. On accepted suggestion, update the controlled input only when useSuggestion returns true, then validate the new value if your workflow requires a fresh result. Do not reuse the original address's check metadata as proof about the correction.
The server-call adapter must throw on non-2xx, malformed JSON, or invalid action shapes. Those become unchecked only for the current generation. Superseded failures are ignored completely: aborting an old check must not mark the newer field unchecked.
The same generation check protects success and failure. Avoid an unconditional finally that clears the spinner: an old request's cleanup can hide the newer request's loading state.
A current timeout is different from an obsolete request
| Event | UI action |
|---|---|
| User edited while a check was pending | Discard the old answer; leave the new input idle |
| Newer check replaced an older check | Discard the older answer; keep the newer state |
| Current request timed out | Allow signup with an unchecked annotation |
Current API result is unknown, checked.mx: false |
Preserve the API's degraded result; do not invent deliverable |
Fail-open means a current unavailable check cannot block registration. It does not mean an old failure should authorise submitting a different, unchecked field while claiming it passed. Timeout handling explains the distinction between an unavailable request and a completed DNS-error verdict.
On submit, capture the address and generation together. If editing remains enabled during validation, verify they still belong to the current field before account creation. Otherwise lock the field for the bounded submit operation. The frontend check remains advisory; your backend owns account creation and its validation policy.
Test responses in the wrong order
Use deferred promises in a local test, not real network timing. Start A, edit, start B, resolve B, then resolve A. The final published result must belong to B. Repeat with A rejecting instead of resolving.
Also test:
- edit without starting B: A must not restore a suggestion;
- A → B → A: the first A must remain obsolete;
- current timeout: signup remains available, marked unchecked;
- stale suggestion click:
useSuggestionreturns false; - accepting a current suggestion: input changes explicitly and old metadata clears.
Deterministic validation tests show how to separate policy from transport. /demo/check needs no key but is fixture-only; it cannot validate arbitrary real addresses or reproduce browser races for you.
Do not log request bodies or suggestions; both contain addresses. nobounce persists no plaintext address; anything persisted is SHA-256 hashed, with domains in clear. Your browser telemetry needs its own safeguards.
For the production request, key handling, and full signup policy, give your coding agent /integrate.md.