Diagnostic TTL / Propagation

Stale negative cache keeps a fixed record unresolvable

You added the missing record. You checked it yourself and it works. Then a coworker, or a customer, or a monitor somewhere still gets NXDOMAIN like nothing changed. Nothing is broken. A resolver asked about the name before the record existed, was told "no such name," and is still repeating that answer because it has not been told otherwise yet. Here is why that happens and what to do about it.

dig and Cloudflare API Python and Node.js Self-heals over time
A padlock on a keyboard
Photo by Sasun Bughdaryan on Unsplash
The short answer

When a resolver asks for a name that does not exist yet, the authoritative server answers NXDOMAIN along with the zone's SOA record. The SOA has a MINIMUM field that tells the resolver how long it may treat that "no such name" answer as true. The resolver holds onto that answer for the full window, by the clock, and does not check again early, even after you add the record. Different resolvers asked at different times, so they clear at different times too. That is why it works for you and not for someone else. There is no way to force another resolver to drop that cached answer. You can only confirm the record is right at the source, wait out the remaining window, and shorten the zone's negative-cache TTL so this is shorter next time. Full commands and a small checker script are below.

The problem in plain words

Every DNS answer that says "this name does not exist" comes with an expiry built in. When the authoritative nameserver returns NXDOMAIN, it attaches the zone's SOA record in the authority section. The last number in that SOA, called MINIMUM, is the number of seconds any resolver is allowed to remember that negative answer for, per RFC 2308. It exists so that a busy resolver does not have to ask the same "does this exist" question over and over for names that keep coming up empty.

The catch is that resolvers trust the clock, not the zone. Once a resolver has cached "NXDOMAIN, good for the next hour," it will keep answering NXDOMAIN for that full hour no matter what happens at the authoritative server in the meantime. Adding the record does not push any signal out to resolvers that already asked. Each resolver only finds out the name is real the next time it happens to ask again, which is when its own cached entry finally expires.

Resolver asks early before record exists Caches NXDOMAIN for SOA MINIMUM secs record added, ignored Still NXDOMAIN on the clock, not the zone Works for me not for them
The resolver's cached NXDOMAIN is not linked to the zone anymore. It expires on its own countdown, not when the record changes.

Why it happens

This is the classic "it resolves for me but not for my coworker" report, and it is confusing because both people are telling the truth about what they see.

The key insight

A cached NXDOMAIN is not tied to the zone once it is cached. Per RFC 2308, the resolver strictly honors the SOA MINIMUM window from the moment it cached the negative answer. It does not re-check early just because you fixed the record. Two things can both be true at once: the authoritative server has been correct for an hour, and a specific resolver is still wrong because its own clock has not run out yet.

The fix, as a flow

There is nothing to fix in the record if it is already correct at the source. The work is to confirm that, measure how much stale time is left on each affected resolver, wait it out, and shorten the SOA MINIMUM so the next gap is smaller.

dig @authoritative NS confirm record is live Query 1.1.1.1, 8.8.8.8, 9.9.9.9 Read SOA TTL seconds left to expire Still stale on any resolver? no yes, wait it out Lower SOA MINIMUM for next time
Confirm the record is correct at the source, measure the remaining stale window on each resolver, then shorten the zone's negative-cache TTL so this gap is smaller in the future.

How to fix it

1

Confirm the record is really live at the authoritative server

Skip every cache and ask the zone's own nameserver directly, without recursion. If this already returns the record with NOERROR, the zone is not the problem. Anything still showing NXDOMAIN from here would mean the record genuinely is not in the zone yet, which is a different note.

Terminal
check-authoritative (shell)
dig @ns1.example.com A newhost.example.com +norecurse

# a good answer looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR
# ;; ANSWER SECTION:
# newhost.example.com.  300  IN  A  203.0.113.10
2

Read the zone's negative-cache window from the SOA

The last field of the SOA record, called MINIMUM, is how long any resolver may cache a "does not exist" answer for names in this zone. This is the number you are waiting on for every stale resolver.

Terminal
check-soa (shell)
dig SOA example.com +noall +answer

# example.com.  3600  IN  SOA  ns1.example.com. hostmaster.example.com. (
#                              2026071101 ; serial
#                              7200       ; refresh
#                              3600       ; retry
#                              1209600    ; expire
#                              3600 )     ; MINIMUM, the negative-cache TTL
3

Compare public resolvers directly to find who is still stale

