TL;DR: For a Next.js phone verification login, let the SMS OTP countdown guide the user, but make the backend the authority for every resend. In a logistics operation, use signed, replay-safe events to suppress bounced recipients before the next dispatch update; poll only when the upstream system cannot push events or reconciliation is the actual requirement.
| Choice | Delivery-reliability fit | Operational trade-off |
|---|---|---|
| Event webhook | Fast path from bounce to suppression | Requires signature checks, idempotency, and a durable queue |
| Status polling | Useful fallback and reconciliation path | Leaves a window between checks and consumes work when nothing changed |
My default is the webhook. A logistics system can send several messages about the same shipment, so a delayed suppression decision can repeat a known-bad delivery attempt. Polling remains valuable as a repair loop, but it should not own the first decision.
Fast path first.
How should a Next.js phone login control SMS OTP verification?
A countdown is client state. It can reduce impatient clicks, but a tab refresh, a second device, or a direct request can bypass it. Backend state survives all three. Put the next permitted attempt, recipient status, event identity, and decision reason in one durable record, then make every sending path consult that record.
This distinction matters for both login codes and transaction or shipment alerts. The UI can say "try again in 28 seconds." Only the server can decide whether another message should exist.
Keep the states small. active, temporarily_blocked, and suppressed are enough for the control plane in many systems; provider-specific diagnostic text belongs beside the raw event, not inside business logic. A permanent invalid-recipient signal should stop later sends. A temporary condition needs a bounded retry policy and an expiry. Unknown input should be quarantined for review rather than guessed into either category.
That is the first criterion: the decision must be durable and shared by every sender. If the login service and the dispatch-alert worker maintain separate blocklists, reliability depends on which one saw the bounce first. That is configuration debt disguised as autonomy.
The two clocks that actually matter
There are two clocks, and mixing them creates brittle systems.
The first is the user-facing resend clock. It limits how quickly a caller may request another one-time code. The second is the recipient-health clock: how quickly a delivery failure changes the eligibility of an address or phone number. One is abuse control. The other is delivery control. They can share storage primitives, but they should not share a single status flag.
For a concrete example, suppose a depot operator requests a login code while shipment exception alerts are already queued for the same contact. The countdown may permit a new request while a bounce event is in flight. The API reads an apparently active recipient and enqueues the code; milliseconds later, the bounce consumer marks that contact suppressed. If the worker trusts the earlier read, it still attempts the code and any dispatch alert sitting behind it. The send transaction must therefore read recipient eligibility immediately before enqueueing, and the worker must check it again before handoff. I choose the extra lookup because the alternative is an avoidable send based on stale state. Two checks. One authority.
Benchmarks should target that path, not a toy endpoint. Measure event-to-suppression latency, duplicate-event handling, queue age, and the number of attempted sends after suppression. Median latency hides the exact tail that hurts here; record a high percentile and the maximum observed during a controlled test. Set the acceptance threshold from the operation's alert deadline rather than copying an arbitrary internet number.
This is the second criterion: the system must minimize the stale-decision window. Webhooks usually fit that goal because work begins when an event arrives. Polling can still detect changes, but its interval defines a built-in delay.
A narrow Node.js implementation
The useful interface is boring. It accepts normalized events, applies them once, and exposes one eligibility check. Transport adapters can translate upstream payloads into this shape without leaking their schemas through the application.
type BounceKind = "temporary" | "permanent";
type DeliveryEvent = {
eventId: string;
recipient: string;
kind: BounceKind;
occurredAt: string;
};
type RecipientState = {
status: "active" | "temporarily_blocked" | "suppressed";
reason: BounceKind;
changedAt: string;
};
interface SuppressionStore {
hasProcessed(eventId: string): Promise<boolean>;
saveEventAndState(
event: DeliveryEvent,
state: RecipientState,
): Promise<void>;
get(recipient: string): Promise<RecipientState | null>;
}
export async function applyDeliveryEvent(
store: SuppressionStore,
event: DeliveryEvent,
): Promise<void> {
if (await store.hasProcessed(event.eventId)) return;
const state: RecipientState = {
status: event.kind === "permanent" ? "suppressed" : "temporarily_blocked",
reason: event.kind,
changedAt: event.occurredAt,
};
// Implement this as one atomic database transaction.
await store.saveEventAndState(event, state);
}
export async function maySend(
store: SuppressionStore,
recipient: string,
): Promise<boolean> {
const state = await store.get(recipient);
return state === null || state.status === "active";
}
The atomic operation is the sharp edge. A process can die after writing the event ID but before updating the recipient, or in the reverse order. One transaction makes replay safe. A unique constraint on eventId should settle concurrent duplicates; an in-memory Set will not survive a restart and will not coordinate multiple workers.
Verify the webhook before normalization. Preserve the raw request bytes if the signature scheme requires them, reject stale or invalid signatures, and acknowledge only after durable acceptance. Then return quickly and let a queue perform classification and state changes. Do not put a remote lookup in the acknowledgement path unless its failure semantics are deliberate.
DKIM solves a different problem. RFC 6376 defines a domain-level signing mechanism for email and explicitly separates the signing identity from the message's author identity. It helps receivers validate responsibility for a message; it does not replace application-level authentication of a bounce webhook. Treat message authentication and control-plane event authentication as separate boundaries.
Test the failures, not the happy-path form
Start with replay. Deliver the same event twice, concurrently, and after a worker restart. The final state must be identical and the event must have one durable effect.
Then reorder two events. A late temporary failure must not reactivate a recipient after a newer permanent suppression. Comparing trusted event time may help, but arrival order and clock quality need explicit rules. If the upstream contract does not guarantee ordering, store enough evidence to review the decision.
Kill the worker between receipt and processing. Fill the queue until consumers lag. Rotate the signing secret. Run the poller against a partial page and a repeated cursor. These are cheap tests, and they reveal more about delivery reliability than a polished resend animation.
Observability should follow the decision chain: accepted event, durable enqueue, classification, state transition, blocked send, and manual release. Avoid using email opens as proof that this chain works. Apple's Mail Privacy Protection downloads remote content in the background and prevents senders from seeing whether a recipient opened an email, so open-derived signals are unsuitable as authoritative delivery or engagement evidence.
Log identifiers and reason codes, but minimize recipient data. Operators need to answer why a dispatch notice was blocked without spraying full addresses through every log sink. Retention belongs in the design review, not in a cleanup ticket.
When polling wins
Choose polling when no authenticated event feed exists, when the source only exposes batch state, or when reconciliation is the goal. It can also be the independent repair mechanism behind a webhook path. Run it with a durable cursor, overlap a small boundary to tolerate ambiguous pagination, and rely on the same idempotent event application used by the webhook.
Polling is also easier to pause during a bad upstream-data episode because ingestion has an explicit scheduler. The cost is staleness. Make that cost visible as now - lastSuccessfulPoll, alert on it, and stop claiming the suppression list is current when the age exceeds the service's delivery deadline.
Do not build two classification engines. Both transports should produce the same DeliveryEvent, enter the same queue, and hit the same store. Less glue. Fewer places for recipient policy to drift.
The decision boundary is crisp: choose events when rapid suppression protects repeated operational messages; choose polling when push is unavailable or periodic verification is the work itself. In either case, backend eligibility outranks the countdown on screen.
Sources
References:
- RFC 6376, DomainKeys Identified Mail (DKIM): https://datatracker.ietf.org/doc/html/rfc6376
- Apple, Use Mail Privacy Protection on iPhone: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)