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.
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.
Why it happens
- A subdomain or hostname was referenced by an app, a link, or a DNS check before its record was created, so the first query anyone's resolver made got a true NXDOMAIN, and that got cached.
- The record was added right away, but the public resolvers people actually use, 1.1.1.1, 8.8.8.8, 9.9.9.9, a corporate recursor, all asked at slightly different times, so each one is now holding its own copy of the old answer with its own expiry.
- The zone's SOA MINIMUM was left at a large default, such as 3600 or more, so any early query before a record exists creates a long-lived stale window.
- Nobody checked the authoritative server directly, so the team assumed the record itself was still wrong when the zone had actually been correct for a while.
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.
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.
How to fix it
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.
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
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.
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
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.
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)
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.
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- RFC 2308: Negative caching of DNS queries (DNS NCACHE). rfc-editor.org/rfc/rfc2308
- DNS resolvers: negative caching, the setting that makes outages last longer. cr0x.net/en/dns-negative-caching-outages
- Cloudflare DNS docs: general DNS issues and troubleshooting. developers.cloudflare.com/dns/troubleshooting/dns-issues
On the solution:
- Cloudflare DNS docs: Time to Live (TTL). developers.cloudflare.com/dns/manage-dns-records/reference/ttl
- Cloudflare API: DNS Settings. developers.cloudflare.com/dns/additional-options/dns-settings
- 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.
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