Lowering TTLs Safely Before a Provider Migration

After working through this guide you will be able to plan and execute a pre-cutover TTL ramp: compute the wait from the value resolvers already hold, script the reduction across every record that matters, verify the countdown at real public resolvers, and put the TTLs back afterwards without leaving a 60-second bill running forever.

Almost every migration post-mortem that says “DNS took hours to propagate” describes the same mistake — the TTL was lowered and the record was changed in the same maintenance window. Lowering a TTL does nothing to the copies already sitting in resolver caches. Those copies expire on the schedule they were handed, which is the old TTL, and only after they expire does the new short value start applying. The ramp is therefore two changes separated by a wait, and the length of that wait is fixed by a number you published days ago.

Key implementation objectives:

  • Compute the pre-cutover wait from the largest TTL currently in the chain, never from the value you are moving to.
  • Cover every record the cutover touches, including MX, the SOA minimum, and the delegation you do not own.
  • Script the ramp so the reduction is one reviewable change rather than forty dashboard clicks.
  • Restore steady-state TTLs on a scheduled follow-up so the low values do not become permanent.
Same-window TTL change versus a sequenced ramp The upper track shows a TTL lowered and a record changed together, leaving clients on the old answer for a full day. The lower track shows the reduction published first, the old TTL draining, and only then the cutover. Where the wait actually comes from Wrong: lower the TTL and cut over in one window 86400s answer already cached clients keep the old target for up to 24 more hours The new 60s value never reaches a resolver that is not due to refresh yet. Right: publish the reduction, wait, then cut over publish TTL=60 at T-48h old TTL drains cut over, 60s tail Every cache has re-fetched at least once, so all of them now hold the short value. wait ≥ the OLD TTL, measured across the whole chain the value resolvers cached before you touched the zone, not the value you are moving to

The arithmetic

Take the largest TTL anywhere on the path a client walks to reach the record. That includes the leaf record itself, every CNAME hop between the queried name and its final address, and the parent NS records if the migration changes nameservers. The wait before the cutover is that maximum, plus a margin for resolvers that refresh lazily. A leaf record at 86400 needs a full day; the common “lower TTLs 24–48 hours in advance” advice is really “wait one old TTL, and 48 hours covers most 86400s zones with room to spare”.

Two subtleties bite teams that already know the rule. First, a CNAME chain exposes the minimum TTL across the chain to the client, but each hop is cached separately at its own TTL — lowering the alias while leaving its target at 86400 means the target keeps pinning the answer. Second, if you are moving to a different DNS provider rather than changing a record value, the clock that matters is the NS TTL in the parent zone, which the registry publishes and you cannot edit. That number is frequently 172800 at legacy TLDs, and it is the reason a nameserver swap has a two-day tail no amount of leaf-record tuning will shorten.

Clocks you control and clocks you do not A two column matrix listing leaf record TTL, SOA minimum and zone default on the controllable side, against parent NS TTL, registrar delegation and resolver floors on the other. Which clocks a ramp can actually move You set these Someone else sets these Leaf record TTL apex, www, MX targets, API hosts SOA MINIMUM field how long a missing name stays missing Zone default TTL inherited by records with no override Parent NS TTL published by the TLD registry Registrar delegation often 48h, and not yours to lower Resolver TTL floors ISP forwarders clamp very low values Plan a provider swap around the right-hand column; the ramp only shortens the left.

Which records to lower

Lower everything the cutover will move, and nothing else. A blanket 60-second TTL across a thousand-record zone multiplies query volume for records nobody is touching.

Record Lower it? Why
Apex A / AAAA Yes The main entry point; usually the highest-traffic answer in the zone
www CNAME and its target Yes, both The chain caches per hop; lowering only the alias achieves nothing
MX Yes if mail moves Sending servers retry, so a stale MX degrades rather than breaks — but it still queues mail
SPF/DKIM TXT Only if values change Receivers cache these; a mismatch during the window causes authentication failures
SOA MINIMUM Yes Governs how long a not-yet-created name keeps returning NXDOMAIN after the swap
NS at the apex Yes Cheap, and some resolvers prefer the child NS set over the parent’s
NS at the registrar Cannot Set by the registry; this is the floor on a nameserver migration
Static asset CNAMEs No Not moving, and lowering them just adds queries

