Consider a React registration form backed by a Node.js service. A user requests a one-time password (OTP), waits, and clicks Resend. The replacement arrives first; the original arrives later. They paste the last message they received and see “Invalid code.”
The problem is not necessarily failed delivery. In this scenario, the application has replaced the code but has not helped the user understand which request is current. Duplicate network requests and overlapping responses can make the situation harder to diagnose.
Design the recovery path as carefully as the first send. That means separating a verification attempt from its messages, distinguishing retries from resends, and making the backend—not message arrival order—the authority on validity.
Give the verification attempt its own identity
Create a server-owned attempt representing a specific action: confirm this destination number for this registration session. Bind an opaque attempt identifier to the session, the normalized phone number, and the purpose. Do not let a registration code authorize account recovery or a change to an existing account’s number.
Keep the attempt’s status, current code revision, code expiry, and overall deadline explicit. A revision identifies a replacement code within the same attempt. A resend can replace that revision without creating a different registration session or extending the overall deadline indefinitely.
Record delivery work separately. A message can be queued or reported delivered while the verification attempt remains pending. Only successful code verification should complete the attempt. Editing the destination should cancel the old attempt and require confirmation of the new number.
A retry repeats work; a resend replaces a code
Treat an automatic retry as another submission of the same operation. For example, the browser might lose the response after the backend accepted a send request. Attach a request key to that operation and reuse it for retries. Store the result under a uniqueness constraint scoped to the session and attempt; reject reuse of the key with different input. This is idempotency: repeating the request does not repeat its effect.
A deliberate resend is a different operation. For the replacement policy used here, OWASP’s OTP handling guidance recommends generating a fresh code and replacing the previous one. It also recommends short validity, strict attempt limits, single use, cryptographically secure generation, and keeping code values out of logs and long-term plaintext storage.
The resulting rule should be unambiguous: once revision B replaces revision A, only B can complete that attempt. Do not revive A merely because it arrives later or B’s delivery fails.
Deduplicate concurrent requests before creating delivery work. An idempotency key is useful only when simultaneous submissions cannot both pass the “not seen before” check.
Serialize the transitions, not just the button clicks
For application-managed codes, make resend and verification compete over the same database state. Use a transaction with a row lock, or an equivalent atomic conditional update, rather than a JavaScript variable that protects only one running process.
During verification, check the session binding, purpose, pending status, current revision, expiry, and applicable attempt limits. Validate the submitted code and transition to verified as one coordinated operation. A competing request must not also consume the same code or overwrite the completed attempt.
During resend, check eligibility, replace the revision, and persist the delivery job together. A transactional outbox—a database record that a worker later processes—can make those local writes succeed or fail together. Keep provider network calls outside the database transaction.
An outbox does not guarantee a single external send. A worker may lose a provider response after acceptance. Use provider-side idempotency where documented; otherwise reconcile uncertain outcomes before retrying blindly. Skip jobs already known to be superseded, but do not assume that this can recall a message already dispatched.
This creates a testable ordering rule: if verification commits first, the later resend must not reopen the attempt. If resend commits first, verification must use the replacement revision.
Make the React screen reflect server state
Disable Resend while its request is pending, but enforce its cooldown on the backend too. Return the current attempt reference, revision, status, expiry, and resend eligibility to the frontend. These are suggested application response fields, not requirements for a messaging provider’s API.
Associate responses with the operation that produced them. A delayed response for an older revision should not replace the current screen, and a late “message accepted” response should never overwrite a verified state. After an uncertain request outcome, retrieve the current attempt state rather than assuming failure and creating another code.
Avoid the instruction “Use the last message you received.” Under a replacement policy, arrival order is not authoritative. Prefer: “A replacement code was requested. Earlier codes no longer work.” Where message templates permit, a non-secret request label visible in both the message and the form can help distinguish requests. Keep a clear route to correct the number or request another code when eligible.
Preserve limits across resends and channel changes
NIST’s SP 800-63B-4 guidance on out-of-band authentication requires that generating a new authentication secret not reset the failed-authentication count. It also identifies out-of-band authentication as not phishing-resistant. For sensitive account actions, require risk-appropriate authentication instead of treating a previously confirmed phone number as sufficient authorization.
Apply the retry-budget principle to the registration design: a new revision, browser refresh, or switch of delivery channel should not provide fresh guessing allowances. Keep relevant counters outside the replaceable code record, tied to the account or registration identity and destination. Define their reset rules explicitly.
Separate limits on checking codes from limits on generating messages. Add destination-level sending controls, broader abuse detection, and a spending ceiling appropriate to the application. Otherwise, protecting the code-entry handler leaves message creation as a separate abuse path.
Keep the provider behind a server-side boundary
Choose who owns the code lifecycle before integrating delivery. With a messaging-only integration, your backend owns code generation and checking. With managed verification, bind the provider’s verification reference to your own session, destination, purpose, and attempt. Confirm results through trusted server-side communication; never accept a browser’s assertion that verification succeeded.
For a flow serving Saudi mobile numbers, Tawked is a regional option whose published service page describes API-based verification through SMS or WhatsApp. The integration question is how that verification lifecycle maps to the application’s resend policy, not simply whether a message can be sent.
Review the selected provider’s documented retry and replacement behavior before implementing it. Do not maintain a competing local code generator alongside managed verification. Keep credentials on the server and account activation idempotent, so repeated completion processing cannot create duplicate accounts.
Keep verification messages transactional: identify the service and requested action without adding promotional offers. Treat marketing preferences as a separate product flow, not a by-product of confirming a phone number.
Measure recovery, then test the awkward sequences
Instrument the registration attempt rather than counting every send as another user. Track attempts started, attempts completed, attempts requiring a resend, and attempts abandoned or expired. Define resend-assisted completion as completed attempts that used resend divided by all attempts that used resend, using a consistent observation window.
Join application events to provider status using internal references. Keep accepted, delivered, and verified outcomes separate; a delivery event must never activate an account. Use masked destinations and non-secret correlation identifiers in operational views, not code values or message bodies.
Before launch, make the following sequences part of the integration tests:
| Test sequence | Required outcome |
|---|---|
| Retry the same send operation after losing its response. | Recover the existing operation; do not create a new code revision. |
| Deliver the original message after its replacement. | Reject the superseded code and keep the screen aligned with the active request. |
| Submit a valid code while a resend runs concurrently. | Apply one consistent ordering; never reopen a verified attempt. |
| Resend or switch channels after failed guesses. | Preserve the applicable failed-attempt budget. |
| Report message delivery without submitting a code. | Leave the registration attempt unverified. |
Start with the delayed-message test. If the team cannot explain which code remains valid, what the user sees, and which counters survive a resend, the verification flow is not ready—even when every message reaches its destination.
