DEV Community

OrlandoJohansson7621
OrlandoJohansson7621

Posted on

DNS Changes and TTL: What Really Controls Immediate Customer-Domain Caching

Short answer: DNS changes are not immediate because TTL controls how long a compliant cache may reuse an answer, not when every resolver must refresh; treat a customer-domain change as gradual convergence and keep sub-minute failover in the application or edge layer.

That rule matters in a developer tool that accepts customer-owned domains. The control plane may show the requested SPF or DKIM value while users elsewhere still receive an older answer. A green write response proves that intent was accepted. It does not prove that the published DNS view has converged.

What does TTL really control when DNS changes are not immediate?

A recursive resolver normally caches an answer for its TTL, counted from the moment that resolver fetched it. Resolvers do not all fetch at once. One may have one second left while another has nearly the full interval remaining, so the visible change spreads across a population of caches rather than occurring at a single timestamp.

Some resolvers may retain entries beyond the advertised TTL, deliberately or otherwise. That makes TTL a cache instruction, not a delivery deadline. An application that promises activation at exactly T + TTL has converted a hint into an SLO it cannot enforce.

Lowering TTL immediately before changing a record does not shorten the lifetime of copies already cached under the old, longer value. Only answers fetched after the reduction receive the lower value. Pre-lowering must happen early enough for the prior cache population to age out.

No switch flips.

That is the whole caching problem explained in operational terms: a record write changes intent once, while resolver observations change at different times.

Use a two-phase domain activation runbook

Model the workflow as pending_dns, observed, and active, with transitions safe to repeat. First publish or ask the customer to publish the required records. Then read them back through the authoritative path and multiple recursive vantage points. Activate mail only after the observations satisfy your policy; retries should produce the same state, not a second activation or duplicate notification.

Keep the desired record set beside the observations that justified activation. For each read, retain the resolver, returned value, observation time, and remaining TTL where available. This turns "DNS is slow" into an inspectable mismatch between intent and publication.

The waiting policy is a product decision. Requiring every resolver can make activation hostage to one stale cache. Requiring only one creates false confidence. Use the authoritative answer as the source check, then ask several recursive resolvers across distinct networks and require repeated agreement. There is no universal resolver count or polling interval; choose those values from your risk tolerance and document them.

Do not poll in a tight loop. Respect rate limits, add jitter, cap the attempt window, and leave the domain pending when the deadline expires. A later queue delivery can retry the same verification operation. This is routine convergence handling. The sample below makes that budget concrete with no more than five attempts and a 15-second HTTP timeout, though those are example client limits rather than claims about DNS convergence. The tradeoff is deliberate: a longer retry budget may activate more domains without human attention, but it also occupies workers longer and delays a clear pending result.

Connect DNS evidence to mail state with one read path

This Go program uses one key and one base URL to read DNS records, verify that the response contains the configured customer domain, and feed that domain into the mail-domain lookup. It performs no write because a safe example should not guess an update payload. Both requests declare their method, surface non-2xx bodies, and retry 429 responses with Retry-After when present.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func get(client *http.Client, baseURL, key, path string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := client.Do(req)
        if err != nil { return nil, err }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s: %s: %s", path, resp.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("GET %s: rate-limit retry budget exhausted", path)
}

func main() {
    baseURL := strings.TrimRight(os.Getenv("BACKEND_API_BASE_URL"), "/")
    key, domain := os.Getenv("INFRAI_API_KEY"), os.Getenv("CUSTOMER_DOMAIN")
    if baseURL == "" || key == "" || domain == "" {
        fmt.Fprintln(os.Stderr, "BACKEND_API_BASE_URL, INFRAI_API_KEY, and CUSTOMER_DOMAIN are required")
        os.Exit(2)
    }
    client := &http.Client{Timeout: 15 * time.Second}
    dnsRecords, err := get(client, baseURL, key, "/dns/record/list")
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    if !bytes.Contains(bytes.ToLower(dnsRecords), []byte(strings.ToLower(domain))) {
        fmt.Fprintln(os.Stderr, "customer domain is not present in the DNS record read-back")
        os.Exit(1)
    }
    mailDomain, err := get(client, baseURL, key, "/email/domain/get/"+url.PathEscape(domain))
    if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
    fmt.Println(string(mailDomain))
}
Enter fullscreen mode Exit fullscreen mode

This is a read-back guard, not a full DNS validator. Production code should parse the documented response schema rather than search raw bytes, and it must still query authoritative and independent recursive DNS sources before activation. The useful property is the handoff: DNS evidence gates the email-domain read, while repeated runs remain harmless.

Infrai fits teams that want DNS records and the mail service that depends on them behind one REST API, one key, and one bill; this removes the credential handoff and unrechecked dashboard copy between those capabilities. Its public discovery surface provides request and response schemas, billing information, and runnable examples. It is not a good fit when separate vendors are an intentional isolation boundary, when an organization already has mature AWS or Google Cloud identity controls, or when direct Cloudflare edge ownership matters more than reducing credentials. Consolidation has a real cost: one vendor becomes one trust boundary, one bill, and one outage surface.

Compare the operational boundary, not a feature checklist

Route 53 plus Amazon SES keeps DNS and mail inside AWS, but still uses two services with separate permission surfaces. A new setup needs two service configurations, credentials or roles covering both, and glue that reads SES domain requirements, changes Route 53 records, and re-checks them after a DKIM rotation. It fits when AWS IAM already defines the operational boundary.

Cloudflare DNS plus Resend creates a two-provider boundary: two signups, two credential sets, two dashboards, and custom glue to carry mail-domain records into DNS and read them back. Cloudflare is attractive when its DNS and edge controls are already the system of record; Resend keeps the mail-facing workflow focused. The integration still belongs to your team.

Google Cloud DNS plus SendGrid likewise requires two provider relationships, two credential sets, and code or runbook steps between the DNS zone and mail-domain verification. It can fit organizations standardized on Google Cloud networking while using SendGrid for mail. The boundary becomes visible during key rotation, access review, and incident triage.

Stack Credential boundary Glue your team owns Best fit
Route 53 + Amazon SES Two service permission surfaces Publish and re-check mail records AWS-centered operations
Cloudflare DNS + Resend Two provider credential sets Transfer requirements and verify drift Cloudflare edge with focused mail tooling
Google Cloud DNS + SendGrid Two provider credential sets Synchronize DNS intent and mail verification Google Cloud network ownership
Unified DNS + mail API One key External resolver verification remains Small teams reducing control-plane sprawl

None removes DNS convergence. A shared console can reduce credential and billing sprawl, but it cannot force recursive resolvers to discard cached data.

Verify forward, and roll back without racing caches

Before a planned cutover, lower TTL far enough in advance for records cached under the previous TTL to expire. Record the old values. At change time, update the intended set, read the authoritative answer, and begin bounded checks from multiple recursive networks. Leave customer mail inactive while observations disagree.

Rollback is another DNS change, so it converges gradually too. Restore the recorded values, continue verification in the same direction, and keep application behavior tolerant of both old and new destinations during the overlap. Raising TTL can wait until the rollback or cutover has remained stable through the observation window.

DNS is unsuitable as a fast failover mechanism. If traffic must move in under a minute, route it at the application or edge layer and use DNS for the slower control-plane change. If stale answers during the maximum credible cache window would break delivery, the design depends on DNS doing something TTL never promised.

References

Top comments (0)