The SOA minimum is the one that gets skipped. It has nothing to do with how long a good answer is cached, but it controls how long an absent one is, and a migration is precisely when clients query names a moment before they exist. Set it to 300 as part of the ramp, for the reasons laid out in Negative Caching and the SOA Minimum TTL.

Prerequisites and environment setup

You need dig from BIND 9.18 or newer, jq, and either AWS CLI v2 or a Cloudflare API token scoped to Zone.DNS:Edit. Confirm access before the ramp rather than at T-48h:

aws sts get-caller-identity --query Account --output text
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID" \
  -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.name'

Take a snapshot of the zone before you touch anything. It is your rollback artifact and your record of the original TTLs:

aws route53 list-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
  > zone-before-ramp.json

If the zone is already managed declaratively, do the ramp there instead — the TTL change belongs in version control alongside the cutover, which is the argument made in Managing DNS Zones as Code with Terraform.

The T-minus schedule

TTL ramp timeline Four milestones on a horizontal timeline: audit the current TTLs, lower them, cut over the records, and restore steady state one day later. Ramp for a zone starting at 86400s Audit the TTLs note the old value Lower to 60s apex, www, MX, NS Cut over turnover in ~60s Restore TTLs back to 3600s T-72h T-48h T-0 T+24h inventory wait one full old TTL swap the targets steady state The T-48h to T-0 gap is set by the OLD TTL, so 86400s demands at least a 24h wait. Shorten the ramp only once you have proved the old value has drained everywhere.
Milestone Action What must be true before you proceed
T-72h Inventory every TTL on the migration path You know the maximum value and where it lives
T-48h Publish TTL 60 on the target records and SOA minimum 300 The change is applied on all authoritative servers
T-24h Spot-check public resolvers Every resolver you sample reports 60, not 86400
T-1h Stand up and health-check the new target It serves correct responses on a direct IP request
T-0 Change the record values Monitoring is watching error rates, not just DNS
T+15m Confirm convergence All sampled resolvers return the new answer
T+24h Restore steady-state TTLs The new target has been stable for a full day

Step-by-step procedure

Step 1 — Inventory the current TTLs

Read the authoritative TTLs rather than whatever a cached lookup reports, and capture the maximum. This value is your wait.

for n in example.com www.example.com api.example.com; do
  for t in A AAAA CNAME MX; do
    dig @ns-1234.awsdns-56.org "$n" "$t" +noall +answer
  done
done

Expected output includes lines like example.com. 86400 IN A 203.0.113.10. Sort by the TTL column; the largest is the number your schedule has to respect. Do not forget the SOA:

dig @ns-1234.awsdns-56.org example.com SOA +noall +answer

Step 2 — Script the ramp on Route 53

Read the current record set, rewrite only the TTL, and submit it as one change batch. Doing this from the existing record set preserves the values, which is what makes the operation safe to repeat.

#!/usr/bin/env bash
set -euo pipefail
ZONE="$HOSTED_ZONE_ID"
NEW_TTL=60
NAMES='["example.com.","www.example.com.","api.example.com."]'

aws route53 list-resource-record-sets --hosted-zone-id "$ZONE" \
| jq --argjson names "$NAMES" --argjson ttl "$NEW_TTL" '
    {Changes: [ .ResourceRecordSets[]
      | select(.Name as $n | $names | index($n))
      | select(.Type == "A" or .Type == "AAAA" or .Type == "CNAME" or .Type == "MX")
      | select(has("AliasTarget") | not)
      | {Action: "UPSERT", ResourceRecordSet: (. + {TTL: $ttl})} ]}' \
> ramp.json

aws route53 change-resource-record-sets \
  --hosted-zone-id "$ZONE" --change-batch file://ramp.json

The select(has("AliasTarget") | not) filter matters: alias records carry no TTL field and the API rejects a batch that tries to set one. Expect a ChangeInfo with status PENDING; poll aws route53 get-change --id <ChangeId> until it reads INSYNC, which confirms the Route 53 fleet is consistent and says nothing about resolver caches.

Step 3 — Script the ramp on Cloudflare

Cloudflare edits records individually, so list, filter, and PATCH each one. Records left on "ttl": 1 are on Auto and cannot be lowered further.

#!/usr/bin/env bash
set -euo pipefail
API="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records"
AUTH=(-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json")

curl -s "$API?per_page=200" "${AUTH[@]}" \
| jq -r '.result[] | select(.proxied == false)
         | select(.name | test("^(example\\.com|www\\.|api\\.)")) | .id' \
| while read -r id; do
    curl -s -X PATCH "$API/$id" "${AUTH[@]}" --data '{"ttl":60}' \
      | jq -r '"\(.result.name) -> ttl \(.result.ttl)"'
  done

Proxied records are filtered out deliberately. Cloudflare serves its own edge addresses with a fixed short TTL for those names, so the value in your zone is not what resolvers see and lowering it accomplishes nothing.

Step 4 — Lower the SOA minimum

aws route53 change-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
  --change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
    "Name":"example.com","Type":"SOA","TTL":300,
    "ResourceRecords":[{"Value":"ns-1234.awsdns-56.org. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 300"}]}}]}'

The last field is the negative-cache TTL. Route 53 manages the serial itself, so the 1 you submit is not authoritative.

Step 5 — Cut over, then restore

At T-0 change the record values. At T+24h, run the same ramp script with NEW_TTL=3600 to restore steady state. Schedule that follow-up as a ticket at the moment you lower the TTLs — a low TTL left in production is the single most common leftover from an otherwise clean migration, and it is invisible because nothing breaks.

Query volume and cost

A 60-second TTL means each caching resolver refetches roughly 1,440 times a day instead of 24 at an hourly TTL. The multiplier is the ratio of the TTLs, and it applies to the authoritative query bill, not to client traffic.

TTL Refetches per resolver per day Relative authoritative load Route 53 cost at 10M baseline queries/month
86400 1 1x ~$0.17
3600 24 24x ~$4
300 288 288x ~$48
60 1440 1440x ~$240

Those figures assume the same set of distinct resolvers. In practice the increase is smaller than the ratio suggests, because a large share of queries already come from resolvers that were refetching anyway, and CDN-fronted names are answered by the CDN rather than by your zone. The number still matters over a 48-hour window on a busy apex, and it is the reason the ramp targets specific records rather than the whole zone. For the steady-state values to return to, see Best TTL Values for High-Traffic SaaS Platforms.

Verification

At T-24h, confirm the short TTL has reached real resolvers. Query the same name twice a few seconds apart at each one and read the countdown.

for r in 1.1.1.1 8.8.8.8 9.9.9.9 208.67.222.222; do
  first=$(dig @"$r" example.com A +noall +answer | awk '{print $2}' | head -1)
  sleep 3
  second=$(dig @"$r" example.com A +noall +answer | awk '{print $2}' | head -1)
  printf '%-16s %s -> %s\n' "$r" "$first" "$second"
done

Expected output:

1.1.1.1          58 -> 55
8.8.8.8          41 -> 38
9.9.9.9          60 -> 57
208.67.222.222   57 -> 54

A value that starts above 60 means that resolver still holds a record fetched under the old TTL — wait longer. A value that never decreases means you are reaching an authoritative or synthesizing layer rather than a cache. A value clamped upward, say a stubborn 300 when you published 60, is a resolver floor, covered below. The scripted multi-resolver approach is expanded in Debugging DNS Propagation Delays Across Global Resolvers.

Troubleshooting

A resolver clamps or ignores the low TTL

