The constraint that changes this design is cutover speed. A mail domain can publish SPF, DKIM, and DMARC records correctly while recursive resolvers still serve older answers. I use a finite verification state machine with a hard attempt budget, then persist a customer-readable pending reason. That makes the wait explicit without pretending DNS has a predictable completion time.
How should bounded domain verification polling keep a pending reason visible?
A verification worker should perform a small number of checks, for example five attempts with exponential delays, and then stop. Each check records which prerequisite is missing: SPF_NOT_VISIBLE, DKIM_NOT_VISIBLE, DMARC_NOT_VISIBLE, or RESOLVER_TIMEOUT. The API returns pending plus that reason, rather than spinning until an arbitrary HTTP timeout.
This is an operational choice, not a DNS standard. SPF is evaluated from TXT data, DKIM depends on a selector-specific TXT record, and DMARC is published at _dmarc.<domain>. The records can arrive at different times because caches have independent TTLs.
What should the state machine remember?
The durable record needs the domain, attempt count, next check time, last resolver observations, and a terminal reason. Keep the observation separate from the customer message. A resolver timeout is useful for retry logic; “DNS is still propagating” is useful in the dashboard.
type PendingReason = 'SPF_NOT_VISIBLE' | 'DKIM_NOT_VISIBLE' | 'DMARC_NOT_VISIBLE' | 'RESOLVER_TIMEOUT';
type Verification = { domain: string; attempts: number; status: 'pending' | 'verified' | 'failed'; reason?: PendingReason; nextCheckAt?: string };
I initially treated attempts as a timer and lost the distinction between “we have not checked yet” and “the budget is exhausted.” Those are different product states. Store both the last observation and the budget decision so support can explain a result without rerunning a check.
How many checks are enough for a cutover?
The answer depends on the propagation window you are willing to expose, not on a magic retry count. A five-attempt schedule of 30 seconds, 90 seconds, 3 minutes, 9 minutes, and 15 minutes gives a bounded worker run of roughly 18 minutes. That is long enough to catch a normal fast cutover while keeping queue ownership clear.
The worker should be idempotent. It reads current DNS answers, evaluates all three policies in one pass, and writes one versioned result. A stale job must not overwrite a newer verified result. Use a compare-and-set on the verification version or an equivalent transaction boundary.
It failed once.
Not yet.
async function verifyOnce(v: Verification): Promise<Verification> {
const answers = await readDnsAnswers(v.domain);
const missing = firstMissingPolicy(answers);
if (!missing) return { ...v, status: 'verified', reason: undefined, nextCheckAt: undefined };
const attempts = v.attempts + 1;
if (attempts >= 5) return { ...v, attempts, status: 'pending', reason: missing, nextCheckAt: undefined };
const delaySeconds = [30, 90, 180, 540][attempts - 1];
return { ...v, attempts, status: 'pending', reason: missing, nextCheckAt: new Date(Date.now() + delaySeconds * 1000).toISOString() };
}
The customer-visible state should say what action is possible: publish the missing record, wait for caches to age out, or retry after checking the authoritative nameserver. It should not claim that a failed lookup proves the record is absent.
That's the contract.
Why authoritative and recursive answers both matter
During a cutover, query an authoritative nameserver when you need to distinguish publication from propagation. Querying only a public recursive resolver can make a newly published record look missing; querying only the authority can hide the customer’s real-world cache experience. Record both views when diagnosing a pending result, but use one consistently for the verification decision.
Different DNS libraries and hosted APIs expose different timeout and negative-caching behavior. A standards-based resolver path keeps the application portable. Commercial DNS products, managed mail platforms, and registrar dashboards differ in how they surface TXT records; treat those interfaces as evidence, not as your state model.
The trade-off is deliberate: a bounded worker can leave a domain pending even while a resolver would have succeeded a few minutes later. That is a poor fit for a launch that can tolerate an open worker for hours; in that case, a scheduled reconciliation process is a better boundary than a longer request-time poll.
Measure time-to-first-correct-answer, time-to-all-three-policies, timeout rate by resolver, and the percentage of jobs that exhaust the budget. Also measure how often a pending reason changes between attempts. If most jobs move from DMARC_NOT_VISIBLE to verified on attempt three, a longer budget may improve cutover completion. If timeouts dominate, adding attempts only increases queue pressure.
Keep the policy strict enough to protect delivery. DMARC alignment and reporting requirements are documented in RFC 7489; an observed TXT record is not automatically a valid policy. Test malformed records, duplicate TXT chunks, selector typos, and a domain that delegates DNS elsewhere.
The useful contract is small: finite work, durable evidence, and a reason a person can act on. That contract survives a provider change because it is built around DNS semantics rather than a vendor dashboard.
There is a second cutover concern that is easy to miss: policy validity can change after visibility. A TXT answer may be present but exceed parser limits, contain an invalid SPF mechanism, or publish a DKIM key under the wrong selector. Treat parsing errors as a distinct observation and expose the exact record family, never the full secret-bearing value. For rollout review, retain a short audit trail: timestamp, resolver class, normalized status, and reason transition. This gives an operator enough evidence to decide whether to wait, correct DNS, or deliberately start a new verification generation.
Top comments (0)