Query the same name against a few resolvers people actually use. A resolver still on the old answer shows status: NXDOMAIN with a SOA record in the authority section, and the TTL on that SOA is the countdown of seconds left until it expires and re-checks.

Terminal
check-resolvers (shell)
dig @1.1.1.1 A newhost.example.com
dig @8.8.8.8 A newhost.example.com
dig @9.9.9.9 A newhost.example.com

# a stale result looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN
# ;; AUTHORITY SECTION:
# example.com.  2143  IN  SOA  ns1.example.com. hostmaster.example.com. ...
# (2143 is the seconds left before this resolver's cached NXDOMAIN expires)
4

Shorten the zone's negative-cache TTL for next time

You cannot evict a stale answer from a resolver you do not control. What you can do is lower the SOA MINIMUM so that the next time a name gets queried before its record exists, the stale window is short instead of an hour or more. In Cloudflare, this is exposed through the zone's DNS Settings.

Terminal
lower-negative-ttl (shell)
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_settings" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"ns_ttl":300}'

How to check it worked

Wait roughly the SOA MINIMUM window, plus a little margin, since each resolver's stale entry was cached at a different moment. Then re-query every resolver you flagged as stale. A good result shows NOERROR with the actual record, and no SOA left in the authority section. A trace bypasses every cache and shows the authoritative chain fresh, which is useful to prove the zone itself was fine the whole time.

Terminal
verify (shell)
dig @1.1.1.1 A newhost.example.com
dig @8.8.8.8 A newhost.example.com
dig @9.9.9.9 A newhost.example.com
dig +trace newhost.example.com A
dig SOA example.com +noall +answer

# a good answer looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR
# newhost.example.com.  300  IN  A  203.0.113.10
# (and the SOA MINIMUM now reads 300 instead of 3600, if you lowered it)

The full code

Here is a small script that reads the zone's SOA MINIMUM, queries a name against several public resolvers directly, and flags any resolver still returning NXDOMAIN after the authoritative server already confirms the record exists. It reports an estimated wait time per stale resolver from the countdown TTL it sees, and can optionally lower the zone's negative-cache TTL through Cloudflare. It never tries to evict a cached answer from a resolver, since no such API exists.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

