Diagnostic TTL / Propagation
TTL set too high delays urgent changes
You need a DNS record fixed right now. Maybe a server moved, maybe a mail host changed, maybe something is actively broken. You update the record at your DNS host in seconds. Then nothing happens for hours. Visitors, and even you on a different network, keep getting the old, wrong answer. The record was never wrong at the DNS host. Its TTL was just set too high, so every resolver that already asked is allowed to keep the stale answer for a long time. Here is why that happens and a script that finds records with a dangerously high TTL before they cause this exact problem.
TTL, time to live, is how many seconds a resolver is allowed to reuse a cached DNS answer before it has to ask again. When a record's TTL is set too high, for example a whole day or more, every resolver that cached the old value keeps serving it for that entire window, even after you fix the record at the source. The record is correct at your DNS host the moment you save it. The delay is entirely caused by other machines trusting an old, cached copy for too long. The fix is to keep TTLs low, commonly 300 seconds, on any record you might need to change in a hurry, and to lower them ahead of a planned change, not during the emergency. Full code, tests, and a dry run guard are below.
The problem in plain words
Every DNS record carries a TTL value alongside its answer. That number tells every resolver in the world, your ISP's resolver, your phone's resolver, a public resolver like 1.1.1.1, exactly how long it is allowed to keep using the answer it already has before it has to ask the authoritative DNS server again. A TTL of 3600 means an hour. A TTL of 86400 means a full day.
That mechanism exists on purpose, to keep the DNS system fast and to stop every single lookup in the world from hammering your name servers. But it means a change you make right now is invisible to anyone whose resolver already has the old answer cached, for as long as that old TTL said it was allowed to hold onto it. Fix the record all you want. The stale copies do not know, and do not care, until their clock runs out.
Why it happens
- A record was set up once, long ago, with a long TTL such as 86400 seconds (one day) or more, and it was never revisited because nothing forced anyone to look at it.
- Some DNS hosts and default zone templates set a high TTL out of the box to reduce query volume, and that default is never lowered before a migration.
- A team plans a server or provider migration, changes the record, but forgets the TTL needed to be lowered days in advance so the change could take effect quickly once it happens.
- A record is treated as an emergency fix during an incident, but the TTL from calmer times is still high, so the fix rolls out on the old, slow schedule instead of quickly.
In every case the record's current value is fine. The number that is wrong is the TTL, and it is wrong before the emergency starts, which is exactly why it needs to be checked ahead of time rather than during a crisis.
TTL is a promise you make to every resolver in the world about how long they are allowed to trust a cached answer. You cannot take that promise back once a resolver has already cached it. Lowering the TTL now only changes how long the next lookup is willing to wait. This is why any record you might need to move in a hurry should carry a short TTL well ahead of the change, not just at the moment you need it.
The fix, as a flow
Read every record's current TTL from the zone. Anything above a safe threshold, for example one hour, is a candidate. For records you expect to change soon, such as ones tied to an active migration or an incident, lower the TTL now, well before you actually need to swap the value. That way, when the real change happens later, resolvers only hold the stale answer for a few minutes instead of a day.
How to fix it
Check the current TTL of the record
Look up the record and read the number just before the record type in the answer. That number is the TTL in seconds. Anything in the thousands, especially 3600 (one hour) or higher, deserves a second look if this is a record you might need to change quickly.
dig +short A example.com
dig A example.com
# look at the TTL column in the ANSWER SECTION, for example:
# example.com. 86400 IN A 203.0.113.10
# ^^^^^
# TTL in seconds (86400 = one full day)
# cross-check from a different resolver
dig @1.1.1.1 A example.com
Decide whether this TTL is a risk
A high TTL is only a problem on records you might need to change in a hurry: an A or CNAME record pointing at a server or host, an MX record, or anything tied to an upcoming migration. A record you truly never expect to touch, such as a long stable SPF anchor, can safely keep a longer TTL.
# BEFORE (bug): TTL of a day, on a record tied to an active migration
example.com. 86400 IN A 203.0.113.10
# AFTER: lowered well ahead of the planned change
example.com. 300 IN A 203.0.113.10
# once the change is stable, raise it back to a normal value
example.com. 3600 IN A 203.0.113.20
Lower the TTL well before the real change, not during it
Lowering the TTL only helps future lookups, so do it days ahead of a planned migration if you can, and at minimum as the very first step of an incident, before you touch the record's value. In the Cloudflare dashboard this is DNS, Records: edit the record and set TTL to a low value like 300 seconds (5 minutes). The same edit works through the API with a PATCH request.
# find the existing record and its current TTL
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=A&name=example.com" \
| jq '.result[] | {id, ttl, content}'
# lower the TTL to 300 seconds, keep the content the same
curl -X PATCH \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-d '{"type":"A","name":"example.com","content":"203.0.113.10","ttl":300}'
How to check it worked
Re-check the record from a couple of public resolvers and confirm the TTL now reads at or below your safe threshold. Then, once you actually make the real change later, watch the TTL count down in repeated lookups and confirm the new value shows up everywhere within roughly that TTL window, not hours later.
# should now show a low TTL, e.g. 300 or less
dig +short A example.com
dig A example.com
# confirm from other public resolvers too
dig @8.8.8.8 A example.com
dig @1.1.1.1 A example.com
# after the real change, watch the TTL count down between lookups
# a few seconds apart, confirming the new value appears within
# roughly one TTL window everywhere you check
dig +trace A example.com
A good result is every resolver you check showing the same low TTL, roughly 300 seconds, on any record tied to an upcoming or recent change. When the real change happens later, the new answer should be visible on all public resolvers within about that TTL window, not hours or a full day.
The full code
Here is the complete checker and repair script in one file for each language. It looks up a record's current TTL, flags it when it sits above a safe threshold, and, only when you turn dry run off, lowers the TTL through the Cloudflare API so the next real change can propagate fast.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a DNS record whose TTL is set high enough to delay an urgent
change by hours, and optionally repair the zone via Cloudflare by
lowering that record's TTL well ahead of the real change.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the domain to check, e.g. "example.com"
CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
DRY_RUN default "true"; set to "false" to actually write
SAFE_TTL_SECONDS TTL to lower flagged records to, default 300
TTL_THRESHOLD_SECONDS TTL above this is flagged, default 3600
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ttl_too_high")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SAFE_TTL_SECONDS = int(os.environ.get("SAFE_TTL_SECONDS", "300"))
TTL_THRESHOLD_SECONDS = int(os.environ.get("TTL_THRESHOLD_SECONDS", "3600"))
CF_API = "https://api.cloudflare.com/client/v4"
def classify_ttl(current_ttl, threshold_seconds):
"""Pure decision function. No I/O.
current_ttl: the record's TTL in seconds, as returned by DNS or the
provider API. A TTL of 1 from some providers means "automatic"
and is treated the same as a safe, already-low TTL.
threshold_seconds: any TTL strictly above this is flagged as risky.
Returns one of "safe" or "high_ttl".
"""
if current_ttl is None or current_ttl <= 1:
return "safe"
if current_ttl > threshold_seconds:
return "high_ttl"
return "safe"
def lookup_ttl(domain, record_type="A"):
"""Return the TTL in seconds for the first answer of record_type."""
import dns.resolver
answer = dns.resolver.resolve(domain, record_type)
return int(answer.rrset.ttl)
def list_zone_records(domain, record_type="A"):
"""List the id, ttl, and content of matching records via Cloudflare."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
params = {"type": record_type, "name": domain, "per_page": 100}
r = requests.get(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
headers=headers, params=params, timeout=30,
)
r.raise_for_status()
return r.json()["result"]
def lower_ttl(record_id, domain, record_type, content, new_ttl):
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
body = {"type": record_type, "name": domain, "content": content, "ttl": new_ttl}
if DRY_RUN:
log.info("[dry run] would lower record %s to TTL %s", record_id, new_ttl)
return
r = requests.patch(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}",
headers=headers, json=body, timeout=30,
)
r.raise_for_status()
log.info("Lowered record %s to TTL %s", record_id, new_ttl)
def run(record_type="A"):
ttl = lookup_ttl(DNS_DOMAIN, record_type)
verdict = classify_ttl(ttl, TTL_THRESHOLD_SECONDS)
log.info("%s record for %s has TTL %s seconds: %s", record_type, DNS_DOMAIN, ttl, verdict)
if verdict == "safe":
log.info("TTL is already at or below the safe threshold. No repair needed.")
return
log.warning(
"TTL of %s seconds on %s is above the %s second threshold. "
"An urgent change to this record could take hours to reach everyone.",
ttl, DNS_DOMAIN, TTL_THRESHOLD_SECONDS,
)
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Not repairing, only reporting.")
return
zone_records = list_zone_records(DNS_DOMAIN, record_type)
for rec in zone_records:
lower_ttl(rec["id"], DNS_DOMAIN, record_type, rec["content"], SAFE_TTL_SECONDS)
log.info("Done.")
if __name__ == "__main__":
run(record_type=os.environ.get("DNS_RECORD_TYPE", "A"))
/**
* Detect a DNS record whose TTL is set high enough to delay an urgent
* change by hours, and optionally repair the zone via Cloudflare by
* lowering that record's TTL well ahead of the real change.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the domain to check, e.g. "example.com"
* CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
* CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
* DRY_RUN default "true"; set to "false" to actually write
* SAFE_TTL_SECONDS TTL to lower flagged records to, default 300
* TTL_THRESHOLD_SECONDS TTL above this is flagged, default 3600
* DNS_RECORD_TYPE record type to check, default "A"
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SAFE_TTL_SECONDS = Number(process.env.SAFE_TTL_SECONDS || 300);
const TTL_THRESHOLD_SECONDS = Number(process.env.TTL_THRESHOLD_SECONDS || 3600);
const CF_API = "https://api.cloudflare.com/client/v4";
export function classifyTtl(currentTtl, thresholdSeconds) {
// Pure decision function. No I/O.
//
// currentTtl: the record's TTL in seconds, as returned by DNS or the
// provider API. A TTL of 1 from some providers means "automatic"
// and is treated the same as a safe, already-low TTL.
// thresholdSeconds: any TTL strictly above this is flagged as risky.
//
// Returns one of "safe" or "high_ttl".
if (currentTtl == null || currentTtl <= 1) return "safe";
if (currentTtl > thresholdSeconds) return "high_ttl";
return "safe";
}
async function lookupTtl(domain, recordType = "A") {
const dns = await import("node:dns/promises");
const resolver = new dns.Resolver();
const method = recordType === "AAAA" ? "resolve6" : "resolve4";
const addresses = await resolver[method](domain, { ttl: true });
return addresses[0].ttl;
}
async function listZoneRecords(domain, recordType = "A") {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const params = new URLSearchParams({ type: recordType, name: domain, per_page: "100" });
const res = await fetch(
`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
{ headers },
);
if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
const body = await res.json();
return body.result;
}
async function lowerTtl(recordId, domain, recordType, content, newTtl) {
const headers = {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
};
if (DRY_RUN) {
console.log(`[dry run] would lower record ${recordId} to TTL ${newTtl}`);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "PATCH",
headers,
body: JSON.stringify({ type: recordType, name: domain, content, ttl: newTtl }),
});
if (!res.ok) throw new Error(`Cloudflare patch returned ${res.status}`);
console.log(`Lowered record ${recordId} to TTL ${newTtl}`);
}
async function run(recordType = process.env.DNS_RECORD_TYPE || "A") {
const ttl = await lookupTtl(DNS_DOMAIN, recordType);
const verdict = classifyTtl(ttl, TTL_THRESHOLD_SECONDS);
console.log(`${recordType} record for ${DNS_DOMAIN} has TTL ${ttl} seconds: ${verdict}`);
if (verdict === "safe") {
console.log("TTL is already at or below the safe threshold. No repair needed.");
return;
}
console.warn(
`TTL of ${ttl} seconds on ${DNS_DOMAIN} is above the ${TTL_THRESHOLD_SECONDS} second threshold. ` +
`An urgent change to this record could take hours to reach everyone.`,
);
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
return;
}
const zoneRecords = await listZoneRecords(DNS_DOMAIN, recordType);
for (const rec of zoneRecords) {
await lowerTtl(rec.id, DNS_DOMAIN, recordType, rec.content, SAFE_TTL_SECONDS);
}
console.log("Done.");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The classification rule is the part most worth testing, because it decides whether a record gets left alone or flagged for a TTL repair. Because classify_ttl is pure, the test needs no network and no DNS lookups. It just feeds in plain numbers and checks the verdict.
from ttl_too_high import classify_ttl
def test_low_ttl_is_safe():
assert classify_ttl(300, 3600) == "safe"
def test_ttl_at_threshold_is_safe():
assert classify_ttl(3600, 3600) == "safe"
def test_ttl_above_threshold_is_high():
assert classify_ttl(86400, 3600) == "high_ttl"
def test_automatic_ttl_of_one_is_safe():
assert classify_ttl(1, 3600) == "safe"
def test_missing_ttl_is_safe():
assert classify_ttl(None, 3600) == "safe"
def test_custom_threshold_is_respected():
assert classify_ttl(1800, 900) == "high_ttl"
assert classify_ttl(600, 900) == "safe"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyTtl } from "./ttl-too-high.js";
test("low TTL is safe", () => {
assert.equal(classifyTtl(300, 3600), "safe");
});
test("TTL at threshold is safe", () => {
assert.equal(classifyTtl(3600, 3600), "safe");
});
test("TTL above threshold is high", () => {
assert.equal(classifyTtl(86400, 3600), "high_ttl");
});
test("automatic TTL of one is safe", () => {
assert.equal(classifyTtl(1, 3600), "safe");
});
test("missing TTL is safe", () => {
assert.equal(classifyTtl(null, 3600), "safe");
});
test("custom threshold is respected", () => {
assert.equal(classifyTtl(1800, 900), "high_ttl");
assert.equal(classifyTtl(600, 900), "safe");
});
Case studies
The move that took a day to finish for half the visitors
A team migrated a site to a new host and updated the A record within minutes of the new server going live. The old record had carried a TTL of 86400 seconds for years, untouched since the domain was first set up, and nobody thought to check it before the move.
For the rest of that day, roughly half of visitors, the ones whose resolver had cached the site recently, kept landing on the old, now decommissioned server. A script that flagged the high TTL a week earlier would have caught it in time to lower it well before the cutover, turning a day of split traffic into a five minute window.
The mail fix that would not finish propagating
During an incident, a team updated an MX record to point mail at a working host after the old one failed. The fix was correct within a minute. But the record's TTL had been left at 43200 seconds (twelve hours) since it was set up, and the team only discovered that after watching mail keep bouncing to the dead host for hours after the "fix" went in.
Checking the TTL as the very first step of the incident, before touching the record's value, would have shown the risk immediately. Afterward, the team added a habit of lowering the TTL on any record tied to an active incident before making the real change, not after.
After this is in place, any record you might need to move in a hurry already carries a TTL of a few minutes, checked well before you ever need to use it. When a real emergency does happen, the fix at your DNS host and the fix everyone actually sees are only minutes apart, not hours. Raise the TTL back to a normal value once the change has settled and is no longer at risk of needing a fast reversal.
FAQ
Why is my DNS change not showing up yet?
The old record has a high TTL, the number of seconds a resolver is allowed to keep using its cached answer. Every resolver that already cached the old value will keep serving it until that TTL runs out, no matter how fast you make the change at your DNS host. The fix is checked, then lowered ahead of time, not after the emergency starts.
What TTL should I use for a record I might need to change in a hurry?
For a record you expect to change soon, such as during a migration or a fix, 300 seconds (five minutes) is a common safe choice. It is short enough that a bad value clears out quickly, but long enough that it does not add meaningful load to your DNS provider. Raise it back to a normal value like 3600 once the change is stable.
Does lowering the TTL now help with a change I already made?
No. Once a resolver has already cached the old record with its old TTL, lowering the TTL on the new record has no effect on that cached copy. It only helps resolvers that ask fresh, after the old entry expires, or future changes where you plan ahead. This is why the TTL needs to be lowered before a planned change, not during one.
Related field notes
Stuck on a tricky one?
If you have a DNS, domain, or propagation problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save you a slow rollout?
If this helped an urgent fix reach everyone in minutes instead of hours, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi