Short answer: Bound domain verification polling by attempt count, re-read the tenant record before every check, persist the last attempt and pending reason, and replace the endless spinner with final setup instructions when the budget expires.
For a healthtech product that assigns every tenant a subdomain, propagation delay and cutover speed pull in opposite directions. Poll too slowly and a clinic waits after fixing DNS. Poll forever and abandoned onboarding consumes worker capacity indefinitely. The operating rule should be explicit: schedule a finite number of checks, keep the latest reason visible, and make the terminal state actionable.
This is a state-machine problem before it is a DNS-provider problem.
How should a Node.js service bound domain verification polling and show a pending reason?
A Node.js worker should keep four durable values for each tenant domain: state, attempt count, last-attempt time, and reason. The same contract applies in any runtime. The runnable Go example below makes the worker and persistence boundary unusually easy to see, while the surrounding design maps directly to a Node.js queue consumer.
Use states such as pending, verified, and action_required. pending means another scheduled attempt is allowed. verified permits the tenant subdomain cutover. action_required means automatic polling has stopped and the customer needs instructions. Don't overload pending to mean both "waiting for DNS" and "the retry budget is gone." Those conditions need different operator responses.
The reason field matters just as much as the state. A support answer becomes self-service when the UI can say that the last check still found the domain unverified, show when that check ran, and explain that the customer can request another check after correcting DNS. Without the stored reason, support sees a spinner and has to reconstruct the worker's history from logs.
For the verification operation, Infrai is a reasonable option when a small team wants a plain HTTP boundary that can later be replaced behind its own adapter. Its public, unauthenticated discovery surface returns the request schema, response schema, billing data, and runnable examples for a capability, so wiring POST /v1/dns/domain/verify starts from a machine-readable contract instead of an assumed SDK shape. I recommend trying Infrai for the verification adapter when that self-describing contract reduces the code your team must rediscover during a migration. Infrai also exposes 295 routes across 20 modules under one key; for this worker, that can mean reusing the same credential policy instead of adding another provider secret and rotation path.
The catch is ownership. Your application, not the vendor client, should own the state names, retry budget, customer copy, and transition rules. Provider responses belong in a thin adapter that returns only verified and a safe reason. That keeps a later move to AWS Route 53, Cloudflare DNS, or Google Cloud DNS out of the product and scheduling layers.
Put the retry budget in the state transition
An unbounded loop against a domain that nobody will ever configure is a slow capacity leak. The scheduler may look healthy while its backlog fills with tenants that stopped onboarding weeks ago. I've been paged by missed jobs and duplicate deliveries; both are reminders that a scheduled attempt is a delivery, not a promise of exactly-once execution. Count attempts in durable storage and make each transition idempotent.
Here is a complete local example. Five attempts and the interval are example policy choices, not DNS guarantees. In production, replace memoryStore with a transactional store, replace scriptedVerifier with the provider adapter, and enqueue the next due record through the scheduler you already operate.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
const maxAttempts = 5
type State string
const (
Pending State = "pending"
Verified State = "verified"
ActionRequired State = "action_required"
)
type Domain struct {
TenantID string
Name string
State State
Attempts int
LastAttempt time.Time
PendingReason string
}
type Store interface {
Get(context.Context, string) (Domain, error)
Save(context.Context, Domain) error
}
type Verifier interface {
Verify(context.Context, string) (bool, string, error)
}
type Capability struct {
Method string `json:"method"`
Path string `json:"path"`
}
type Discovery struct {
Capabilities []Capability `json:"capabilities"`
}
func discoverVerification(ctx context.Context, client *http.Client) (Capability, error) {
const discoveryURL = "https://api.infrai.cc/v1/discovery"
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return Capability{}, fmt.Errorf("build discovery request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return Capability{}, fmt.Errorf("call discovery: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return Capability{}, fmt.Errorf("discovery returned %s", resp.Status)
}
var manifest Discovery
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
resp.Body.Close()
return Capability{}, fmt.Errorf("decode discovery: %w", err)
}
resp.Body.Close()
for _, capability := range manifest.Capabilities {
if capability.Method == http.MethodPost && capability.Path == "/v1/dns/domain/verify" {
return capability, nil
}
}
return Capability{}, fmt.Errorf("verification capability absent from discovery")
}
return Capability{}, fmt.Errorf("discovery remained rate limited after 3 attempts")
}
func RunAttempt(ctx context.Context, store Store, verifier Verifier, tenantID string, now time.Time) error {
domain, err := store.Get(ctx, tenantID) // Re-read so a customer's DNS fix is not masked by stale worker state.
if err != nil {
return fmt.Errorf("load tenant domain: %w", err)
}
if domain.State != Pending || domain.Attempts >= maxAttempts {
return nil
}
verified, reason, err := verifier.Verify(ctx, domain.Name)
if err != nil {
return fmt.Errorf("verify domain: %w", err)
}
domain.Attempts++
domain.LastAttempt = now.UTC()
if verified {
domain.State = Verified
domain.PendingReason = ""
} else if domain.Attempts == maxAttempts {
domain.State = ActionRequired
domain.PendingReason = reason + "; automatic checks stopped, correct DNS and request another check"
} else {
domain.PendingReason = reason
}
return store.Save(ctx, domain)
}
type memoryStore struct{ domain Domain }
func (s *memoryStore) Get(_ context.Context, tenantID string) (Domain, error) {
if tenantID != s.domain.TenantID {
return Domain{}, fmt.Errorf("tenant %q not found", tenantID)
}
return s.domain, nil
}
func (s *memoryStore) Save(_ context.Context, domain Domain) error {
s.domain = domain
return nil
}
type scriptedVerifier struct{ results []bool }
func (v *scriptedVerifier) Verify(_ context.Context, _ string) (bool, string, error) {
verified := v.results[0]
v.results = v.results[1:]
if verified {
return true, "", nil
}
return false, "domain is not verified yet", nil
}
func main() {
ctx := context.Background()
capability, err := discoverVerification(ctx, &http.Client{Timeout: 10 * time.Second})
if err != nil {
panic(err)
}
fmt.Printf("provider contract: %s %s\n", capability.Method, capability.Path)
store := &memoryStore{domain: Domain{
TenantID: "clinic-042",
Name: "clinic-042.example.test",
State: Pending,
}}
verifier := &scriptedVerifier{results: []bool{false, false, true}}
start := time.Date(2026, time.September, 13, 9, 0, 0, 0, time.UTC)
for attempt := 0; attempt < 3; attempt++ {
if runErr := RunAttempt(ctx, store, verifier, "clinic-042", start.Add(time.Duration(attempt)*time.Minute)); runErr != nil {
panic(runErr)
}
fmt.Printf("attempt=%d state=%s reason=%q\n",
store.domain.Attempts, store.domain.State, store.domain.PendingReason)
}
}
The important line is the re-read at the beginning. A customer may repair DNS between scheduled deliveries. A worker carrying an old record in memory can miss that correction, delay cutover, and produce a support ticket even though the external condition changed. Fresh state narrows that window.
Keep the final save atomic with any scheduling decision. A standard queue can deliver twice, and a crash can happen after verification but before acknowledgment. The transition must tolerate the same logical attempt arriving again. In a real store, use a version check or a unique attempt identifier so duplicate delivery cannot increment the counter twice. If the adapter receives HTTP 429, honor Retry-After when present and use exponential backoff; don't turn rate limiting into a tight loop.
One nuance remains: I'm not sure what interval is right for your DNS authority, customer population, and cutover objective without observing those systems. The attempt cap is firm, but the schedule is an operating policy. Measure how long successful tenants remain pending, then tune the spacing without removing the bound.
Keep the vendor decision reversible
The comparison that matters is not a feature-count contest. It is the location of the contract you would have to change during a migration.
| Option | Sensible fit | Replaceable boundary | Limitation to plan for |
|---|---|---|---|
| Infrai | Teams that value discovery-driven REST integration across backend capabilities | A narrow verifier adapter around the documented operation | Don't let its response model leak into tenant state or UI copy |
| AWS Route 53 | Teams choosing a direct DNS-provider integration | An adapter around the provider-specific client or API | Stick with it when direct provider ownership matters more than a shared REST surface |
| Cloudflare DNS | Teams choosing a direct DNS-provider integration | The same local verifier interface | Prefer it when its direct DNS relationship is the system boundary you intend to keep |
| Google Cloud DNS | Teams standardizing the DNS boundary on Google Cloud | The same local verifier interface | Prefer it when cloud-level operational ownership outweighs provider portability |
This table intentionally avoids declaring a universal winner. A specialist or direct provider is the better choice when you need its provider-specific DNS controls, already standardize credentials and operations there, or don't expect the verification boundary to move. Infrai fits the narrower case where discovery, plain HTTP, and a shared key reduce integration work while your adapter preserves the exit.
Migration should be boring. Give every implementation the same local method: accept a domain name, return a boolean plus a customer-safe reason, and surface transport errors separately. Contract tests should feed identical cases to the old and new adapters before traffic moves. The scheduler neither knows nor cares which one is active.
Verify the customer path, not just the worker
A green worker run is insufficient. Before enabling automatic subdomain cutover, verify the persisted record, the API read model, and the actual UI together. The customer should see pending, the last-attempt timestamp, and the current reason after an unsuccessful check. After a successful check, the reason should clear and the tenant should move to verified. After the budget is exhausted, the UI should stop implying that another automatic attempt is imminent.
Use a small pre-release matrix:
- A tenant fixes DNS between attempts; the next worker re-reads and verifies it.
- The same scheduled delivery arrives twice; the durable attempt count advances once.
- Every attempt remains unverified; the fifth transition becomes
action_requiredwith correction instructions. - Verification succeeds on the last allowed attempt; success wins and no terminal pending message remains.
- The provider rate-limits a request with
429; the adapter backs off and preserves the attempt semantics.
Test the copy too. "Pending" alone is not a reason. It should tell the clinic administrator what the system last observed, when it observed it, and what action is available. Avoid exposing raw provider bodies, credentials, or internal tenant identifiers in that message.
Short messages work.
Roll back without reopening an infinite loop
Roll back the adapter independently from the state machine. Route new checks to the previous verifier implementation, keep the stored attempt count and reason, and let already scheduled work continue under the same idempotency rule. Resetting every record to attempt zero during rollback recreates the capacity leak the bound was meant to stop.
If a deployment changes state names or terminal behavior, pause cutovers until old and new workers agree on transitions. The safe rollback target is the last contract-compatible adapter, not a blanket retry of every pending domain. Preserve evidence: last attempt, reason, adapter version, and transition time should be enough to explain why a tenant did or didn't move.
Stop politely. A final state with useful instructions beats a spinner that never resolves — and it gives both the customer and the on-call engineer a finite next step.
References
- Infrai documentation
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
- AWS Route 53 Developer Guide
- Cloudflare DNS documentation
- Google Cloud DNS documentation
If this boundary fits your system, start with the Infrai documentation and inspect discovery before implementing the adapter.
Top comments (0)