DNS, from stub resolver to authoritative answer
What happens between typing a hostname and getting an IP address, and why do DNS changes take so long to show up?
DNS is a distributed, cached database. A stub resolver asks a recursive resolver, which walks root, TLD and authoritative servers on a cold cache and then caches every answer, including "does not exist", for as long as each TTL allows.
THE MENTAL MODEL
Think of DNS as a cache hierarchy with one source of truth per zone. The authoritative servers own the records; everything else (the recursive resolver, the OS, the browser, your runtime, sometimes your HTTP client) holds copies that expire on a TTL the owner chose in the past. "Propagation" is not a push: nothing is propagated, old copies simply age out. So a DNS change is only as fast as the longest TTL someone cached before you made it, including negative answers cached from the SOA. Debugging is therefore always the same two questions: what does the authoritative server say right now, and which cache between it and the client still holds something else?
HOW IT FITS TOGETHER
- App / stub resolver → Recursive resolverA? www.example.com (RD=1)The stub comes from /etc/resolv.conf, DHCP, or a DoH setting in the browser. It does no iteration itself.
- Recursive resolver → Root serverA? www.example.comRoot server addresses come from built-in root hints. With QNAME minimisation (RFC 9156) the resolver only asks the root about "com.".
- Root server → Recursive resolverReferral: com. NS + glueNot an answer: a delegation to the .com servers, cached for the TTL of those NS records (typically two days).
- Recursive resolver → .com TLD serverA? www.example.com
- .com TLD server → Recursive resolverReferral: example.com. NSThese NS records and glue come from the registrar. Changing nameservers means waiting out this TTL.
- Recursive resolver → Authoritative NSA? www.example.com
- Authoritative NS → Recursive resolverCNAME edge.cdn.net. (AA=1)Authoritative answer. A CNAME restarts resolution at the target name, often in another zone with its own walk.
- Recursive resolver → Authoritative NSA? edge.cdn.net (CDN's NS)Shown on the same lane for brevity; in reality this goes to the CDN's authoritative servers after another referral chain.
- Authoritative NS → Recursive resolverA 203.0.113.7, TTL 60
- Recursive resolver → App / stub resolverCNAME + A, TTLs counting downThe resolver caches every record separately. The OS, browser and runtime may cache again on top.
| Holds | Typical use | Gotcha | |
|---|---|---|---|
| A / AAAA | IPv4 / IPv6 address | Point a name at servers or a load balancer | Missing AAAA is fine; a broken AAAA causes slow happy-eyeballs fallbacks |
| CNAME | Alias to another name | Point a subdomain at a SaaS or CDN host | Cannot coexist with any other record, so not at the zone apex |
| MX | Priority + mail host name | Route inbound email | Must point to a name with A/AAAA, never to a CNAME or an IP |
| TXT | Arbitrary strings | SPF, DKIM, DMARC, domain verification | Strings over 255 bytes must be split; multiple SPF records is an error |
| NS | Nameservers for a zone | Delegation at the parent and apex | Parent (registrar) and child NS sets drift apart silently |
| CAA | Which CAs may issue certs | Restrict issuance to your CA | A CA must refuse to issue if it is not listed, breaking renewals |
| HTTPS / SVCB | Endpoint, ALPN, hints, ECH keys | Advertise h3, alias the apex | Clients that ignore it still need A/AAAA at the same name |
| SOA | Zone serial, timers, negative TTL | Zone metadata, secondaries | Its MINIMUM field sets how long NXDOMAIN is cached |
KEY TERMS
- Stub, recursive and authoritative
- A stub resolver (libc getaddrinfo, systemd-resolved, the browser) sends one recursive query and waits. A recursive resolver (your ISP, 1.1.1.1, 8.8.8.8, a VPC resolver) does the iterative walk and owns the shared cache. Authoritative servers only answer for zones they host and set the AA flag; they never recurse.
- TTL and caching
- Each record carries a TTL in seconds that tells caches how long to keep it. Resolvers count it down and may clamp it to their own min/max. Lower a TTL a full old-TTL period before a migration, make the change, then raise it again; lowering it at the moment of change does nothing for copies already cached.
- Negative caching
- NXDOMAIN (the name does not exist) and NODATA (the name exists, but not with this type) are cached too, for the lesser of the SOA MINIMUM field and the SOA record's TTL (RFC 2308). Querying a hostname before creating it can keep it "missing" for that long.
- CNAME at the apex and flattening
- A CNAME may not coexist with other data at the same name (RFC 1034), and the apex always has SOA and NS, so example.com cannot be a CNAME. Providers offer CNAME flattening or ALIAS/ANAME records: the authoritative server resolves the target itself and serves synthetic A/AAAA. The HTTPS record's AliasMode (priority 0) is the standard alternative, but only clients that understand HTTPS records follow it.
- DNSSEC
- Signs records so resolvers can verify they came from the zone owner. Each zone publishes DNSKEYs and RRSIG signatures; the parent publishes a DS hash of the child's key, forming a chain of trust up to the root. It provides integrity, not privacy, and a broken DS/DNSKEY pair makes validating resolvers return SERVFAIL for the whole zone.
- DoT and DoH
- DNS over TLS (RFC 7858, port 853) and DNS over HTTPS (RFC 8484, application/dns-message on 443) encrypt the stub-to-recursive hop. They hide queries from the network path but not from the resolver you chose, and browsers with DoH enabled can bypass the OS resolver and the split-horizon (internal-only) names it knows about.
- Where caches hide
- Besides the recursive resolver: the OS (systemd-resolved, mDNSResponder), the browser's host cache, the JVM (networkaddress.cache.ttl), connection pools that never reconnect, and long-lived processes that resolved once at boot. A correct TTL does not help a client that keeps an open connection to the old IP.
IN YOUR STACK
Shell · dig (BIND 9) Client
# What does my configured resolver return, and with what remaining TTL?
dig www.example.com A +noall +answer
# Walk the delegation from the root yourself (bypasses resolver caches)
dig www.example.com +trace
# Ask the authoritative server directly: the source of truth right now
dig example.com NS +short
dig @ns1.example-dns.net www.example.com A +norecurse
# Compare public resolvers to see which cache is stale
for r in 1.1.1.1 8.8.8.8 9.9.9.9; do echo "$r: $(dig @$r www.example.com +short)"; done
# Negative answers: status NXDOMAIN or NOERROR with no answer, SOA in authority
dig nothere.example.com A +noall +comments +authority
# DNSSEC: look for the "ad" flag, RRSIGs and the parent's DS record
dig example.com A +dnssec
dig example.com DS @a.gtld-servers.net +norecurse
# Encrypted transports
dig @1.1.1.1 example.com +https
dig @1.1.1.1 example.com +tls- +trace makes iterative queries itself, starting from the root, and turns off recursion, so it shows what the hierarchy says now rather than what your resolver cached.
- The TTL in a recursive answer is the remaining time in that cache, not the configured TTL. Ask the authoritative server with +norecurse to see the real value.
- SERVFAIL from validating resolvers but a good answer with +cd (checking disabled) points at a DNSSEC problem.
DNS zone file · RFC 1035 master format Server
$ORIGIN example.com.
$TTL 3600
@ IN SOA ns1.example-dns.net. hostmaster.example.com. (
2026110601 ; serial: bump on every change
7200 ; refresh (secondaries)
900 ; retry
1209600 ; expire
300 ) ; minimum: negative-cache TTL
@ IN NS ns1.example-dns.net.
@ IN NS ns2.example-dns.net.
@ 300 IN A 203.0.113.10
@ 300 IN AAAA 2001:db8::10
@ IN HTTPS 1 . alpn=h2,h3
www IN CNAME example.com.
api 60 IN CNAME api-prod.lb.example-cloud.net.
@ IN MX 10 mail.example.com.
mail IN A 203.0.113.25
@ IN TXT "v=spf1 mx -all"
@ IN CAA 0 issue "letsencrypt.org"
@ IN CAA 0 iodef "mailto:security@example.com"- Trailing dots matter: without one, the name is relative to $ORIGIN, so "example.com" would become example.com.example.com.
- HTTPS 1 . means ServiceMode with the owner name as target; it advertises HTTP/3 so browsers can skip the Alt-Svc round trip.
- Low TTLs on api make failover fast but raise resolver load and expose you to resolver outages more often.
TypeScript · Node.js node:dns Server
import dns from "node:dns";
import { Resolver } from "node:dns/promises";
// lookup(): getaddrinfo, the OS path. Honors /etc/hosts and nsswitch,
// runs on the libuv threadpool. This is what http.request and fetch use.
const { address, family } = await dns.promises.lookup("api.example.com");
// resolve*(): always a network DNS query, no threadpool, ignores /etc/hosts.
const resolver = new Resolver({ timeout: 2000, tries: 2 });
resolver.setServers(["1.1.1.1", "8.8.8.8"]); // fallback only on timeout/error
const records = await resolver.resolve4("api.example.com", { ttl: true });
// [{ address: "203.0.113.7", ttl: 42 }] remaining TTL in that cache
const mx = await resolver.resolveMx("example.com");
const txt = await resolver.resolveTxt("example.com"); // string[][] (chunks)
try {
await resolver.resolve4("nothere.example.com");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOTFOUND") { /* NXDOMAIN */ }
}
dns.setDefaultResultOrder("ipv4first"); // default is "verbatim"- Node caches nothing itself. A burst of outbound requests means a burst of getaddrinfo calls that can saturate the 4-thread default pool (UV_THREADPOOL_SIZE) and stall fs and crypto work.
- Avoid resolveAny: many servers refuse or minimise ANY queries (RFC 8482). Query the specific types you need.
- lookup() and resolve4() can disagree: /etc/hosts, search domains and the OS resolver only affect lookup().
Go · net.Resolver Server
package main
import ("context"; "fmt"; "net"; "time")
func main() {
r := &net.Resolver{
PreferGo: true, // pure Go resolver: a blocked query costs a goroutine, not a thread
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 2 * time.Second}
return d.DialContext(ctx, network, "1.1.1.1:53") // pin a resolver
},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
addrs, err := r.LookupNetIP(ctx, "ip", "api.example.com")
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
fmt.Println("NXDOMAIN or no records")
return
}
fmt.Println(addrs, err)
mx, _ := r.LookupMX(ctx, "example.com")
txt, _ := r.LookupTXT(ctx, "example.com")
fmt.Println(mx, txt)
}- On Unix, Go picks the pure Go or cgo resolver at runtime. GODEBUG=netdns=go or netdns=cgo forces one; netdns=go+1 logs the decision.
- The package-level net.LookupHost uses context.Background; use Resolver methods so lookups honour request deadlines.
- Like Node, the standard library does not cache answers. Long-lived http.Transport connections keep using an old IP until they close.
HCL · Terraform Cloudflare provider Server
variable "zone_id" { type = string }
# Apex pointing at a hostname: Cloudflare flattens it into A/AAAA answers.
resource "cloudflare_dns_record" "apex" {
zone_id = var.zone_id
name = "@"
type = "CNAME"
content = "my-app.pages.dev"
proxied = true
ttl = 1 # 1 = automatic
}
resource "cloudflare_dns_record" "api" {
zone_id = var.zone_id
name = "api.example.com"
type = "A"
content = "203.0.113.7"
proxied = false
ttl = 300
comment = "Managed by Terraform; do not edit in dashboard"
}
resource "cloudflare_dns_record" "caa" {
zone_id = var.zone_id
name = "@"
type = "CAA"
data = { flags = 0, tag = "issue", value = "letsencrypt.org" }
ttl = 3600
}- Provider v5 renamed cloudflare_record to cloudflare_dns_record and value to content; ttl is required and must be 1 (automatic) or 60 to 86400.
- Proxied records are always flattened and answered with Cloudflare edge IPs, so the origin IP never appears in DNS.
- Flattening a CNAME used for third-party verification breaks it, because the CNAME itself is no longer returned.
Shell · curl + DNS over HTTPS Client
# JSON API (Cloudflare and Google flavour, not part of RFC 8484)
curl -s -H "accept: application/dns-json" \
"https://cloudflare-dns.com/dns-query?name=example.com&type=HTTPS" | jq '.Answer'
# RFC 8484 wire format: GET with the base64url query in ?dns=, no padding.
# This is "www.example.com A" with DNS ID 0, the example from the RFC.
curl -s -H "accept: application/dns-message" \
"https://cloudflare-dns.com/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" \
| xxd | head
# Make curl itself resolve names over DoH instead of the OS resolver
curl --doh-url https://cloudflare-dns.com/dns-query https://example.com/ -o /dev/null -w "%{remote_ip}\n"- Wire-format DoH is the standard and what browsers use; the JSON form is a convenience API whose fields vary by provider.
- Clients should send DNS ID 0 so identical queries are cacheable by HTTP caches.
- DoH bypasses the OS stub: names that only exist in a corporate split-horizon zone or /etc/hosts will not resolve.
WHERE IT BITES
- Lowering the TTL at the same moment you change the record. Caches keep the old answer for the old TTL; lower it at least one old-TTL period in advance.
- Probing a hostname before it exists. The NXDOMAIN is cached for the SOA negative TTL, so a freshly created record looks broken for minutes to hours.
- Testing only through your own resolver. Ask the authoritative server with +norecurse and compare a few public resolvers before declaring a change live or broken.
- Assuming DNS failover is instant. Clients cache beyond TTLs, connection pools keep sockets to the old IP, and some resolvers clamp low TTLs upward.
- Changing nameservers or DNSSEC keys without a plan. Parent NS and DS records are cached for days, and a DS that no longer matches a DNSKEY makes the zone SERVFAIL for every validating resolver.
- Forgetting CAA when switching certificate authorities or adding a CDN that issues certs, so renewals fail weeks later.
CLOSE THE AI. EXPLAIN THIS.
You move api.example.com to a new load balancer with a 3600 s TTL and some users still hit the old one two hours later. Name every cache that could still hold the old answer, how you would prove which one with dig, and what you would do differently next time.WHEN IT BREAKS IN PRODUCTION
SOURCES
- RFC 2308: Negative Caching of DNS Queries (opens in new tab)Checked
- RFC 9460: SVCB and HTTPS Resource Records (opens in new tab)Checked
- RFC 8484: DNS Queries over HTTPS (DoH) (opens in new tab)Checked
- BIND 9 manual pages: dig (opens in new tab)Checked
- Node.js: DNS (opens in new tab)Checked
- Cloudflare: CNAME flattening (opens in new tab)Checked
Explainer reviewed