Many ISP and enterprise forwarders enforce a minimum cache time — commonly 30, 60, or in bad cases 1800 seconds — and some cap the maximum as well. You cannot override this from the zone. Detect it by comparing the TTL you published with the one a specific resolver reports on a fresh answer; if it comes back higher than your published value, that resolver is imposing a floor. Plan for it by keeping the old target serving traffic through the tail rather than by lowering the TTL further, and by measuring residual traffic at the old origin rather than assuming convergence.

The CDN TTL dominates and the DNS ramp does nothing

When a hostname is proxied, the answer clients receive comes from the CDN’s own edge addresses on the CDN’s own TTL, and requests reach your origin by whatever origin-resolution logic the CDN uses internally — which has its own refresh interval. Lowering the DNS TTL on such a record changes nothing users can observe. Diagnose by checking whether the returned address belongs to your origin or the provider:

dig example.com A +short
curl -sI https://example.com/ | grep -iE 'server|cf-ray|x-served-by'

If the address is the CDN’s, drive the cutover through the CDN’s origin configuration and its purge API instead, and read CDN TTL Behavior on Cloudflare, Fastly and CloudFront for how each provider treats the two layers.

Low TTLs left behind after the migration

Six months later the apex is still at 60 seconds, the authoritative bill is twenty times what it should be, and the zone is one authoritative outage away from a visible incident because nothing is cached long enough to ride it out. Audit periodically:

aws route53 list-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" \
| jq -r '.ResourceRecordSets[] | select(.TTL != null and .TTL < 300)
         | "\(.Name)\t\(.Type)\t\(.TTL)"'

Anything in that list that is not deliberately a failover record should be restored.

The nameserver change is slower than the ramp promised

If the migration moves the zone to a different provider, the leaf TTLs were never the constraint. The parent zone’s NS TTL governs, it is set by the registry, and it is typically 172800 seconds. Check what the parent actually publishes:

dig @a.gtld-servers.net example.com NS +noall +authority

Keep both providers authoritative and serving identical data across the whole window, as described in Migrating DNS Zones Without Downtime Using Zone Transfers. Do not decommission the old provider until traffic to it has fallen to zero for at least one parent NS TTL.

Rollback

Rollback is a re-UPSERT of the previous values from the snapshot you took at T-72h, and it is fast precisely because the TTL is still 60. Do not raise the TTL as part of the rollback — you would be locking whatever answer is live into caches for an hour at the worst possible moment. The order is: revert the values, confirm convergence at the same resolvers you sampled during verification, purge any CDN cache pointed at the reverted origin, and only then decide whether to retry. Restoring TTLs is always a separate, deliberate change made after the dust settles.

Frequently Asked Questions

How long do I actually have to wait after lowering the TTL? At least one full old TTL — the value resolvers cached before the change, not the value you set. If the apex was at 86400 you need a day; if it was already at 300 you need five minutes. Sample several public resolvers and confirm each reports the new low value before you proceed.

Should I lower the TTL on every record in the zone? No. Lower only the records the cutover touches, plus the SOA minimum. A blanket reduction multiplies authoritative query volume for records nobody is migrating, and on a busy zone that is a real bill for no benefit.

Is 60 seconds too low? It is fine for a migration window and a poor steady state. Some forwarders clamp below 60 anyway, resolvers re-query 1,440 times a day per cache, and a low TTL couples your availability tightly to your authoritative servers. Use it for the ramp, then restore 300–3600 depending on how fast you need failover.

Does lowering the TTL help if my records are proxied through a CDN? Not for HTTP clients. A proxied name resolves to the provider’s edge addresses on the provider’s TTL, so your zone value is invisible. Drive that cutover through the CDN’s origin settings and purge API instead.

What if the migration changes nameservers rather than records? Then the parent zone’s NS TTL sets the pace and you cannot lower it. Run both providers in parallel with identical zone data for at least one parent NS TTL after the delegation change, and watch query volume at the old provider fall to zero before decommissioning it.

Back to Mastering TTL Strategies