Diagnostic TTL / Propagation
Answers differ across public resolvers
You changed a record, then checked it on a few public resolvers, and they do not agree. 1.1.1.1 shows the new IP address. 8.8.8.8 still shows the old one. Nobody broke anything. Each resolver is just holding onto its own cached copy of the answer for a different amount of time. Here is why that happens, how to tell it apart from a real mistake, and how to make the next change settle faster.
DNS "propagation" is not a push. Nothing tells other resolvers that a record changed. Each public resolver caches the answer it was given until its TTL runs out, then asks your authoritative nameservers again on its own schedule. Right after a change, some resolvers still hold the old cached answer while others have already refreshed to the new one, so different resolvers legitimately disagree for up to one TTL period. Confirm the authoritative nameservers all agree with each other, then, if the old TTL was long, lower it going forward so the next change clears faster. A script can check this with dnspython and, when the cause is a stale long TTL, lower it through the Cloudflare API. Full code, tests, and a dry run guard are below.
The problem in plain words
People talk about DNS changes "propagating," as if the new answer spreads out to every resolver on the internet like a ripple. That is not how it works. Nothing is pushed anywhere. Every recursive resolver, whether it is 1.1.1.1, 8.8.8.8, 9.9.9.9, or your ISP's own resolver, keeps its own private copy of whatever answer it last fetched, and it keeps serving that copy from memory for as long as the record's TTL (time to live) says it is allowed to.
So when you update a record, resolvers that queried it recently are still sitting on the old answer, sometimes for hours, and will keep handing it out until their copy expires. Resolvers that have not queried it in a while, or whose TTL already ran out, ask your authoritative nameservers fresh and get the new answer right away. Query the same name against several resolvers during that window and you will see both answers at once. That is expected. It gets worse, and lasts longer, if the old record had a long TTL left over from before the change, if a resolver ignores the declared TTL and caches longer than it should, or if only some of your authoritative nameservers actually have the new record yet.
Why it happens
RFC 1035 defines TTL as the length of time a resolver is allowed to reuse an answer before asking again, and every recursive resolver on the internet makes that decision independently. A few things make the disagreement window longer or more confusing:
- A long TTL left over from before the change. If the old record had a TTL of 86400 seconds (24 hours), any resolver that queried it even a minute before your change can serve the stale answer for up to a full day afterward.
- A resolver that ignores the declared TTL and caches longer anyway. RFC 8767 documents that some resolvers serve stale answers past their TTL on purpose, to stay resilient when the authoritative servers are unreachable, which can look like extra-long lag.
- A partial rollout at the DNS host. If only some of your authoritative nameservers or edge nodes have received the new record, different resolvers get different "fresh" answers depending on which authoritative server they happen to ask, which is a real misconfiguration, not caching.
- Checking too soon. Running the same query twice in a row against the same resolver just returns its own cache both times, so it can look "stuck" even though it is behaving exactly as designed.
Julia Evans' widely cited explanation puts it simply, DNS "doesn't propagate," caches just expire, each on its own clock. See the citations at the end for the exact sources.
There is no way to push a DNS update to someone else's resolver. The only thing you control is the TTL, which sets the maximum length of the disagreement window. A short TTL means resolvers check in often and catch up fast. A long TTL means whatever answer they last cached can stick around a long time, even after you have already fixed the record.
The fix, as a flow
Since there is nothing to push, the plan is to confirm the record itself is right at the source, rule out a partial rollout, and then make future changes converge faster by lowering the TTL ahead of time. Waiting out the current TTL window is the only way to clear an inconsistency that already happened.
How to fix it
Query several public resolvers in parallel and compare
Ask the same name against a handful of well known public resolvers at the same time. Note both the returned value and the TTL each one reports.
dig +short A example.com @1.1.1.1
dig +short A example.com @8.8.8.8
dig +short A example.com @9.9.9.9
dig +short A example.com @208.67.222.222
Check the authoritative answer directly, bypassing every cache
Find the zone's authoritative nameservers, then query one of them by name. This is the true, uncached source of the record and is the value every resolver will eventually converge on.
dig +short NS example.com
dig +short A example.com @ns1.example-dns-host.com
Read the remaining cache lifetime each resolver is holding
The TTL column in the answer section shows how many seconds are left before that resolver will ask again. A low, counting-down number means it is close to refreshing on its own.
dig A example.com @8.8.8.8 | grep -A1 "ANSWER SECTION"
# example.com. 247 IN A 203.0.113.10
# the "247" is seconds left in this resolver's cache before it re-asks
Confirm every authoritative nameserver for the zone agrees
If all authoritative servers already return the same, correct answer, this is ordinary cache lag and it will clear on its own. If the authoritative servers disagree with each other, that is a partial rollout at the DNS host, not a caching issue, and it needs to be fixed at the zone before anything else.
for ns in ns1.example-dns-host.com ns2.example-dns-host.com; do
echo "$ns:"
dig +short A example.com @$ns
done
If the record is correct, lower the TTL for next time
There is nothing to repair at the zone right now, but a long TTL will make the next change slow to settle too. Drop the TTL going forward, for example to 300 seconds (five minutes), or to Auto on Cloudflare.
PATCH https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}
{
"type": "A",
"name": "example.com",
"content": "203.0.113.10",
"ttl": 300
}
Wait out the remaining window from the old TTL
There is no way to force a third party resolver to drop its cache early. The maximum time any resolver can still hold the stale answer is whatever the old TTL was, counted from the moment you made the change. Once that window has fully passed, every resolver should agree.
For future changes, lower the TTL one full cycle ahead of the cutover
The trick that actually speeds up a planned change is timing. Drop the TTL to something short, such as 300 seconds, at least one full old-TTL period before you flip the record. By the time you make the real change, every resolver's cache has already expired under the old TTL and will fetch fresh, quickly, under the new short one.
How to check it worked
Re-run the same multi-resolver query after the old TTL window has passed, and confirm the authoritative source now advertises the shorter TTL too.
dig +short A example.com @1.1.1.1
dig +short A example.com @8.8.8.8
dig +short A example.com @9.9.9.9
dig +short A example.com @208.67.222.222
# Confirm the new, lower TTL took effect at the authoritative source
dig A example.com @ns1.example-dns-host.com
A good result looks like this: every resolver returns the exact same value, for example all four returning 203.0.113.10. The authoritative query in the last line shows the ANSWER SECTION with the new, lower TTL, for example "example.com. 300 IN A 203.0.113.10". If any resolver still shows the old value after one full old-TTL interval has passed since the change, that resolver is over-caching or non-compliant, and the problem is on their side, not in your zone.
The full code
A script cannot make third party resolvers refresh early, but it can automate the diagnosis, and it can lower the TTL through the Cloudflare API once you decide the cause is a stale long TTL rather than a real misconfiguration. The script below sends parallel queries for a name and record type to the public resolvers plus the zone's own authoritative nameservers, collects every answer and its reported TTL, and reports whether the disagreement is ordinary propagation lag or an authoritative mismatch that needs a real fix at the DNS host.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect whether public resolvers disagree with a zone's authoritative
answer because of ordinary TTL caching, or because of a real mismatch at
the DNS host. On repair, lowers the record's TTL through the Cloudflare
API so future changes converge faster. Safe to run again and again.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_resolver_consistency")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
RECORD_TYPE = os.environ.get("RECORD_TYPE", "A")
PUBLIC_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"]
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"
def diagnose_resolver_inconsistency(authoritative_answer, resolver_answers, resolver_ttls, configured_ttl):
"""Pure decision logic, no I/O.
authoritative_answer: set of record values from the zone's own
authoritative NS (source of truth).
resolver_answers: {resolver_ip: set of record values returned}.
resolver_ttls: {resolver_ip: TTL currently reported for that answer}.
configured_ttl: the TTL currently set on the authoritative record.
Returns a dict:
{
"consistent": bool,
"stale_resolvers": list[str],
"likely_cause": "propagation_lag" | "authoritative_mismatch" | "none",
"recommend_lower_ttl": bool,
}
A resolver is "stale" if its answer set differs from authoritative_answer.
If authoritative_answer itself is not unanimous in resolver_answers.values()
across resolvers that DO match it, and configured_ttl is high (over 3600
seconds), the cause is "propagation_lag" and recommend_lower_ttl is True.
If even resolvers with expired-looking TTLs (close to 0) still disagree
with authoritative_answer, or all resolvers agree with each other but
differ from authoritative_answer, the cause is "authoritative_mismatch"
(a partial rollout at the DNS host) and recommend_lower_ttl is False.
"""
stale = [ip for ip, answer in resolver_answers.items() if answer != authoritative_answer]
if not stale:
return {
"consistent": True,
"stale_resolvers": [],
"likely_cause": "none",
"recommend_lower_ttl": False,
}
matching = [ip for ip in resolver_answers if ip not in stale]
near_expired_but_still_stale = any(resolver_ttls.get(ip, configured_ttl) <= 5 for ip in stale)
all_stale_agree_with_each_other = len({tuple(sorted(resolver_answers[ip])) for ip in stale}) == 1
no_resolver_matches_authoritative = not matching
if near_expired_but_still_stale or (all_stale_agree_with_each_other and no_resolver_matches_authoritative):
return {
"consistent": False,
"stale_resolvers": stale,
"likely_cause": "authoritative_mismatch",
"recommend_lower_ttl": False,
}
return {
"consistent": False,
"stale_resolvers": stale,
"likely_cause": "propagation_lag",
"recommend_lower_ttl": configured_ttl > 3600,
}
def query_resolver(name, rdtype, resolver_ip):
"""One resolver's answer set and the TTL it currently reports."""
import dns.resolver
r = dns.resolver.Resolver(configure=False)
r.nameservers = [resolver_ip]
r.lifetime = 5
answer = r.resolve(name, rdtype)
values = {rdata.to_text() for rdata in answer}
ttl = answer.rrset.ttl
return values, ttl
def query_authoritative(name, rdtype, domain):
"""Ask the zone's own authoritative nameservers directly, bypassing
every public resolver's cache."""
import dns.resolver
ns_answer = dns.resolver.resolve(domain, "NS")
ns_host = str(ns_answer[0].target).rstrip(".")
ns_ip = dns.resolver.resolve(ns_host, "A")[0].to_text()
r = dns.resolver.Resolver(configure=False)
r.nameservers = [ns_ip]
r.lifetime = 5
answer = r.resolve(name, rdtype)
values = {rdata.to_text() for rdata in answer}
ttl = answer.rrset.ttl
return values, ttl
def lower_ttl(record_id, name, rdtype, content, new_ttl=300):
"""Repair step: lower the record's TTL through the Cloudflare API so
future changes converge faster. Only called when the cause is a stale
long TTL, never for an authoritative mismatch."""
import requests
url = f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", "Content-Type": "application/json"}
payload = {"type": rdtype, "name": name, "content": content, "ttl": new_ttl}
resp = requests.patch(url, headers=headers, json=payload, timeout=30)
resp.raise_for_status()
return resp.json()
def find_record_id(name, rdtype):
import requests
url = f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
resp = requests.get(url, headers=headers, params={"type": rdtype, "name": name}, timeout=30)
resp.raise_for_status()
results = resp.json()["result"]
return results[0] if results else None
def run():
log.info("Checking resolver consistency for %s %s (DRY_RUN=%s)", DNS_DOMAIN, RECORD_TYPE, DRY_RUN)
authoritative_answer, configured_ttl = query_authoritative(DNS_DOMAIN, RECORD_TYPE, DNS_DOMAIN)
log.info("Authoritative answer: %s (TTL %s)", sorted(authoritative_answer), configured_ttl)
resolver_answers = {}
resolver_ttls = {}
for ip in PUBLIC_RESOLVERS:
try:
values, ttl = query_resolver(DNS_DOMAIN, RECORD_TYPE, ip)
resolver_answers[ip] = values
resolver_ttls[ip] = ttl
log.info("Resolver %s: %s (TTL %s)", ip, sorted(values), ttl)
except Exception as exc:
log.warning("Resolver %s failed to answer: %s", ip, exc)
result = diagnose_resolver_inconsistency(authoritative_answer, resolver_answers, resolver_ttls, configured_ttl)
log.info("Diagnosis: %s", result)
if result["consistent"]:
log.info("OK: every resolver agrees with the authoritative answer. Nothing to do.")
return
if result["likely_cause"] == "authoritative_mismatch":
log.warning(
"Authoritative mismatch detected, this is not ordinary propagation. "
"Fix the record at the DNS host so every authoritative server agrees."
)
return
log.warning(
"Ordinary propagation lag. Stale resolvers: %s. This will clear on its own within "
"the current TTL window (%s seconds).", result["stale_resolvers"], configured_ttl,
)
if result["recommend_lower_ttl"]:
record = find_record_id(DNS_DOMAIN, RECORD_TYPE)
if not record:
log.warning("Could not find the record via the Cloudflare API to lower its TTL.")
return
if DRY_RUN:
log.info("Dry run: would lower TTL for record %s from %s to 300", record["id"], configured_ttl)
return
lower_ttl(record["id"], DNS_DOMAIN, RECORD_TYPE, record["content"], new_ttl=300)
log.info("Lowered TTL for %s to 300 seconds so future changes converge faster.", DNS_DOMAIN)
if __name__ == "__main__":
run()
/**
* Detect whether public resolvers disagree with a zone's authoritative
* answer because of ordinary TTL caching, or because of a real mismatch
* at the DNS host. On repair, lowers the record's TTL through the
* Cloudflare API so future changes converge faster. Safe to run again
* and again.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const RECORD_TYPE = process.env.RECORD_TYPE || "A";
const PUBLIC_RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"];
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";
export function diagnoseResolverInconsistency(authoritativeAnswer, resolverAnswers, resolverTtls, configuredTtl) {
// Pure decision logic, no I/O.
//
// authoritativeAnswer: Set of record values from the zone's own
// authoritative NS (source of truth).
// resolverAnswers: Map(resolverIp -> Set of record values returned).
// resolverTtls: Map(resolverIp -> TTL currently reported for that answer).
// configuredTtl: the TTL currently set on the authoritative record.
//
// Returns { consistent, staleResolvers, likelyCause, recommendLowerTtl }.
//
// A resolver is "stale" if its answer set differs from authoritativeAnswer.
// If authoritativeAnswer itself is not unanimous across resolvers that DO
// match it, and configuredTtl is high (over 3600 seconds), the cause is
// "propagation_lag" and recommendLowerTtl is true. If even resolvers with
// expired-looking TTLs (close to 0) still disagree with authoritativeAnswer,
// or all stale resolvers agree with each other but none match the
// authoritative answer, the cause is "authoritative_mismatch" (a partial
// rollout at the DNS host) and recommendLowerTtl is false.
const sameSet = (a, b) => a.size === b.size && [...a].every((v) => b.has(v));
const staleResolvers = [];
for (const [ip, answer] of resolverAnswers) {
if (!sameSet(answer, authoritativeAnswer)) staleResolvers.push(ip);
}
if (staleResolvers.length === 0) {
return { consistent: true, staleResolvers: [], likelyCause: "none", recommendLowerTtl: false };
}
const matching = [...resolverAnswers.keys()].filter((ip) => !staleResolvers.includes(ip));
const nearExpiredButStillStale = staleResolvers.some((ip) => (resolverTtls.get(ip) ?? configuredTtl) <= 5);
const staleAnswerKey = (ip) => JSON.stringify([...resolverAnswers.get(ip)].sort());
const allStaleAgreeWithEachOther = new Set(staleResolvers.map(staleAnswerKey)).size === 1;
const noResolverMatchesAuthoritative = matching.length === 0;
if (nearExpiredButStillStale || (allStaleAgreeWithEachOther && noResolverMatchesAuthoritative)) {
return { consistent: false, staleResolvers, likelyCause: "authoritative_mismatch", recommendLowerTtl: false };
}
return {
consistent: false,
staleResolvers,
likelyCause: "propagation_lag",
recommendLowerTtl: configuredTtl > 3600,
};
}
async function queryResolver(name, rdtype, resolverIp) {
// One resolver's answer set and the TTL it currently reports.
const dns = await import("node:dns");
const { Resolver } = dns.promises;
const resolver = new Resolver();
resolver.setServers([resolverIp]);
const method = rdtype === "AAAA" ? "resolve6" : "resolve4";
const values = await resolver[method](name, { ttl: true });
const set = new Set(values.map((v) => v.address));
const ttl = values.length ? values[0].ttl : 0;
return { values: set, ttl };
}
async function queryAuthoritative(name, rdtype, domain) {
// Ask the zone's own authoritative nameservers directly, bypassing every
// public resolver's cache.
const dns = await import("node:dns");
const { Resolver } = dns.promises;
const defaultResolver = new Resolver();
const nsHosts = await defaultResolver.resolveNs(domain);
const nsIps = await defaultResolver.resolve4(nsHosts[0]);
const authResolver = new Resolver();
authResolver.setServers([nsIps[0]]);
const method = rdtype === "AAAA" ? "resolve6" : "resolve4";
const values = await authResolver[method](name, { ttl: true });
const set = new Set(values.map((v) => v.address));
const ttl = values.length ? values[0].ttl : 0;
return { values: set, ttl };
}
async function findRecordId(name, rdtype) {
const url = new URL(`https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records`);
url.searchParams.set("type", rdtype);
url.searchParams.set("name", name);
const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
const { result } = await res.json();
return result[0] || null;
}
async function lowerTtl(recordId, name, rdtype, content, newTtl = 300) {
// Repair step: lower the record's TTL through the Cloudflare API so
// future changes converge faster. Only called for propagation lag with
// a high configured TTL, never for an authoritative mismatch.
const url = `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`;
const res = await fetch(url, {
method: "PATCH",
headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ type: rdtype, name, content, ttl: newTtl }),
});
if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
return res.json();
}
export async function run() {
console.log(`Checking resolver consistency for ${DNS_DOMAIN} ${RECORD_TYPE} (DRY_RUN=${DRY_RUN})`);
const { values: authoritativeAnswer, ttl: configuredTtl } = await queryAuthoritative(DNS_DOMAIN, RECORD_TYPE, DNS_DOMAIN);
console.log(`Authoritative answer: ${[...authoritativeAnswer].sort()} (TTL ${configuredTtl})`);
const resolverAnswers = new Map();
const resolverTtls = new Map();
for (const ip of PUBLIC_RESOLVERS) {
try {
const { values, ttl } = await queryResolver(DNS_DOMAIN, RECORD_TYPE, ip);
resolverAnswers.set(ip, values);
resolverTtls.set(ip, ttl);
console.log(`Resolver ${ip}: ${[...values].sort()} (TTL ${ttl})`);
} catch (err) {
console.warn(`Resolver ${ip} failed to answer: ${err.message}`);
}
}
const result = diagnoseResolverInconsistency(authoritativeAnswer, resolverAnswers, resolverTtls, configuredTtl);
console.log("Diagnosis:", result);
if (result.consistent) {
console.log("OK: every resolver agrees with the authoritative answer. Nothing to do.");
return;
}
if (result.likelyCause === "authoritative_mismatch") {
console.warn(
"Authoritative mismatch detected, this is not ordinary propagation. " +
"Fix the record at the DNS host so every authoritative server agrees."
);
return;
}
console.warn(
`Ordinary propagation lag. Stale resolvers: ${result.staleResolvers}. This will clear on its own ` +
`within the current TTL window (${configuredTtl} seconds).`
);
if (result.recommendLowerTtl) {
const record = await findRecordId(DNS_DOMAIN, RECORD_TYPE);
if (!record) {
console.warn("Could not find the record via the Cloudflare API to lower its TTL.");
return;
}
if (DRY_RUN) {
console.log(`Dry run: would lower TTL for record ${record.id} from ${configuredTtl} to 300`);
return;
}
await lowerTtl(record.id, DNS_DOMAIN, RECORD_TYPE, record.content, 300);
console.log(`Lowered TTL for ${DNS_DOMAIN} to 300 seconds so future changes converge faster.`);
}
}
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 decision rule is the part most worth testing, because it decides whether the script tells you to sit tight or to change a live record's TTL. Because the function takes plain sets and dicts and returns a plain dict, the test needs no network and no DNS library at all.
from check_resolver_consistency import diagnose_resolver_inconsistency
def test_consistent_when_every_resolver_matches():
result = diagnose_resolver_inconsistency(
authoritative_answer={"203.0.113.10"},
resolver_answers={"1.1.1.1": {"203.0.113.10"}, "8.8.8.8": {"203.0.113.10"}},
resolver_ttls={"1.1.1.1": 250, "8.8.8.8": 300},
configured_ttl=300,
)
assert result["consistent"] is True
assert result["stale_resolvers"] == []
assert result["likely_cause"] == "none"
def test_propagation_lag_with_high_ttl_recommends_lower_ttl():
result = diagnose_resolver_inconsistency(
authoritative_answer={"203.0.113.10"},
resolver_answers={"1.1.1.1": {"203.0.113.10"}, "8.8.8.8": {"198.51.100.5"}},
resolver_ttls={"1.1.1.1": 100, "8.8.8.8": 60000},
configured_ttl=86400,
)
assert result["consistent"] is False
assert result["stale_resolvers"] == ["8.8.8.8"]
assert result["likely_cause"] == "propagation_lag"
assert result["recommend_lower_ttl"] is True
def test_authoritative_mismatch_when_expired_ttl_still_disagrees():
result = diagnose_resolver_inconsistency(
authoritative_answer={"203.0.113.10"},
resolver_answers={"1.1.1.1": {"198.51.100.5"}},
resolver_ttls={"1.1.1.1": 2},
configured_ttl=300,
)
assert result["consistent"] is False
assert result["likely_cause"] == "authoritative_mismatch"
assert result["recommend_lower_ttl"] is False
def test_authoritative_mismatch_when_all_resolvers_agree_but_differ_from_authoritative():
result = diagnose_resolver_inconsistency(
authoritative_answer={"203.0.113.10"},
resolver_answers={"1.1.1.1": {"198.51.100.5"}, "8.8.8.8": {"198.51.100.5"}},
resolver_ttls={"1.1.1.1": 200, "8.8.8.8": 200},
configured_ttl=300,
)
assert result["consistent"] is False
assert result["likely_cause"] == "authoritative_mismatch"
assert result["recommend_lower_ttl"] is False
def test_propagation_lag_with_low_ttl_does_not_recommend_lower_ttl():
result = diagnose_resolver_inconsistency(
authoritative_answer={"203.0.113.10"},
resolver_answers={"1.1.1.1": {"203.0.113.10"}, "8.8.8.8": {"198.51.100.5"}},
resolver_ttls={"1.1.1.1": 100, "8.8.8.8": 250},
configured_ttl=300,
)
assert result["consistent"] is False
assert result["likely_cause"] == "propagation_lag"
assert result["recommend_lower_ttl"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseResolverInconsistency } from "./check-resolver-consistency.js";
test("consistent when every resolver matches", () => {
const result = diagnoseResolverInconsistency(
new Set(["203.0.113.10"]),
new Map([["1.1.1.1", new Set(["203.0.113.10"])], ["8.8.8.8", new Set(["203.0.113.10"])]]),
new Map([["1.1.1.1", 250], ["8.8.8.8", 300]]),
300,
);
assert.equal(result.consistent, true);
assert.deepEqual(result.staleResolvers, []);
assert.equal(result.likelyCause, "none");
});
test("propagation lag with high ttl recommends lower ttl", () => {
const result = diagnoseResolverInconsistency(
new Set(["203.0.113.10"]),
new Map([["1.1.1.1", new Set(["203.0.113.10"])], ["8.8.8.8", new Set(["198.51.100.5"])]]),
new Map([["1.1.1.1", 100], ["8.8.8.8", 60000]]),
86400,
);
assert.equal(result.consistent, false);
assert.deepEqual(result.staleResolvers, ["8.8.8.8"]);
assert.equal(result.likelyCause, "propagation_lag");
assert.equal(result.recommendLowerTtl, true);
});
test("authoritative mismatch when expired ttl still disagrees", () => {
const result = diagnoseResolverInconsistency(
new Set(["203.0.113.10"]),
new Map([["1.1.1.1", new Set(["198.51.100.5"])]]),
new Map([["1.1.1.1", 2]]),
300,
);
assert.equal(result.consistent, false);
assert.equal(result.likelyCause, "authoritative_mismatch");
assert.equal(result.recommendLowerTtl, false);
});
test("authoritative mismatch when all resolvers agree but differ from authoritative", () => {
const result = diagnoseResolverInconsistency(
new Set(["203.0.113.10"]),
new Map([["1.1.1.1", new Set(["198.51.100.5"])], ["8.8.8.8", new Set(["198.51.100.5"])]]),
new Map([["1.1.1.1", 200], ["8.8.8.8", 200]]),
300,
);
assert.equal(result.consistent, false);
assert.equal(result.likelyCause, "authoritative_mismatch");
assert.equal(result.recommendLowerTtl, false);
});
test("propagation lag with low ttl does not recommend lower ttl", () => {
const result = diagnoseResolverInconsistency(
new Set(["203.0.113.10"]),
new Map([["1.1.1.1", new Set(["203.0.113.10"])], ["8.8.8.8", new Set(["198.51.100.5"])]]),
new Map([["1.1.1.1", 100], ["8.8.8.8", 250]]),
300,
);
assert.equal(result.consistent, false);
assert.equal(result.likelyCause, "propagation_lag");
assert.equal(result.recommendLowerTtl, false);
});
Case studies
The migration that "propagated" for a full day
A team moved a site to a new host and updated the A record, but the previous team had left the TTL at 86400 seconds (24 hours) for years. For most of the next day, some visitors landed on the new server while others, whose ISP resolver had cached the record only an hour earlier, kept hitting the old one.
Nothing was actually wrong. Checking every authoritative nameserver confirmed they all already served the new IP. The team waited out the remaining window, and once it passed, every resolver agreed. They set the TTL to 300 seconds afterward so the next move would not repeat the same day-long wait.
The one nameserver that never got the update
A record change looked "stuck" on one public resolver no matter how long the team waited, well past any reasonable TTL. Querying each authoritative nameserver individually showed the real cause, three of the four nameservers had the new record, the fourth was still serving the old one from a failed sync at the DNS host.
This was not caching lag at all, it was an authoritative mismatch. The DNS host fixed the sync on the fourth nameserver, and every resolver converged normally afterward without needing to wait any longer.
Every authoritative nameserver for the zone agrees. Every public resolver you check returns that same answer, or is clearly still inside its own TTL window and will catch up before long. The TTL on records you expect to change again is short enough that the next cutover clears in minutes, not hours. A quick multi-resolver check before and after any change confirms this without any guesswork.
FAQ
Why do 1.1.1.1 and 8.8.8.8 show different answers for the same domain?
Each public resolver keeps its own cached copy of a DNS answer until that answer's TTL runs out, then it asks your authoritative nameservers again. If you changed a record recently, resolvers that already had the old answer cached keep serving it until their TTL expires, while resolvers that ask fresh get the new value right away. Both answers are correct for that moment, the difference is just cache timing.
Is there a way to force every resolver to update right now?
No. There is no push mechanism in DNS and no button that tells other people's resolvers to drop their cache. You can only wait out the TTL that was in effect when you made the change. What you can control is future changes, by lowering the TTL ahead of time so the next cutover clears faster.
How do I know if this is normal caching or an actual misconfiguration?
Check every authoritative nameserver for the zone directly with dig, bypassing all public resolvers. If every authoritative server agrees with each other, the record is correct and any resolver disagreement is ordinary caching that will clear on its own. If the authoritative servers disagree with each other, that is a real misconfiguration, a partial rollout at the DNS host, not propagation.
Related field notes
Citations
On the problem:
- RFC 1035: Domain Names, Implementation and Specification, defines TTL as the resolver's cache lifetime for an answer. datatracker.ietf.org/doc/html/rfc1035
- RFC 8767: Serving Stale Data to Improve DNS Resiliency, documents resolvers that intentionally cache past the declared TTL. datatracker.ietf.org/doc/html/rfc8767
- Julia Evans: DNS "propagation" is actually caches expiring, a clear explanation of why resolvers disagree after a change. jvns.ca/blog/2021/12/06/dns-doesn-t-propagate
On the solution:
- Cloudflare DNS docs: general DNS troubleshooting, including cache and propagation behavior. developers.cloudflare.com/dns/troubleshooting/dns-issues
- Cloudflare DNS docs: FAQ covering TTL and propagation questions. developers.cloudflare.com/dns/faq
- Cloudflare API Reference: DNS record details and update, including the ttl field used to lower a record's cache lifetime. developers.cloudflare.com/api/resources/dns/subresources/records/methods/get
Stuck on a tricky one?
If you have a DNS, domain, or DNSSEC 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 confusing afternoon?
If this helped you tell normal DNS caching apart from a real misconfiguration, 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