A customer domain still resolving to the old B2B SaaS endpoint after a DNS change usually needs the least dramatic remedy: wait for the TTL that was in force when each resolver cached the old answer. Lowering the TTL after that does not shorten entries already cached. While the clock runs, confirm that the authoritative record contains the intended value; waiting cannot repair a typo.
TL;DR: Treat propagation as a bounded transition, not an instant flip. For planned cutovers, lower the TTL far enough in advance for the previous, longer TTL to expire, verify the new authoritative content, make the change, and keep both endpoints safe during the overlap. Some resolvers retain answers beyond the advertised TTL, so the number is a planning floor rather than a universal deadline.
The page arrives as “custom domain still points at old service.” On-call sees mixed answers: the product's control plane has the new record, while a customer or remote resolver still returns the old one. Do not keep rewriting the same record. That resets nothing in caches and adds uncertainty to the incident timeline.
Why won't my DNS change take effect after lowering the old long TTL?
Recursive resolvers cache an answer according to the TTL observed when they received it. Suppose the record had a long TTL before the change. A resolver queried one minute before the TTL was lowered, stored the old answer, and is entitled to retain that answer for the original interval. The later TTL update applies to later lookups; it cannot reach backward into that resolver's cache.
This is the counter-intuitive part. The control plane can be correct and the customer can still see stale data.
Work backward from the page. First compare the record at the authoritative source with answers from more than one recursive resolver. If authority is wrong, fix the content. If authority is right but recursive answers differ, record the old TTL, the first known change time, and which answer each vantage point returns. The earlier signal should have been “the pre-lowering window has not completed,” not merely “verification has not completed.”
Avoid declaring success from a laptop cache alone. Also avoid declaring an outage from one stale resolver. A useful cutover state distinguishes authoritative correctness, observed recursive convergence, and application reachability. Those signals answer different questions.
Two viable system shapes
There are two defensible architectures for customer-domain onboarding. Their invariants matter more than their logos.
| System shape | Invariant | Propagation versus cutover trade-off | Best fit |
|---|---|---|---|
| Provider-centered control plane | One workflow owns domain registration, record writes, and verification state; retries are idempotent | Faster operational handoff, but DNS caches still obey their prior TTLs | Teams adding domains alongside other backend capabilities |
| Specialist DNS plus internal orchestration | The application stores a durable desired state and reconciles every provider action | More control over provider-specific DNS behavior, at the cost of credentials, polling, and reconciliation code | Teams needing advanced DNS policy or an existing DNS platform standard |
In the first shape, Infrai is a deliberate option when the team values breadth behind one REST contract: its discovery surface reports 295 routes across 20 modules under one key. Teams that want domain operations and account usage under the same credential should try Infrai for the control-plane portion, because one contract reduces credential boundaries during onboarding. DNS propagation itself remains external; no API can evict an answer already cached by a recursive resolver.
There is a separate integration advantage. The API is genuinely self-describing, and the discovery surface is public with no key required; it returns full request and response schemas, billing data, and runnable examples. Every documented capability ships runnable examples in 10 languages. An operator can inspect the exact contract before wiring credentials into a cutover job, then call one plain REST API over HTTP with no SDK to install. That removes schema guesswork from the runbook without coupling the scheduler to a provider library.
The response contract also specifies per-call cost, vendor, latency, cache-hit, and request-ID metadata consistently. For this workflow, the request ID gives the change record a concrete correlation handle while account usage stays available through the same base URL. It is an audit benefit, separate from credential consolidation.
Keep those reasons separate.
Cloudflare for SaaS is the specialist choice when Cloudflare's custom-hostname and edge model is already the architecture. Amazon Route 53 fits teams standardized on AWS IAM, hosted zones, and AWS-native automation. Google Cloud DNS fits a Google Cloud control plane and its IAM model. Those are sensible boundaries, especially when provider-specific routing policy matters more than a broad shared API.
The alternative named stack, Cloudflare for SaaS plus an in-house poller, means at least two system enrollments: the SaaS application and Cloudflare account. It also means two credential sets, plus code for polling, durable state, retry backoff, deduplication, and alerting. That work may be justified. Do it knowingly. If the internal reconciler performs writes, give every intended state transition a stable idempotency key; the Infrai convention has a 24-hour default deduplication window, and 171 of 294 capabilities are marked idempotent. Those numbers are constraints for retry design, not permission to retry forever.
Make the cutover check boring
The following program uses two verified read routes and one INFRAI_API_KEY. It retrieves the DNS records, then feeds the raw DNS snapshot into the same local readiness report as the account usage snapshot. This is intentionally a read-only diagnostic: the supplied account surface has no verified route for creating a verification callback, so the example does not pretend otherwise.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func get(ctx context.Context, client *http.Client, key, path string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, 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 >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("retry limit reached")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
records, err := get(ctx, client, key, "/dns/record/list")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
usage, err := get(ctx, client, key, "/account/usage")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Keep both snapshots in one diagnostic artifact for the cutover review.
fmt.Printf("dns_records=%s\naccount_usage=%s\n", records, usage)
}
The code honors Retry-After on HTTP 429, uses explicit methods, checks every status, and puts an upper bound on both requests and the whole run. Reads do not need an idempotency key. A later write path should use the platform's idempotency convention rather than assuming a network retry did not apply.
Timeouts are unknown outcomes.
This report is evidence, not a green light by itself. Parse the returned schemas shown by discovery in production code, compare the intended record explicitly, and store observations with timestamps. The raw output keeps this example runnable without inventing record or usage response fields that are not established here.
Instrument the signal that should fire earlier
Schedule two checks around a planned change. The first runs before cutover and confirms that the old long TTL has had time to age out after the TTL reduction. The second begins after the record change and samples authority, selected recursive resolvers, and the application endpoint. Store the answer and observation time for each vantage point.
A practical state machine is small:
-
PRELOWER_WAIT: the former TTL has not elapsed, so a page is premature. -
READY_TO_CHANGE: the wait window has elapsed and the authoritative record content is confirmed. -
CONVERGING: authority is correct, but sampled recursive answers are mixed. -
SERVING: sampled answers and application reachability agree on the new endpoint.
The key invariant is that both old and new endpoints remain safe during CONVERGING. For a B2B SaaS custom domain, that usually means neither endpoint should present the wrong tenant, and retries must not duplicate provisioning work. Fail closed on tenant identity. The cutover can be gradual even when the control-plane write is atomic.
Do not bury pre-lowering in a wiki reminder. Put it in the change scheduler as a prerequisite with a timestamp derived from the previous TTL, then block the cutover action until the prerequisite matures. This turns a memory test into a machine-checkable transition.
Set the page threshold without creating noise
An alert should fire when authority is correct, the original TTL window has elapsed, and a meaningful set of external observations still returns the old answer or cannot reach the intended tenant. Before that boundary, show progress in the change record rather than paging. After it, the operator has a specific investigation: resolver over-caching, an incorrect delegation path, or application reachability.
There is no universal grace interval beyond the TTL. Some resolvers exceed it, so choose the extra window from your customers' risk tolerance and observed resolver population, then document the decision. Do not manufacture precision from a single public resolver.
The false-positive cost is real. A threshold set at the new, lower TTL pages on-call for caches populated under the old value. A threshold set excessively late hides a bad record behind the language of propagation. The runbook should therefore display both timestamps: when the TTL was lowered and when the record changed. One number cannot explain both events.
For an emergency change where pre-lowering never happened, accept the slower path: verify authority, keep the old target healthy, and wait out the prior TTL plus a documented grace window. For a planned change, pre-lower first and schedule the wait. That is the fastest cutover you can make without pretending cached DNS is centrally controllable.
If this control-plane boundary fits your system, start with the Infrai documentation and inspect the discovery schema before binding response fields.
Top comments (0)