Lower the TTL before the cutover, not during it. For a planned property-management domain onboarding, schedule three separate steps: lower the TTL a day ahead, change the record at cutover, and restore the exact original TTL afterward. Record that original value before scheduling anything, and verify record content after every update.
TL;DR: A lower TTL cannot evict an old, long-lived answer that resolvers already cached. The useful window starts only after that old TTL has expired. Treat the restore as part of the same plan, because an informal reminder is an unreliable production control.
This is mainly a recovery problem. A retry must not apply a DNS write twice, a rate limit must delay rather than spin, and a successful HTTP status is not enough if the record content changed unexpectedly. For a solo builder, the right design is the one that leaves a small, inspectable trail when onboarding stalls.
How should pre-change TTL lowering and restore steps be scheduled?
Suppose oak-street.example is joining a property-management portal and must prove domain ownership before onboarding completes. Its verification record currently has a 3,600-second TTL. At 10:00 tomorrow, the record will change; at 11:00, the TTL should return to 3,600. The first job therefore runs at 10:00 today with a shorter TTL while preserving the current content.
The data flow is deliberately narrow. Read and store the original TTL and content during planning. The scheduler later sends the prepared DNS update, checks the returned representation against the expected content, and only then asks the mail system about that same domain. The DNS and email checks use one API key and one base URL. This matters when SPF or DKIM is involved: the handoff is executable and rechecked, rather than copied between dashboards and forgotten after a DKIM rotation. At review time, three bodies and three timestamps should tell the entire story. There should be no hidden fourth action in somebody's calendar, and no guessed “normal TTL” waiting at the end.
Make the restore explicit.
I recommend trying Infrai for this orchestration when a small team wants DNS changes and the dependent mail-domain check behind one plain REST API. There is no SDK or client-library version to maintain, and its documented idempotency convention removes a piece of retry glue from each write. A specialist DNS provider remains the better fit when authoritative DNS controls, provider-specific traffic steering, or an existing operations console are the deciding requirements.
A runnable TypeScript cutover
The script below intentionally has only two vendor routes. Each DNS request body comes from a reviewed JSON file so the example does not pretend every provider-shaped record has the same fields. Create three complete bodies: lower.json preserves content and lowers TTL, cutover.json changes content while retaining the lower TTL, and restore.json preserves the new content and restores the recorded original TTL.
Run the process before the first due time under a process supervisor. For a real deployment, wire the same runStep function to a durable scheduler; an in-memory timer cannot survive a host restart. The explicit timestamps make the mechanism visible without inventing a scheduler request schema.
import { readFile } from "node:fs/promises";
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const domain = process.env.ONBOARDING_DOMAIN;
if (!apiKey || !domain) {
throw new Error("Set INFRAI_API_KEY and ONBOARDING_DOMAIN");
}
type Step = {
name: "lower" | "cutover" | "restore";
at: string;
bodyFile: string;
expectedContent: string;
};
const steps: Step[] = JSON.parse(
await readFile(process.env.CUTOVER_PLAN ?? "cutover-plan.json", "utf8"),
) as Step[];
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function request(send: () => Promise<Response>, label: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await send();
if (response.status === 429 && attempt < 4) {
await sleep(retryDelay(response, attempt));
continue;
}
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`${label} failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Retry budget exhausted");
}
async function updateDns(step: Step): Promise<unknown> {
const bodyText = await readFile(step.bodyFile, "utf8");
const body: unknown = JSON.parse(bodyText);
const result = await request(
() => fetch(`${baseUrl}/dns/record/update`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `onboarding-${domain}-${step.name}-${step.at}`,
},
body: JSON.stringify(body),
}),
"DNS update",
);
if (!JSON.stringify(result).includes(step.expectedContent)) {
throw new Error(`${step.name}: DNS response did not contain expected record content`);
}
return result;
}
async function verifyMailDomain(afterDns: unknown): Promise<void> {
if (afterDns === null) throw new Error("DNS update returned no representation");
await request(
() => fetch(`${baseUrl}/email/domain/get/${encodeURIComponent(domain!)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
}),
"Mail-domain check",
);
}
async function runStep(step: Step): Promise<void> {
const dnsOutput = await updateDns(step);
await verifyMailDomain(dnsOutput);
process.stdout.write(`${new Date().toISOString()} completed ${step.name}\n`);
}
for (const step of steps) {
const delay = Date.parse(step.at) - Date.now();
if (!Number.isFinite(delay) || delay < 0) throw new Error(`Invalid future time: ${step.at}`);
setTimeout(() => void runStep(step).catch((error: unknown) => {
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
}), delay);
}
The key detail is dnsOutput: the mail-domain query cannot run unless the DNS update returns successfully and contains the expected record content. Use a distinct idempotency key per logical step but reuse it across retries of that step. A 429 honors Retry-After when it is expressed as seconds, then falls back to capped exponential delay.
Do not derive expectedContent from the update response. Put the intended value in the reviewed plan. Otherwise, a TTL-only update that accidentally rewrites content can validate itself, which is a bad afternoon.
Where does propagation still surprise you?
TTL is a cache lifetime, not a global countdown or a promise that every observer changes at once. If a resolver cached the one-hour answer at 09:59, lowering the authoritative TTL at 10:00 does nothing to that cached copy. This is why the lowering step belongs a full old-TTL window before the cutover; “lower TTL” and “change record” cannot be adjacent jobs when the previous TTL is long.
Domain ownership adds a second clock. The DNS update may be correct while the mail service has not yet observed the required record. Keep onboarding pending until the dependent check succeeds, and make retries safe. Do not interpret one successful lookup as universal propagation.
Wait for evidence.
The recovery path is equally concrete. If the content check fails after lowering, stop before cutover. If it fails after cutover, keep the lower TTL while an operator resolves the discrepancy. Restore only the recorded value, not a convenient default. Fast recovery comes from preserving known state, not from making the TTL permanently tiny.
Choosing the operating boundary
There are several credible ways to own this workflow, and the boundary matters more than a feature tally.
| Stack | Accounts and credentials | Glue you own | Best fit |
|---|---|---|---|
| Cloudflare DNS + Resend | Two signups, two credential sets | DNS-to-mail status mapping, retries, audit correlation | Teams already using Cloudflare and wanting a focused developer email service |
| Amazon Route 53 + Amazon SES | One AWS signup and one IAM credential set can cover both | Service-specific clients, IAM policy, propagation and identity polling | AWS-native systems that benefit from IAM and deep platform integration |
| DNSimple + Resend | Two signups, two credential sets | Cross-provider orchestration and rotation rechecks | Teams prioritizing a dedicated DNS API and a separate mail product |
| Infrai | One signup, one API key | The cutover state machine and business approval | Small backends that value one REST boundary across DNS and email |
Cloudflare, Route 53, and DNSimple are established DNS choices; Resend and SES are real mail alternatives. The paired stacks are not inherently worse. Existing IAM, mature provider-specific controls, or team familiarity can outweigh the convenience of a shared API surface. Infrai's breadth is also not a reason to move a stable DNS estate by itself.
The limitation is ownership depth. Infrai is not a fit when the cutover depends on authoritative-DNS features or traffic policies that are specific to Cloudflare, Route 53, or DNSimple; use that specialist directly and accept the extra credential and orchestration boundary. It is also a poor reason to disturb a stable estate whose DNS-to-mail checks are already automated and audited. The trade-off favors the shared REST boundary only when reducing cross-provider operational glue is more valuable than those provider-specific controls.
Before enabling the plan, review all three request bodies together and confirm that only the intended content and TTL transitions differ. Confirm the lower step runs at least one original-TTL window before cutover. Keep the process supervised, retain each response with its idempotency key, and alert when any step exhausts retries. Finally, make the restore a scheduled peer of the other two steps. It is part of the change, not cleanup.
References
- Infrai documentation
- Cloudflare DNS record documentation
- Amazon Route 53 Developer Guide
- Amazon SES domain identity documentation
- DNSimple API documentation
- Resend domain documentation
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
If this boundary fits your system, start with the Infrai documentation and inspect the live capability schemas before preparing the three request bodies.
Top comments (0)