stale_negative_cache.py
"""Detect a stale negative-cache NXDOMAIN across public resolvers.
Safe to run on a schedule. Only reads, unless you also lower the zone's
negative-cache TTL, which is guarded by DRY_RUN.
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stale_negative_cache")


def stale_negative_cache_report(soa_minimum: int, soa_ttl_seen: int,
                                 resolver_results: dict, authoritative_has_record: bool) -> dict:
    """
    Pure decision logic, no I/O.
    - soa_minimum: the SOA MINIMUM field (negative-cache TTL) in seconds, from RFC 2308.
    - soa_ttl_seen: the TTL value seen on the SOA record inside an NXDOMAIN authority section.
    - resolver_results: {resolver_ip: (rcode, sample_ttl)}.
    - authoritative_has_record: True if the authoritative server currently answers NOERROR.
    Returns: {"stale_resolvers": [...], "eta_seconds": {...}, "is_stale_negative_cache": bool}
    """
    stale = []
    eta = {}
    for resolver, (rcode, ttl) in resolver_results.items():
        if authoritative_has_record and rcode == "NXDOMAIN":
            stale.append(resolver)
            eta[resolver] = max(ttl, 0)
    return {
        "stale_resolvers": stale,
        "eta_seconds": eta,
        "is_stale_negative_cache": authoritative_has_record and len(stale) > 0,
        "max_wait_seconds": max(eta.values()) if eta else 0,
    }


def run():
    # Imported lazily so the pure function above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import dns.rdatatype
    import requests

    domain = os.environ["DNS_DOMAIN"]
    zone = os.environ.get("DNS_ZONE", domain)
    public_resolvers = os.environ.get("PUBLIC_RESOLVERS", "1.1.1.1,8.8.8.8,9.9.9.9").split(",")
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")
    api_token = os.environ.get("CLOUDFLARE_API_TOKEN")

    # Confirm the record at the authoritative server first.
    ns_answer = dns.resolver.resolve(zone, "NS")
    nameservers = [str(r.target).rstrip(".") for r in ns_answer]
    ns_ip = dns.resolver.resolve(nameservers[0], "A")[0].address

    auth_resolver = dns.resolver.Resolver()
    auth_resolver.nameservers = [ns_ip]

    authoritative_has_record = True
    try:
        auth_resolver.resolve(domain, "A")
    except dns.resolver.NXDOMAIN:
        authoritative_has_record = False
    except dns.resolver.NoAnswer:
        authoritative_has_record = False

    # Read the zone's SOA MINIMUM (negative-cache TTL).
    soa = dns.resolver.resolve(zone, "SOA")[0]
    soa_minimum = soa.minimum

    # Query each public resolver directly and record rcode plus the SOA TTL
    # it shows in the authority section, if any.
    resolver_results = {}
    for ip in public_resolvers:
        r = dns.resolver.Resolver()
        r.nameservers = [ip.strip()]
        try:
            r.resolve(domain, "A")
            resolver_results[ip.strip()] = ("NOERROR", 0)
        except dns.resolver.NXDOMAIN as exc:
            ttl = 0
            for rrset in (exc.response().authority if hasattr(exc, "response") else []):
                if rrset.rdtype == dns.rdatatype.SOA:
                    ttl = rrset.ttl
            resolver_results[ip.strip()] = ("NXDOMAIN", ttl)
        except dns.resolver.NoAnswer:
            resolver_results[ip.strip()] = ("NOERROR", 0)

    report = stale_negative_cache_report(soa_minimum, 0, resolver_results, authoritative_has_record)
    log.info("Report for %s: %s", domain, report)

    if not report["is_stale_negative_cache"]:
        log.info("No stale negative cache detected.")
        return

    log.info("Stale on %s. Longest wait about %d seconds.",
              ", ".join(report["stale_resolvers"]), report["max_wait_seconds"])

    if not zone_id or not api_token:
        log.info("No Cloudflare credentials set, skipping negative-cache TTL check.")
        return

    settings = requests.get(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_settings",
        headers={"Authorization": f"Bearer {api_token}"},
        timeout=30,
    )
    settings.raise_for_status()
    log.info("Current zone DNS settings: %s", settings.json().get("result"))

    if dry_run:
        log.info("Dry run: would lower the zone's negative-cache TTL to speed up future recovery.")
        return

    resp = requests.patch(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_settings",
        headers={"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"},
        json={"ns_ttl": 300},
        timeout=30,
    )
    resp.raise_for_status()
    log.info("Lowered the zone's negative-cache TTL to 300 seconds for next time.")


if __name__ == "__main__":
    run()
stale-negative-cache.js
/**
 * Detect a stale negative-cache NXDOMAIN across public resolvers.
 * Safe to run on a schedule. Only reads, unless you also lower the zone's
 * negative-cache TTL, which is guarded by DRY_RUN.
 */
import { pathToFileURL } from "node:url";

export function staleNegativeCacheReport(soaMinimum, soaTtlSeen, resolverResults, authoritativeHasRecord) {
  // Pure decision logic, no I/O.
  // resolverResults: { [resolverIp]: [rcode, sampleTtl] }
  const stale = [];
  const eta = {};
  for (const [resolver, [rcode, ttl]] of Object.entries(resolverResults)) {
    if (authoritativeHasRecord && rcode === "NXDOMAIN") {
      stale.push(resolver);
      eta[resolver] = Math.max(ttl, 0);
    }
  }
  const etaValues = Object.values(eta);
  return {
    staleResolvers: stale,
    etaSeconds: eta,
    isStaleNegativeCache: authoritativeHasRecord && stale.length > 0,
    maxWaitSeconds: etaValues.length ? Math.max(...etaValues) : 0,
  };
}

export async function run() {
  // Imported lazily so the pure function above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");
  const resolvePromises = dns.promises;

  const domain = process.env.DNS_DOMAIN;
  const zone = process.env.DNS_ZONE || domain;
  const publicResolvers = (process.env.PUBLIC_RESOLVERS || "1.1.1.1,8.8.8.8,9.9.9.9").split(",");
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  // Confirm the record at the authoritative server first.
  const nameservers = await resolvePromises.resolveNs(zone);
  const nsIp = (await resolvePromises.resolve4(nameservers[0]))[0];

  const authResolver = new dns.promises.Resolver();
  authResolver.setServers([nsIp]);

  let authoritativeHasRecord = true;
  try {
    await authResolver.resolve4(domain);
  } catch (err) {
    if (err.code === "ENOTFOUND" || err.code === "ENODATA") {
      authoritativeHasRecord = false;
    } else {
      throw err;
    }
  }

  // Read the zone's SOA MINIMUM (negative-cache TTL). Node's resolveSoa
  // exposes it as minttl.
  const soa = await resolvePromises.resolveSoa(zone);
  const soaMinimum = soa.minttl;

  // Query each public resolver directly and record rcode plus a sample TTL.
  const resolverResults = {};
  for (const ip of publicResolvers) {
    const r = new dns.promises.Resolver();
    r.setServers([ip.trim()]);
    try {
      await r.resolve4(domain);
      resolverResults[ip.trim()] = ["NOERROR", 0];
    } catch (err) {
      if (err.code === "ENOTFOUND") {
        // node:dns does not expose the authority-section SOA TTL directly,
        // so a real implementation would parse the raw response; here we
        // record the negative hit with an unknown (0) sample TTL.
        resolverResults[ip.trim()] = ["NXDOMAIN", 0];
      } else if (err.code === "ENODATA") {
        resolverResults[ip.trim()] = ["NOERROR", 0];
      } else {
        throw err;
      }
    }
  }

  const report = staleNegativeCacheReport(soaMinimum, 0, resolverResults, authoritativeHasRecord);
  console.log(`Report for ${domain}:`, report);

  if (!report.isStaleNegativeCache) {
    console.log("No stale negative cache detected.");
    return;
  }

  console.log(`Stale on ${report.staleResolvers.join(", ")}. Longest wait about ${report.maxWaitSeconds} seconds.`);

  if (!zoneId || !apiToken) {
    console.log("No Cloudflare credentials set, skipping negative-cache TTL check.");
    return;
  }

  const settingsRes = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_settings`, {
    headers: { Authorization: `Bearer ${apiToken}` },
  });
  if (!settingsRes.ok) throw new Error(`Cloudflare API returned ${settingsRes.status}`);
  const settings = await settingsRes.json();
  console.log("Current zone DNS settings:", settings.result);

  if (dryRun) {
    console.log("Dry run: would lower the zone's negative-cache TTL to speed up future recovery.");
    return;
  }

  const patchRes = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_settings`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
    body: JSON.stringify({ ns_ttl: 300 }),
  });
  if (!patchRes.ok) throw new Error(`Cloudflare API returned ${patchRes.status}`);
  console.log("Lowered the zone's negative-cache TTL to 300 seconds for next time.");
}

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 reporting function is the part worth testing on its own, because it decides which resolvers count as stale and how long the wait should be. It takes plain numbers, a dict of tuples, and a bool, so the test needs no network and no DNS library at all.

test_stale.py
from stale_negative_cache import stale_negative_cache_report


def test_detects_stale_resolver_when_authoritative_is_fixed():
    results = {"8.8.8.8": ("NXDOMAIN", 2143), "1.1.1.1": ("NOERROR", 299)}
    report = stale_negative_cache_report(3600, 2143, results, True)
    assert report["is_stale_negative_cache"] is True
    assert report["stale_resolvers"] == ["8.8.8.8"]
    assert report["eta_seconds"]["8.8.8.8"] == 2143
    assert report["max_wait_seconds"] == 2143


def test_not_stale_when_authoritative_still_missing():
    results = {"8.8.8.8": ("NXDOMAIN", 2143)}
    report = stale_negative_cache_report(3600, 2143, results, False)
    assert report["is_stale_negative_cache"] is False
    assert report["stale_resolvers"] == []


def test_no_stale_resolvers_when_all_agree():
    results = {"8.8.8.8": ("NOERROR", 300), "1.1.1.1": ("NOERROR", 300)}
    report = stale_negative_cache_report(3600, 0, results, True)
    assert report["is_stale_negative_cache"] is False
    assert report["max_wait_seconds"] == 0


def test_negative_ttl_is_clamped_to_zero():
    results = {"9.9.9.9": ("NXDOMAIN", -5)}
    report = stale_negative_cache_report(3600, -5, results, True)
    assert report["eta_seconds"]["9.9.9.9"] == 0
stale-negative-cache.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { staleNegativeCacheReport } from "./stale-negative-cache.js";

test("detects stale resolver when authoritative is fixed", () => {
  const results = { "8.8.8.8": ["NXDOMAIN", 2143], "1.1.1.1": ["NOERROR", 299] };
  const report = staleNegativeCacheReport(3600, 2143, results, true);
  assert.equal(report.isStaleNegativeCache, true);
  assert.deepEqual(report.staleResolvers, ["8.8.8.8"]);
  assert.equal(report.etaSeconds["8.8.8.8"], 2143);
  assert.equal(report.maxWaitSeconds, 2143);
});

test("not stale when authoritative still missing", () => {
  const results = { "8.8.8.8": ["NXDOMAIN", 2143] };
  const report = staleNegativeCacheReport(3600, 2143, results, false);
  assert.equal(report.isStaleNegativeCache, false);
  assert.deepEqual(report.staleResolvers, []);
});

test("no stale resolvers when all agree", () => {
  const results = { "8.8.8.8": ["NOERROR", 300], "1.1.1.1": ["NOERROR", 300] };
  const report = staleNegativeCacheReport(3600, 0, results, true);
  assert.equal(report.isStaleNegativeCache, false);
  assert.equal(report.maxWaitSeconds, 0);
});

test("negative ttl is clamped to zero", () => {
  const results = { "9.9.9.9": ["NXDOMAIN", -5] };
  const report = staleNegativeCacheReport(3600, -5, results, true);
  assert.equal(report.etaSeconds["9.9.9.9"], 0);
});

Case studies

New hire onboarding

The internal tool that worked for the infra team and nobody else

A team added the DNS record for a new internal tool and confirmed it worked from their own laptops within minutes. A new hire trying the same URL an hour later got NXDOMAIN and assumed they had the wrong address. Support checked the record, saw it was correct, and was stuck until someone thought to check the SOA.

The new hire's laptop was on a corporate resolver that had cached NXDOMAIN two hours earlier, right before the record was added, with a 3600 second MINIMUM. Waiting the remaining window fixed it without touching a single record. The zone's negative-cache TTL was lowered to 300 seconds afterward.

Launch day

The product launch that got tested too early

A marketing page for a new subdomain was shared in a Slack channel for early review, before the record was live. Several people clicked it and got NXDOMAIN, which their browsers and OS resolvers cached. The record was published minutes later, but the same people kept hitting stale answers when the page actually went live.

Checking against 1.1.1.1, 8.8.8.8, and 9.9.9.9 directly showed two of the three already resolving fine, with the third counting down from a SOA TTL of under ten minutes. The team simply waited, and reminded themselves to pre-create records before ever sharing a link.

What good looks like

Once every previously stale resolver has aged out its cached NXDOMAIN, dig @resolver A newhost.example.com against each one returns status: NOERROR with the real record, and the SOA no longer shows up in the authority section. A dig +trace confirms the authoritative chain was correct the whole time. Going forward, a shorter SOA MINIMUM means any future gap between "someone asked too early" and "the record exists" self-heals in minutes instead of an hour or more.

FAQ

I added the DNS record, so why does it still say NXDOMAIN for some people?

Some resolvers queried the name before you created the record and cached the NXDOMAIN answer. That cached negative answer is honored strictly by its own clock, not rechecked against the zone, so it keeps being returned until it expires on its own. Different resolvers cache it at different times, so it clears for each one at a different moment.

Can I force a resolver to drop a stale NXDOMAIN?

Not for a resolver you do not control. There is no API call or DNS record change that reaches into someone else's recursive resolver and evicts a cached answer. You can only confirm the record is correct at your own authoritative server and wait out the negative-cache TTL on the affected resolver.

How long will the stale NXDOMAIN last?

As long as the SOA MINIMUM field says, counted from when that resolver last queried the name, not from when you added the record. Check the countdown TTL on the SOA record inside the NXDOMAIN answer to see how much time is left, then lower the SOA MINIMUM so future gaps are shorter.

Related field notes

Citations

On the problem:

  1. RFC 2308: Negative caching of DNS queries (DNS NCACHE). rfc-editor.org/rfc/rfc2308
  2. DNS resolvers: negative caching, the setting that makes outages last longer. cr0x.net/en/dns-negative-caching-outages
  3. Cloudflare DNS docs: general DNS issues and troubleshooting. developers.cloudflare.com/dns/troubleshooting/dns-issues

On the solution:

  1. Cloudflare DNS docs: Time to Live (TTL). developers.cloudflare.com/dns/manage-dns-records/reference/ttl
  2. Cloudflare API: DNS Settings. developers.cloudflare.com/dns/additional-options/dns-settings
  3. Cloudflare Community: is the default TTL from SOA record configurable. community.cloudflare.com

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate 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 fix your stale NXDOMAIN?

If this saved you a confusing afternoon of "it works for me" DNS debugging, 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