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.

Python and Node.js dnspython + Cloudflare API Safe by default (dry run)
Network wires
Photo by Kirill Sh on Unsplash
The short answer

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.

Authoritative NS now serves the new IP Resolver A (8.8.8.8) cached old answer, TTL not expired Old IP 198.51.100.5 Resolver B (1.1.1.1) cache expired, asked fresh New IP: 203.0.113.10 Two people ask "what is the IP?" and get two different, both-valid-for-now answers
Nothing is broken. Resolver A has not re-checked yet, resolver B has. Both are correctly reporting what they cached at the time.

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:

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.

The key insight

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.

Query every authoritative NS directly All NS agree? yes Lower TTL e.g. to 300s, then wait Old TTL expires no, disagree Fix the record at the DNS host
If every authoritative server already agrees, the only remaining problem is the wait, and the fix is a shorter TTL for next time. If the authoritative servers disagree with each other, fix that record first.

How to fix it

1

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.

Terminal
compare-resolvers.sh
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
2

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.

Terminal
check-authoritative.sh
dig +short NS example.com

dig +short A example.com @ns1.example-dns-host.com
3

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.

Terminal
check-ttl.sh
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
4

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.

Terminal
check-all-ns.sh
for ns in ns1.example-dns-host.com ns2.example-dns-host.com; do
  echo "$ns:"
  dig +short A example.com @$ns
done
5

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.

DNS record
Cloudflare API: lower the record's TTL
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
}
6

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.

7

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.

Terminal
verify.sh
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.

check_resolver_consistency.py
"""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()
check-resolver-consistency.js
/**
 * 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.

test_resolver_diagnosis.py
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
resolver-diagnosis.test.js
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

Leftover long TTL

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.

Partial rollout

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.

What good looks like

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:

  1. RFC 1035: Domain Names, Implementation and Specification, defines TTL as the resolver's cache lifetime for an answer. datatracker.ietf.org/doc/html/rfc1035
  2. RFC 8767: Serving Stale Data to Improve DNS Resiliency, documents resolvers that intentionally cache past the declared TTL. datatracker.ietf.org/doc/html/rfc8767
  3. 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:

  1. Cloudflare DNS docs: general DNS troubleshooting, including cache and propagation behavior. developers.cloudflare.com/dns/troubleshooting/dns-issues
  2. Cloudflare DNS docs: FAQ covering TTL and propagation questions. developers.cloudflare.com/dns/faq
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes