← Back to the library
OperationsHow it works · Core · 12 min

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

A cold lookup for www.example.comNothing is cached. The stub asks once with RD set; the recursive resolver iterates through referrals and caches each step, so the next lookup for any .com name skips the root.
  1. 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.
  2. 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.".
  3. 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).
  4. Recursive resolver → .com TLD serverA? www.example.com
  5. .com TLD server → Recursive resolverReferral: example.com. NSThese NS records and glue come from the registrar. Changing nameservers means waiting out this TTL.
  6. Recursive resolver → Authoritative NSA? www.example.com
  7. 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.
  8. 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.
  9. Authoritative NS → Recursive resolverA 203.0.113.7, TTL 60
  10. Recursive resolver → App / stub resolverCNAME + A, TTLs counting downThe resolver caches every record separately. The OS, browser and runtime may cache again on top.
Record types you will actually touchEvery record has an owner name, class (IN), type, TTL and data. SOA and NS exist at every zone apex.
HoldsTypical useGotcha
A / AAAAIPv4 / IPv6 addressPoint a name at servers or a load balancerMissing AAAA is fine; a broken AAAA causes slow happy-eyeballs fallbacks
CNAMEAlias to another namePoint a subdomain at a SaaS or CDN hostCannot coexist with any other record, so not at the zone apex
MXPriority + mail host nameRoute inbound emailMust point to a name with A/AAAA, never to a CNAME or an IP
TXTArbitrary stringsSPF, DKIM, DMARC, domain verificationStrings over 255 bytes must be split; multiple SPF records is an error
NSNameservers for a zoneDelegation at the parent and apexParent (registrar) and child NS sets drift apart silently
CAAWhich CAs may issue certsRestrict issuance to your CAA CA must refuse to issue if it is not listed, breaking renewals
HTTPS / SVCBEndpoint, ALPN, hints, ECH keysAdvertise h3, alias the apexClients that ignore it still need A/AAAA at the same name
SOAZone serial, timers, negative TTLZone metadata, secondariesIts 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.
dig (BIND 9) documentation (opens in new tab)

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.
RFC 1035 master format documentation (opens in new tab)

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().
Node.js node:dns documentation (opens in new tab)

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.
net.Resolver documentation (opens in new tab)

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.
Terraform Cloudflare provider documentation (opens in new tab)

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.
curl + DNS over HTTPS documentation (opens in new tab)

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.

SOURCES

Explainer reviewed