Skip to content
Software Atlas

DNS

How a name like example.com becomes an IP address: resolvers, the root-to-domain hierarchy, record types, TTLs and the caches hiding in every layer — plus the Go and Node resolver traps, Kubernetes ndots, and why outages so often turn out to be “just DNS”.

⏱ ~45 min read14 sections5 interactive widgetsGo · JavaScript · dig
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceDNS turns names people can remember into the numbers computers connect to. It's a phone book — but split across millions of servers, and copied into caches everywhere.

Computers on the internet find each other by IP address, the way phones call numbers. But nobody wants to type 142.250.74.46 to search the web. DNS (the Domain Name System) is the lookup step in between: before your browser, your Go service or your Node script can send a single byte to api.example.com, something has to ask "what's the IP address for this name?"

One phone book? No — a tree of them

No single server could hold every name on the internet and answer billions of questions a second. So DNS is split into a tree, and each part of the tree is run by whoever owns it. Read a name right to left to walk down from the top:

The DNS tree — read a name from right to left
. (root)comorgdevexample.comgoogle.comwikipedia.orgwwwapiroot serversTLDsdomainsnames inside a domain
Each dot in a name is a step down the tree. api.example.com = start at the root, go to com, then example, then api.
  • The invisible root at the top (written as a final ., which you almost never type) knows who runs each ending: com, org, dev, uk …
  • The com servers know who runs each name registered under .com — example.com, google.com, millions more.
  • example.com's own servers know the actual addresses: www.example.com, api.example.com, and so on.

Nobody has to know everything. Each level just knows who to ask next. Section §02 walks through a real lookup, hop by hop.

Why "it's always DNS"

There's a running joke among engineers: when something mysterious breaks, "it's always DNS." Three facts explain it:

1

It's in front of everything

Almost every request starts with a name lookup. If DNS is slow, everything is slow; if it fails, nothing connects — and the error rarely says "DNS".
2

It's cached everywhere

Answers are copied into caches in your browser, your OS, your ISP (internet provider) and your app. A change isn't seen everywhere at once — some clients keep the old answer for minutes or hours.
3

It's spread across owners

Your domain, your DNS provider, your cloud and the user's ISP each run a piece. A mistake in any one of them looks like your outage.

A first taste

Here is the question your program asks, in three forms. dig is the command-line tool engineers use to ask DNS directly; we'll lean on it all through this topic.

look up example.com
$ dig +short example.com
192.0.2.10

The map

§02–§04 are the basics: how a lookup travels, and what DNS can store. §05–§07 are what bites developers: caching and TTLs, what goes over the wire, and how Go and Node actually resolve names. §08–§11 are the production stories: real failure modes, debugging, security, and DNS as a service-discovery and load-balancing tool. §12–§14 wrap up with pitfalls, a quiz and a cheat sheet.

Foundations§02

What happens when you type example.com

In one sentenceYour computer asks one server a question. That server — the recursive resolver — walks down the tree from the root to find the answer, then remembers it so the next person gets it instantly.

The cast

Five players take part in a lookup. Two of them work for you; three are the tree from §01.

WhoWhere it runsWhat it does
Browser / app cacheinside your programRemembers recent answers for itself. (Browsers do; Go and Node don't — §07.)
Stub resolveryour operating systemChecks /etc/hosts (a small local file of name → IP overrides, checked before DNS) and maybe a small cache, then forwards the question. It never walks the tree itself.
Recursive resolveryour ISP (internet provider), your company, or a public one like 8.8.8.8 (Google) or 1.1.1.1 (Cloudflare)Does the real work: asks the tree, step by step, and caches every answer it gets.
Root servers13 named servers, copied to hundreds of locationsKnows only who runs each ending: .com, .org, .uk…
TLD servers, then the Authoritative name serverrun by the registry (e.g. Verisign for .com), then by the domain owner's DNS provider.com knows who runs example.com; example.com's own servers know the actual address.

Step through a real lookup

Pick a scenario and step through it. Watch two things: the cache in each box filling up, and the latency bar at the bottom growing with each round trip across the internet.

Recursive resolution, hop by hop
Top row: your side. Bottom row: the DNS tree. Purple = sending, cyan = receiving.
Browser
its own small cache
cache empty
OS stub resolver
/etc/hosts + maybe a cache
cache empty
Recursive resolver
e.g. 1.1.1.1 — big shared cache
cache empty
Root server
knows: who runs .com, .org …
.com → a.gtld-servers.net
.com TLD server
knows: who runs each .com
example.com → ns1.example.com
example.com's server
authoritative: the real records
example.com A 192.0.2.10
Browser— need the IP for example.com
You type example.com. Before anything else, the browser checks its own cache. It's empty — this is the first visit.
time spent waiting on the network0 ms
root.comauthoritativeyour machine ↔ resolver(round-trip times are typical, not measured)
1 / 11

What you just saw

Each server between the resolver and the answer replied with a referral: "I don't know, but these servers do." Handing off part of the tree like that is called Delegation. The resolver follows referrals until it reaches a server that does know — the authoritative one — and that server answers with the record.

There are two styles of asking, and both appeared in the walk-through:

  • Your OS asks recursively: "find me the final answer, however many hops it takes." It sends one question and waits for one reply.
  • The resolver asks iteratively: each server answers just one hop ("ask the .com servers next") and the resolver does the next hop itself.

A chicken-and-egg detail: glue

The .com servers said "example.com is handled by ns1.example.com". But to reach ns1.example.com, you'd need its IP… which only example.com's servers know. Round and round.

DNS breaks the loop with a Glue record: alongside the referral, the .com servers also hand over ns1.example.com's IP address. That's why, in the cold scenario above, the .com referral arrived with an address attached. When your name servers live under a different domain — like ns1.example-dns.net, which the rest of this topic uses for example.com — no glue is needed: the resolver just looks that name up first, which can cost extra round trips.

Foundations§03

Record types: what DNS can store

In one sentenceDNS doesn't just store "name → IP". Each name can hold several records of different types, and the type says what the value means.

Anatomy of a record

Every piece of DNS data is a DNS record with the same five fields. Here's one, exactly as dig prints it:

One record, five fields
example.com.
300
IN
A
192.0.2.10
name
TTL (seconds)
class
type
value (meaning depends on type)
IN means “internet” — the only class you'll meet in practice. The trailing dot on the name means “this is the full name, all the way to the root”.

The TTL is the TTL: how many seconds anyone may cache this answer before asking again. It gets a whole section (§05) because it causes more confusion than everything else here combined.

A name can have several records of the same type — two A records means "either of these two IPs works". The group of all records with one name and one type is answered together, as a set.

Reading dig output

Without options, dig prints the whole reply. It looks noisy, but only four parts matter (output trimmed — real dig prints a few more lines, including an OPT PSEUDOSECTION: that's the one ADDITIONAL record, an EDNS marker explained in §06):

$ dig example.com A

; <<>> DiG 9.18.28 <<>> example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 31012     ← ① status
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:                                          ← ② what you asked
;example.com.                   IN      A

;; ANSWER SECTION:                                            ← ③ the records
example.com.            300     IN      A       192.0.2.10

;; Query time: 23 msec                                        ← ④ who answered, how fast
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; MSG SIZE  rcvd: 56
  1. status: NOERROR means the name exists (the answer section may still be empty if it has no record of that type). NXDOMAIN means the name doesn't exist at all — that's the NXDOMAIN answer. SERVFAIL means the resolver couldn't get an answer, e.g. the domain's servers are broken.
  2. flags: qr = this is a reply, rd = "recursion desired" (you asked the resolver to do the full walk), ra = "recursion available". An aa flag would mean the answer came straight from an authoritative server rather than a cache.
  3. answer section: the records, in the five-field format above.
  4. server and time: which resolver answered (1.1.1.1, on Port 53), and how long it took. Run the same command twice: the second time is usually a few milliseconds, because the resolver cached it — and the TTL will have gone down.

Most of the time you just want the records: dig +short example.com prints only the values, and dig +noall +answer example.com prints only the answer section.

Explore the record types

Each scenario is something real software does. Pick one to see the dig command, its output, and what the client does with the answer.

Record-type explorer
Pick a job. See the query, the answer, and what the client does with it.
Job: Open https://example.com in a browser
Queryaddresses are documentation examples
$ dig +noall +answer example.com A
$ dig +noall +answer example.com AAAA
Answer section
example.com.		300	IN	A	192.0.2.10
example.com.		300	IN	AAAA	2001:db8::10
What the fields mean
A = an IPv4 address (four numbers). AAAA (“quad-A”) = an IPv6 address (the longer hex form). A name can have either, both, or several of each.
What the client does next
The client asks for both, then tries to connect — modern clients race IPv6 and IPv4 and use whichever connects first (“Happy Eyeballs”). Then it opens a TCP connection to port 443 on that IP.

The whole family on one table

TypeValueWho reads it
Aan IPv4 addressevery client connecting to a name
AAAAan IPv6 address ("quad-A")the same clients, when they have IPv6
CNAMEanother name ("this is an alias for…")resolvers, which then look up that name (§04)
MXa mail server name + prioritymail servers delivering email to @yourdomain
TXTfree textemail anti-forgery checks (SPF, DKIM, DMARC — they tell receiving mail servers which senders to trust), "prove you own this domain" checks
NSthe names of the domain's authoritative serversresolvers following referrals (§02)
SOAzone settings, incl. the negative-cache timeresolvers caching "doesn't exist" answers (§05)
SRVhost + port + priority + weight for a serviceservice discovery, SIP, XMPP, Kubernetes (§11)
CAAwhich certificate authorities may issue certificatesCAs, before issuing a Certificate
PTRa name for an IP (reverse lookup)mail servers' spam checks, logs, traceroute (a tool that lists every router on the path to a host)
Practitioner§04

CNAMEs, the apex problem and ALIAS records

In one sentenceA CNAME says "this name is a nickname — go look up that name instead." It's how you point your domain at a service whose IPs you don't control.

Why aliases exist

Say you host your site on a CDN. The CDN runs thousands of servers and changes their IPs whenever it likes. If you copied their IPs into your own A records, you'd have to update them every time the CDN moved — and you'd never know when.

Instead, you publish one record that points at a name the CDN controls:

$ dig +noall +answer www.example.com
www.example.com.                3600  IN  CNAME  example-com.edge.cdn.example.net.
example-com.edge.cdn.example.net.  60  IN  A      203.0.113.7
example-com.edge.cdn.example.net.  60  IN  A      203.0.113.8

Read it top to bottom: www.example.com is an alias for example-com.edge.cdn.example.net, and that name has two IPs. The CDN changes its own A records whenever it wants (notice its short 60-second TTL), and your CNAME never has to change.

Following a CNAME
you ask
www.example.com A?
resolver chases the chain…
203.0.113.7, .8
hop 1
→ CNAME edge.cdn…
hop 2
edge.cdn… → A records
The resolver does the chasing, then returns the whole chain in one reply. Every extra link can mean extra round trips on a cold cache — keep chains short.

The rule that causes all the trouble

A CNAME means "everything about this name lives over there". So the DNS standard says: a name with a CNAME may have no other records at all — no A, no MX, no TXT. Otherwise a resolver asking for the MX wouldn't know whether to use the local MX or follow the alias.

That rule collides with the zone apex — the bare domain, example.com itself, also called the Zone apex. Every zone's apex must carry an SOA record and NS records (§03). So the apex already has other records, and can't be a CNAME.

example.com.  3600  IN  SOA    ns1.example-dns.net. …   ← required at the apex
example.com.  3600  IN  NS     ns1.example-dns.net.     ← required at the apex
example.com.  3600  IN  CNAME  myapp.herokudns.com.     ← ✕ not allowed alongside them

In practice: www.example.com can be a CNAME to your hosting provider, but example.com can't. Many DNS dashboards simply refuse to save it.

The workarounds

1

ALIAS / ANAME / flattening

Your DNS provider offers a special record type — an ALIAS / CNAME flattening record. It looks up the target name itself and serves the resulting IPs as ordinary A/AAAA records at the apex. Clients never see a CNAME.
2

Cloud-native aliases

Route 53 "alias" records do the same, but only for targets inside AWS (load balancers, CloudFront…), and they resolve instantly inside AWS's own DNS.
3

Redirect the apex

Point example.com at a tiny web server with fixed IPs whose only job is to redirect visitors to www.example.com with an HTTP redirect. Old-school, but works with any provider.
Practitioner§05

TTLs and the caches hiding everywhere

In one sentenceA DNS change is not "pushed" anywhere. Every cache keeps the old answer until its own copy expires — so a change reaches everyone only after the old TTL has run out everywhere.

"DNS propagation" is really cache expiry

People say "wait for DNS to propagate", as if the new record were travelling around the world. It isn't. The moment you save it, your authoritative servers start serving it. What you're actually waiting for is every cache that already holds the old answer to throw it away.

Worked example. Your record has a TTL of 3600 (one hour), and you change the IP at 12:00:

  • A resolver that last fetched it at 11:10 keeps the old IP until 12:10 (11:10 + 1 hour).
  • A resolver that fetched it at 11:59 keeps the old IP until 12:59.
  • A resolver that hasn't asked since yesterday has nothing cached — it gets the new IP on its next question.

So the worst case for well-behaved caches is "change time + old TTL": 13:00 here. Note that it's the old TTL that matters — whatever TTL was attached to the answer at the moment each cache stored it.

The caches between your users and your servers

LayerCaches DNS?Watch out for
BrowserYes, brieflyChrome keeps answers for about a minute when it asks the OS (which doesn't report TTLs); its built-in resolver follows the TTL. Approximate — browsers change this.
OSSometimesmacOS and Windows cache; on Linux it depends — desktops usually run systemd-resolved (a local DNS cache); many servers and most containers have no OS cache at all.
Recursive resolverYes, by TTLSome clamp TTLs to a minimum or maximum of their own choosing, so your TTL is a request, not a guarantee.
Your app's runtimeDependsGo and Node: no cache (§07). Java: 30 s by default today, but older setups cached successful lookups forever.
Open connectionsNot a cache — but acts like oneA connection that's already open keeps talking to the IP it connected to. It never looks the name up again (§11).

See it happen

The record below changes from the old IP to a new one at time 0. Each row is a client that last looked up the name at a different moment. Drag through time, change the TTL, and try the two ways of preparing for a migration.

When does everyone see the new IP?
The record changes from OLD 192.0.2.10 to NEW 192.0.2.99 at time 0. Red = this client still uses the old IP.
Migration prep:
Your laptop
looked it up 5% of a TTL ago
old IP for 57 min
→ OLD
A phone
looked it up 70% of a TTL ago
old IP for 18 min
→ NEW
ISP resolver (+ all its users)
cached it just before the change
old IP for 59 min
→ OLD
Public resolver
cached it 40% of a TTL ago
old IP for 36 min
→ OLD
Old Java service
caches lookups forever
old IP until it restarts
→ OLD
Go service, keep-alive pool
connection opened yesterday
old IP until the connection closes
→ OLD
change (0)t = 25 min71 min
1 of 6 clients use the new IP at t = 25 min. No preparation: every cache keeps the TTL it got (60 min).
The last two rows never switch on their own: one ignores TTLs, the other already has a connection open and never looks the name up again (§11). That's why you keep the old server running after a move.

The migration playbook

You're moving api.example.com to a new server. The trick is to make the TTL short before the move, and to keep the old server alive after it.

  1. Days before (at least one old-TTL ahead — a full day if the TTL is 86400): lower the TTL to 60 seconds. Now wait for the old, long-TTL copies to expire everywhere. Lowering it 10 minutes before the move does almost nothing — the widget's "too late" option shows why.
  2. Move: change the record to the new IP. Well-behaved caches pick it up within about a minute.
  3. Keep the old server running and watch its traffic fall. Some clients ignore TTLs, and long-lived connections never re-resolve. Shut it down only when its traffic is near zero — often a day or more later.
  4. After: raise the TTL again (an hour is a common choice) so resolvers cache it and your DNS provider isn't asked constantly.

Negative caching: "doesn't exist" is cached too

Resolvers also cache NXDOMAIN — the answer "this name doesn't exist". That's Negative caching, and it stops a typo storm from hammering the authoritative servers. How long it's cached comes from the zone's SOA record: the last number in the SOA (often called the "minimum"), or the SOA's own TTL if that's smaller.

The trap, which every team hits once:

$ dig +short new-api.example.com     # checking whether it works yet… (it doesn't exist yet)
$                                    # empty: NXDOMAIN — and the resolver caches that
# you create the record in the dashboard one minute later
$ dig +short new-api.example.com
$                                    # still empty, for up to the SOA's negative-cache time!
$ dig +short @ns1.example-dns.net new-api.example.com
192.0.2.30                           # the authoritative server already has it

The @server form asks that server directly, skipping your resolver's cache — the quickest way to check "is the record right, and I'm just looking at a stale cache?" (more in §09). Create records before anything looks them up, and keep the SOA negative-cache time modest (300–900 s is common).

Practitioner§06

On the wire: UDP, TCP and encrypted DNS

In one sentenceA normal DNS lookup is one small UDP packet out and one back, on port 53. No connection, no handshake — which makes it fast, and also explains its strangest failures.

Why UDP?

There are two main ways to send data across the internet. TCP sets up a connection first — a back-and-forth Handshake before any data moves — and then guarantees delivery. UDP just fires off individual packets: no setup, no guarantee. (TCP vs UDP covers the difference in depth.)

A DNS question and its answer each usually fit in a single packet of a hundred bytes or so. With TCP, you'd spend a full round trip on the handshake before even asking. With UDP, the question goes out immediately. For something in front of every request, that saved round trip matters.

$ sudo tcpdump -n -i eth0 port 53      # print DNS traffic on interface eth0 as it happens
10:42:01.120 IP 10.0.0.5.51234 > 1.1.1.1.53: 31012+ A? example.com. (29)
10:42:01.143 IP 1.1.1.1.53 > 10.0.0.5.51234: 31012 1/0/0 A 192.0.2.10 (45)
#                ↑ random source port   ↑ ID   ↑ 1 answer   ↑ the answer   ↑ size in bytes

Two lines, 23 milliseconds, done. 31012 is the query's ID number: the reply must carry the same one, so the client can match replies to questions. (It also matters for security — §10.)

The price of UDP: lost packets mean waiting

UDP doesn't resend lost packets. If the question or the answer is dropped, the client hears nothing — it can't tell "slow" from "lost". So it waits for a Timeout, then tries again.

On Linux that waiting is set in /etc/resolv.conf, and the defaults are harsh: 5 seconds per try, 2 tries per server.

One lost packet, five seconds gone
try 1
query lost… waiting 5 s for an answer that never comes
try 2
✓
Approximate. This is the classic signature of a DNS problem: requests that take almost exactly 5 s (or 10 s, or 2.5 s with other settings) longer than usual.
# /etc/resolv.conf — the OS resolver's settings
nameserver 10.0.0.2            # ask this resolver first…
nameserver 10.0.0.3            # …then this one if the first doesn't answer
options timeout:2 attempts:3   # wait 2 s per try, up to 3 rounds (defaults: 5 s and 2)

When the answer is too big: truncation and TCP

Original DNS limited UDP answers to 512 bytes. A name with many records, long TXT values or security signatures (§10) can exceed that. Two mechanisms deal with it:

  1. EDNS: the query says "I can accept bigger UDP answers" — usually 1232 bytes today, a size chosen so the packet won't need to be split up on the way.
  2. If the answer still doesn't fit, the server sends a cut-down reply with the TC flag set — that's Truncation. The client then asks again over TCP, where size doesn't matter.
Too big for UDP: retry over TCP
UDP
query
reply, TC=1
TCP
handshake
query
full 3 KB reply
The TCP retry costs a handshake plus another round trip. It's rare for ordinary lookups, but it's normal for large answers.

Privacy: plain DNS is a postcard

Plain DNS is not encrypted. Everyone on the path — the café Wi-Fi, your ISP, anyone on the network in between — can read every name you look up, and can tamper with the answers. Even when the page itself is HTTPS, the DNS lookup before it gave away which site you visited.

Two standards wrap DNS in encryption (TLS, the same layer that puts the S in HTTPS): DoH / DoT.

Plain DNSDNS over TLS (DoT)DNS over HTTPS (DoH)
Port53 UDP/TCP853 TCP443 (looks like any web traffic)
Who can read namesanyone on the pathonly the resolveronly the resolver
Typical userOS defaultAndroid "Private DNS", systemd-resolvedFirefox, Chrome, Cloudflare/Google apps
Practitioner§07

DNS from Go and Node: two resolvers each

In one sentenceBoth Go and Node can resolve names two ways: ask the operating system (which reads /etc/hosts and the OS settings) or talk DNS directly over the network. Which one your code uses changes speed, behaviour and failure modes.

The two paths

The "ask the OS" path goes through getaddrinfo, a function in the system's libc that every C program uses. It honours /etc/hosts, the OS's resolver settings and anything else the OS is configured to consult. But it blocks the Thread that calls it until the answer arrives.

The "talk DNS directly" path builds DNS packets itself and sends them to the resolver listed in /etc/resolv.conf. It doesn't need to block a thread, but it only knows what DNS knows.

Go: a pure-Go resolver, and a fallback to the C library

Go ships its own DNS client written in Go. On Linux it's the default: it reads /etc/hosts and /etc/resolv.conf itself, sends the A and AAAA queries in parallel, and waits for answers the same cheap way Go waits for any network I/O — a parked Goroutine, not a blocked thread (see Async / Await §09).

The alternative is the cgo resolver, which calls the system's getaddrinfo through cgo. Go switches to it automatically when it sees settings it can't handle itself — for example, an /etc/nsswitch.conf that asks for name sources Go doesn't understand. On macOS and Windows, Go asks the OS by default. You can see and override the choice:

$ GODEBUG=netdns=1 ./server          # print which resolver Go picked
go package net: dynamic selection of DNS resolver
$ GODEBUG=netdns=go ./server         # force the pure-Go resolver
$ GODEBUG=netdns=cgo ./server        # force the C library (getaddrinfo)
$ CGO_ENABLED=0 go build             # no C at all → always pure Go

For everyday code, net.LookupHost (or just dialing a hostname) is fine. When you need control — a deadline, a specific DNS server, a forced resolver — build a net.Resolver:

resolver.go
r := &net.Resolver{
    PreferGo: true, // ← use Go's own DNS client, not getaddrinfo
    Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
        d := net.Dialer{Timeout: 2 * time.Second}
        return d.DialContext(ctx, network, "1.1.1.1:53") // ← ignore resolv.conf's server, ask this one
    },
}

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // ← always release the timer, even on success

ips, err := r.LookupHost(ctx, "api.example.com")
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
    // NXDOMAIN: the name really doesn't exist — retrying won't help
} else if err != nil {
    // timeout or server failure: maybe temporary, maybe retry
}
fmt.Println(ips) // [192.0.2.20]

The error text tells you a lot. From the pure-Go resolver: lookup nope.example.com on 8.8.8.8:53: no such host — the on 8.8.8.8:53 part names the resolver from /etc/resolv.conf (it still says that even with a custom Dial, so don't trust it blindly when you've overridden one).

Node: dns.lookup vs dns.resolve* — the same module, two different machines

two ways to resolve in Node
import { lookup } from "node:dns/promises";

// → getaddrinfo, on a libuv thread-pool thread
// ✓ honours /etc/hosts and OS settings
// ✗ no TTL, no timeout option of its own
// ✗ only ~2 run at once by default (see below)
const { address } = await lookup("api.example.com");

Neither tab is "wrong". The first is the default: http.get, https.request, net.connect and fetch all resolve hostnames with dns.lookup. The second uses c-ares, a library that does DNS on the network without blocking any thread.

Failures look different too: getaddrinfo ENOTFOUND nope.invalid comes from dns.lookup; queryA ENOTFOUND nope.example.com comes from resolve4. And getaddrinfo EAI_AGAIN means "temporary failure" — usually the resolver didn't answer at all, a classic sign of broken DNS inside a container.

The production trap: dns.lookup and the thread pool

Because getaddrinfo blocks, Node runs it on the libuv thread pool (4 threads by default — Threads & Processes §07). And libuv treats DNS as "slow work": to keep file reads from starving, it lets slow work use at most half the pool (half, rounded up). With the default 4 threads, that's 2 lookups at a time.

When DNS is healthy, lookups take a millisecond and nobody notices. When the resolver gets slow — say 2 s per answer — lookups line up behind each other:

8 outgoing requests to 8 different hosts, resolver taking 2 s per answer
thread 1
lookup 1
lookup 3
lookup 5
lookup 7
thread 2
lookup 2
lookup 4
lookup 6
lookup 8
thread 3
thread 4
waiting
lookups 3–8 queued: nobody sends their HTTP request yet
Measured on Node 22 with the default pool of 4: the 8th lookup finishes after ~8 s, not ~2 s. A file read started at the same time still finished in a few milliseconds, on the other two threads. With UV_THREADPOOL_SIZE=16 (8 allowed at once) or with dns.resolve4, all 8 finish in ~2 s.

So a slow resolver doesn't just add 2 s to each request — it turns into a queue, and every outgoing call to a new hostname waits its turn. Under load the queue never drains, and your service looks like it's hanging on "nothing".

Neither Go nor Node caches DNS

Go's net package and Node's dns module both send a fresh question every time a new connection needs a name. (Reused Keep-alive connections skip the lookup entirely, because they're already connected.) On a server without an OS-level cache, every new connection is a round trip to the resolver.

That's often fine. When it isn't, there are three fixes, from least to most code: run a local caching resolver next to the app (systemd-resolved, dnsmasq, or NodeLocal DNSCache in Kubernetes); in Node, raise UV_THREADPOOL_SIZE — an environment variable read when the pool first starts, so set it at launch (UV_THREADPOOL_SIZE=16 node server.js), not from inside your code; or give Node's HTTP client a custom lookup that caches:

cached-lookup.mjs
import http from "node:http";
import { Resolver } from "node:dns/promises";

const resolver = new Resolver({ timeout: 2000, tries: 2 });
const cache = new Map(); // hostname → { ips, expires }

async function cachedResolve(hostname) {
  const hit = cache.get(hostname);
  if (hit && hit.expires > Date.now()) return hit.ips;           // ← fresh: no DNS query at all
  const recs = await resolver.resolve4(hostname, { ttl: true }); // ← network query, no thread pool
  const ttl = Math.min(...recs.map((r) => r.ttl), 60);           // honour the TTL, but cap it at 60 s
  const ips = recs.map((r) => r.address);
  cache.set(hostname, { ips, expires: Date.now() + ttl * 1000 });
  return ips;
}

// The callback shape Node's http/net modules expect from a custom `lookup`.
function lookup(hostname, options, callback) {
  cachedResolve(hostname).then(
    (ips) => options.all
      ? callback(null, ips.map((address) => ({ address, family: 4 })))
      : callback(null, ips[0], 4),
    callback, // ← on failure, pass the error straight through
  );
}

const agent = new http.Agent({ keepAlive: true, lookup }); // ← every request through this agent uses it
http.get("http://api.example.com/health", { agent }, (res) => res.resume());
Practitioner§08

“It's always DNS”: how it actually fails

In one sentenceDNS rarely fails loudly. It fails as slowness, as some users but not others, or as the old server still getting traffic — symptoms that look like anything except DNS.

Here are the failure modes behind most "it was DNS" postmortems, roughly in the order you'll meet them.

1. The failover that half the clients ignored

The primary database dies. Your automation repoints db.internal.example.com to the replica in seconds. Yet for the next hour, a third of your app servers keep trying the dead primary.

The record was right — the caches weren't. The TTL was 3600, so every cache that fetched the name in the last hour keeps the old answer for up to an hour (§05). Some app servers also held open connections to the old IP, and an open connection never re-resolves (§11). A DNS-based failover is only as fast as the longest TTL and the oldest connection.

2. The name that "didn't exist" for an hour

You checked a name before creating it, so the resolver cached NXDOMAIN — for as long as the zone's SOA allows (§05). The fix is to wait, or to flush that resolver's cache. Prevention: create records first, test second.

3. Search domains and ndots:5: five lookups for one name

Short names like wiki work at many companies because the resolver adds a suffix for you — those suffixes are the search domains. An option called ndots decides when to try them: if the name has fewer dots than ndots, the resolver tries every search domain before the name as written.

Kubernetes sets this up for every Pod, so that a short service name like payments finds payments.default.svc.cluster.local:

# /etc/resolv.conf inside a Kubernetes pod (namespace "default")
nameserver 10.96.0.10                                   # the cluster's DNS server
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5                                         # ← fewer than 5 dots? try search domains first

api.stripe.com has only 2 dots — fewer than 5 — so the resolver first tries api.stripe.com.default.svc.cluster.local, then two more made-up names, each getting NXDOMAIN, before it finally asks for the real one. And each attempt is two queries, A and AAAA. Try it:

Search domains & ndots
A pod in namespace “default” looks up a name. Each line is one name the resolver tries, in order, until one exists.
/etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
api.stripe.com — an external API. It has 2 dots; that's fewer than ndots (5), so the search domains go first.
Names tried8 queries
  1. 1. api.stripe.com.default.svc.cluster.localNXDOMAIN ×2
  2. 2. api.stripe.com.svc.cluster.localNXDOMAIN ×2
  3. 3. api.stripe.com.cluster.localNXDOMAIN ×2
  4. 4. api.stripe.com✓ found
6 wasted queries before the real answer. Multiply by every new connection your service opens.

The fixes, from simplest:

  • Write the name with a trailing dot — api.stripe.com. — to mark it as a complete name, an FQDN. The resolver then skips the search list. It's ideal for things that only do DNS, like database or cache hosts. Careful with HTTP: the dot can end up in the request's Host header, and many servers and CDNs then answer 404 or 421, or present the wrong certificate. For external HTTP APIs, the next two fixes are usually safer.
  • Lower ndots for pods that mostly call external names, in the pod spec:
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"     # names with 2+ dots (api.stripe.com) are tried as written first
  • Run a cache on every node (NodeLocal DNSCache), so the wasted lookups are answered locally in microseconds instead of crossing the network to the cluster DNS server.

4. Requests that are slow by exactly five seconds

When p99 Latency shows a cluster of requests taking 5 s longer than normal, suspect a lost DNS packet and the resolver's 5-second retry timer (§06). A notorious example on Linux and Kubernetes: getaddrinfo sends the A and AAAA queries at the same instant from the same socket. On some kernels, the connection-tracking table used by NAT can race on those two packets and drop one — and the client waits 5 s for a reply that will never come.

# glibc option: send A and AAAA one after another, from separate sockets
options single-request-reopen

The broader fix is the same as above: a node-local DNS cache, which keeps the hop short and avoids that NAT path. And always set timeout/attempts deliberately rather than living with the 5 s default.

5. The cache that never expires

Some software looks a name up once and keeps the answer until it restarts:

  • Older Java setups cached successful lookups forever. Modern JVMs default to 30 s, but security-manager configs and custom settings still bring back "forever". Check networkaddress.cache.ttl.
  • nginx resolves hostnames in proxy_pass at startup and never again, unless you configure a resolver and use a variable for the upstream. When the upstream's IP changes, nginx keeps sending traffic to the old one.
  • Your own code: a Map of hostname → IP with no expiry is the same bug.

6. Different answers for different people

Split-horizon DNS means one name gives different answers depending on who asks: git.example.com is a private 10.x address inside the office and a public IP (or nothing) outside. It's useful — and it makes "works for me" bugs. The developer on the VPN gets one answer; the CI runner outside gets another; a browser using encrypted DNS (§06) skips the company resolver and gets the public one. When answers differ, always ask: which resolver did this machine use?

7. The resolver itself falls over

Every service in a cluster leans on a handful of DNS servers. Combine ndots:5 (8 queries per external name), no caching in Go/Node (a lookup per new connection), and a burst of new connections after a deploy, and the cluster DNS can be the first thing to saturate. Cloud resolvers also have per-machine packet limits (on AWS, roughly 1,024 DNS packets per second per network interface — check current docs). Past the limit, queries are dropped… and you're back to 5-second timeouts.

Practitioner§09

The debugging toolkit

In one sentenceDebugging DNS means asking the same question at different layers — the way your app asks, your resolver, the authoritative server — and finding the layer where the answer changes.

Three questions, then one more

When a name "doesn't work", answer these in order. Each one uses a different tool, and each rules out one layer.

1

What does my app's path say?

getent ahosts api.example.com asks the way most programs do — through getaddrinfo, so /etc/hosts and the search domains apply. If this is wrong but dig is right, the problem is local.
2

What does my resolver say?

dig api.example.com asks the resolver in /etc/resolv.conf directly (no /etc/hosts). Note the status, the answer and the TTL.
3

What does the source of truth say?

dig @ns1.your-dns.net api.example.com asks the authoritative server. If it's right here but wrong in step 2, you're looking at a stale cache.

And the fourth: is the delegation right? dig +trace walks the tree from the root yourself, so you see whether .com actually points at the servers you think it does.

Here's the checklist as a picture. Each tool asks a different layer; the culprit is the layer where the answers start to differ:

Which layer is lying?
You changed api.example.com to 192.0.2.99, but users still reach the wrong server. Pick a cause, and see what each tool prints.
The layers a lookup passes through (left = closest to your app)
1
/etc/hosts
local overrides
✕ culprit
2
OS stub / cache
getaddrinfo
3
Recursive resolver
shared cache
4
.com delegation
set at your registrar
5
Authoritative server
your DNS provider
What each tool prints
$ getent ahosts api.example.comasks: everything your app sees✕ 10.0.0.42
$ dig api.example.comasks: your resolver (skips /etc/hosts)✓ 192.0.2.99
$ dig +trace api.example.comasks: the delegation, from the root✓ 192.0.2.99
$ dig @ns1.new-dns.net api.example.comasks: the new provider's server directly✓ 192.0.2.99
Only getent is wrong — it's the only tool that reads /etc/hosts. Answers diverge between your app's path and dig, so the culprit is local to this machine: a line someone added to /etc/hosts for testing.

dig, the tool you'll use most

dig api.example.com                 # A record, full output
dig +short api.example.com AAAA     # just the IPv6 addresses
dig +noall +answer example.com MX   # just the answer section
dig @8.8.8.8 api.example.com        # ask a specific resolver
dig @ns1.example-dns.net api.example.com   # ask the authoritative server (no caches)
dig -x 192.0.2.10                   # reverse lookup (PTR)
dig +tcp example.com                # force TCP (tests the firewall rule from §06)

Run dig @8.8.8.8 and dig @1.1.1.1 for the same name: if they disagree, one of them has an older cached copy. The TTL in each answer tells you how many seconds that copy has left.

dig +trace: do the resolver's job yourself

+trace ignores caches and walks the tree exactly like the walkthrough in §02 — root, then TLD, then authoritative — printing each referral. (Output trimmed.)

$ dig +trace api.example.com
.                    518400  IN  NS  a.root-servers.net.         ← ① the root servers
;; Received 239 bytes from 10.0.0.2#53(10.0.0.2) in 3 ms

com.                 172800  IN  NS  a.gtld-servers.net.         ← ② root's referral: ask .com
;; Received 1170 bytes from 198.41.0.4#53(a.root-servers.net) in 12 ms

example.com.         172800  IN  NS  ns1.example-dns.net.        ← ③ .com's referral: ask these
;; Received 268 bytes from 192.5.6.30#53(a.gtld-servers.net) in 21 ms

api.example.com.     300     IN  A   192.0.2.20                  ← ④ the authoritative answer
;; Received 60 bytes from 198.51.100.1#53(ns1.example-dns.net) in 34 ms

If step ③ lists servers you stopped using last year, your registrar still has the old name servers: moving your zone to a new provider isn't finished until the registrar's NS records point there.

The local files

$ cat /etc/hosts                     # checked BEFORE DNS by getaddrinfo
127.0.0.1   localhost
10.0.0.42   api.example.com          # ← a forgotten line like this overrides DNS for this machine

$ cat /etc/resolv.conf               # which resolver, which search domains, which timeouts
nameserver 127.0.0.53                # 127.0.0.53 = systemd-resolved, a local cache; ask it what it uses:
search corp.example.com
$ resolvectl status                  # Linux with systemd-resolved: the real upstream resolvers
$ scutil --dns                       # macOS equivalent

/etc/hosts is the oldest trick in the book — and the oldest trap. A line someone added for testing silently wins over DNS for every program that uses getaddrinfo, but not for dig, which only speaks DNS. "dig says one thing and my app does another" is very often /etc/hosts.

Flushing caches (when you must)

WhereHow
Chromechrome://net-internals/#dns → "Clear host cache"
macOSsudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
Linux (systemd-resolved)resolvectl flush-caches
Windowsipconfig /flushdns
Public resolversGoogle and Cloudflare each have a "purge cache" web page for a single name

Flushing only fixes your machine. Your users' resolvers keep their copies until the TTL runs out — which is why the real fix is the migration playbook in §05, not a flush.

Staff-level§10

Security: poisoning, DNSSEC, rebinding and takeovers

In one sentenceDNS was designed for a friendly network: plain DNS has no proof that an answer is genuine. Most DNS attacks either forge an answer, or abuse the fact that applications trust whatever the name resolves to.

Cache poisoning: forging the answer

A resolver sends a question over UDP and accepts the first reply that looks right: correct question, correct 16-bit Transaction ID, arriving at the right Port. Nothing proves the reply came from the real server. If an attacker can get a forged reply in before the real one, the resolver caches the fake answer and serves it to all its users until the TTL runs out. That's Cache poisoning.

For years this looked hard: the attacker had to guess the ID (1 in 65,536) and could only try when the name wasn't already cached — once per TTL. In 2008, Dan Kaminsky showed how to get unlimited tries:

The Kaminsky attack, simplified
attacker
ask: rnd1.example.com
flood forged replies, guessing IDs
ask: rnd2…
flood again
resolver
waiting for the real reply
waiting…
💥 accepts a fake
real server
NXDOMAIN
late
Each random name is a fresh race the attacker can try immediately. The forged reply's real payload is the extra ‘authority’ data: “the servers for example.com are at 203.0.113.66” — which, if accepted, hands the attacker the whole domain.

Because rnd1, rnd2, rnd3… are never cached, every one forces a new query and a new race. With enough races, a guessed ID eventually lands.

The industry-wide fix, shipped within weeks, was source port randomization: the resolver sends each query from a random UDP port, so the attacker must guess the port and the ID — roughly 65,536 × ~60,000 combinations instead of 65,536. Some resolvers add more randomness by MiXiNg the letter case of the name in the query and checking the reply copies it exactly. These make blind forgery impractical, not impossible.

DNSSEC: signatures, not secrecy

DNSSEC fixes the root cause: the zone owner signs each set of records with a private key, and the signature (an RRSIG record) travels with the answer. A validating resolver checks it with the zone's public key (a DNSKEY record), and checks that key against a fingerprint published by the parent zone (a DS record in .com), all the way up to the root's key, which resolvers ship with. A forged answer can't carry a valid signature, so it's rejected.

What DNSSEC does not do: it doesn't encrypt anything (that's DoH/DoT, §06), and it only helps if the resolver validates. Adoption is uneven — many large domains don't sign — and a mistake in signing (an expired signature, a key rollover gone wrong) makes the whole domain fail to resolve for validating resolvers, with a SERVFAIL status. Teams that enable it automate key rotation and monitor signature expiry.

DNS rebinding: turning your browser against localhost

Browsers protect you with the Same-origin policy: a script from evil.example can't read responses from other sites. But the check compares names, not IP addresses. DNS rebinding exploits that gap:

  1. You visit evil.example. Its DNS answer is the attacker's server, with a TTL of 1 second. The page loads a script.
  2. A few seconds later, the script requests evil.example/api again. The cached answer has expired, so the browser looks the name up again — and this time the attacker's DNS answers 127.0.0.1.
  3. To the browser, it's still the same origin, evil.example, so the script can read the response. But the request went to your own machine: a dev server, a database admin page, a router config page, a local AI tool — services that "only listen on localhost, so they're safe".

Defences, for anyone running a local or internal HTTP service:

  • Check the Host header. A request that arrived via rebinding still says Host: evil.example. Reject anything that isn't the name you expect (localhost, your domain). Many dev servers now do this by default.
  • Require authentication even on localhost and internal networks.
  • Resolvers and routers with rebinding protection refuse public names that resolve to private addresses; browsers are also adding restrictions on public pages calling private addresses (check current browser docs).

Subdomain takeover: the CNAME you forgot

Your marketing team used promo.example.com → CNAME → example-promo.some-hosting.app. The campaign ended, the hosting account was deleted — but the CNAME stayed. Now anyone can create an account on that hosting service, claim example-promo, and serve their content at promo.example.com, under your domain, with a valid certificate and your cookies' domain.

Staff-level§11

DNS in production: discovery, balancing, failover

In one sentenceDNS is the cheapest service discovery and load balancing you can get — and its limits all come from the same place: answers are cached, and connections outlive answers.

DNS as service discovery

In a system of microservices, instances start, crash and move all day. Service discovery is how a caller finds today's addresses. DNS is the simplest version: every service gets a stable name, and the name's records track the live instances.

Kubernetes gives you two flavours:

Normal Service (ClusterIP)Headless Service (clusterIP: None)
dig payments.default.svc.cluster.local returnsone stable virtual IPthe IP of every ready pod
Who picks a podthe node's networking rules, per connectionyour client, from the list
When pods changeDNS answer never changesDNS answer changes; clients must re-resolve
Good formost HTTP servicesdatabases with replicas, gRPC client-side balancing, anything that needs to reach a specific pod

Kubernetes also publishes SRV records for named ports, so a client can learn the port as well as the address: _grpc._tcp.payments.default.svc.cluster.local. Outside Kubernetes, Consul offers the same idea: every registered service is reachable as name.service.consul (on port 8600 by default), and Consul only returns instances whose Health check is passing.

DNS as a load balancer — and why it's a weak one

Give a name several A records and clients spread across them ("round-robin DNS"). It's free and needs no extra hop. But compared to a real Load balancer (Load Balancing covers those):

  • The client chooses, and you can't control how. Many clients just take the first address; others sort them by their own rules. The spread is rarely even.
  • Caches multiply the skew. A resolver serving 50,000 users hands all of them the same cached answer in the same order for a whole TTL.
  • It can't see load or health. Plain DNS keeps returning a dead server's IP until someone removes the record — and then every cache still has it until the TTL expires.
  • It balances connections, not requests. A client that opens one Keep-alive connection sends everything to one IP, possibly for hours.

So DNS usually balances between places — data centres, regions, load balancers — and a real load balancer balances within each place.

Sending users somewhere nearby: GeoDNS and anycast

1

GeoDNS

The authoritative server answers differently depending on where the question comes from — a GeoDNS setup gives European resolvers the Frankfurt IP and American ones the Virginia IP. Caveat: it sees the resolver's location, not the user's. A user in Tokyo using a resolver in California gets the American answer.
2

Anycast

Many sites announce the same IP, and internet routing delivers each packet to the nearest one. That's Anycast. No DNS tricks needed — 1.1.1.1, 8.8.8.8 and the root servers all work this way, and so do most CDNs.
3

Latency-based

A variant of GeoDNS: the provider measures latency from networks to each region and answers with the fastest region, not the closest on the map.

Health-checked failover vs TTL reality

DNS providers such as Route 53 can run health checks and swap records automatically: "if the primary fails 3 checks, answer with the secondary". The DNS side of that is quick. The client side isn't:

Primary fails at 0 s — when does traffic actually move? (TTL 60 s, approximate)
detect
3 failed checks ~30 s
DNS flips
caches
old answer, up to 60 s more
TTL-ignorers
minutes… or until restart
open conns
never re-resolve: stuck until the connection breaks
Health checks take time to decide, caches hold the old answer for up to one TTL, some caches ignore your TTL, and already-open connections never move at all.

Plan for "most traffic moves within a couple of minutes, the long tail takes much longer". For anything that needs faster failover, put a stable address in front — a load balancer or an anycast IP that never changes — and do the failover behind it.

Connections outlive DNS answers

This is the single most surprising production fact about DNS: a DNS lookup only happens when a new connection is opened. Connection pools, HTTP keep-alive, database pools and gRPC channels deliberately keep connections open for minutes or hours, so a DNS change doesn't reach them at all.

Connections outlive DNS answers
A client sends requests round-robin over a pool of 3 keep-alive connections. Change the DNS record, then watch where the traffic goes.
DNS record
api.example.com → OLD
192.0.2.10
Only read when a new connection opens.
Client's connection pool
conn #1→ OLD 192.0.2.10
conn #2→ OLD 192.0.2.10
conn #3→ OLD 192.0.2.10
Recent traffic
100% to OLD
total: OLD 0 · NEW 0
3 keep-alive connections, opened long ago, all to the OLD IP.

You can watch it happen. In Go, httptrace reports each lookup and whether a connection was reused:

trace.go
package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/http/httptest"
    "net/http/httptrace"
    "strings"
    "time"
)

func main() {
    // A local test server, so this runs anywhere. Use "localhost" so there's a name to resolve.
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")
    }))
    defer srv.Close()
    url := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1)

    var dnsStart time.Time
    trace := &httptrace.ClientTrace{
        DNSStart: func(info httptrace.DNSStartInfo) { dnsStart = time.Now() },
        DNSDone: func(info httptrace.DNSDoneInfo) {
            log.Printf("dns took %v, addrs=%v, err=%v", time.Since(dnsStart), info.Addrs, info.Err)
        },
        GotConn: func(info httptrace.GotConnInfo) {
            log.Printf("conn to %s, reused=%v", info.Conn.RemoteAddr(), info.Reused) // ← reused=true: no DNS at all
        },
    }

    for range 2 { // send the same request twice
        req, err := http.NewRequest("GET", url, nil)
        if err != nil {
            log.Fatal(err)
        }
        req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        io.Copy(io.Discard, resp.Body) // ← read to the end AND close,
        resp.Body.Close()              //   or the connection can't be reused
    }
}

Output (timestamps trimmed; times will differ):

dns took 89µs, addrs=[{127.0.0.1 }], err=<nil>
conn to 127.0.0.1:35483, reused=false
conn to 127.0.0.1:35483, reused=true        ← second request: no DNS lookup at all

In Node, the socket emits a lookup event — and on a reused keep-alive socket, it doesn't fire. This prints only one line, for request 1:

trace.mjs
import http from "node:http";

// A local test server, so this runs anywhere.
const server = http.createServer((req, res) => res.end("ok")).listen(0, async () => {
  const url = `http://localhost:${server.address().port}/`;
  const agent = new http.Agent({ keepAlive: true }); // ← keep connections open for reuse

  for (let i = 0; i < 2; i++) {
    await new Promise((done) => {
      const req = http.get(url, { agent }, (res) => {
        res.resume();          // read the body to the end…
        res.on("end", done);   // …so the socket goes back to the agent for reuse
      });
      req.on("socket", (socket) => {
        socket.on("lookup", (err, address, family, host) => {
          console.log(`request ${i + 1}: dns ${host} → ${address}`, err ?? ""); // only NEW connections
        });
      });
    });
  }
  agent.destroy();
  server.close();
});

Ways to make clients notice DNS changes:

  • Give connections a maximum age. Database pools usually have a "max lifetime" setting; for HTTP servers, closing keep-alive connections after N minutes or N requests forces clients to reconnect (and re-resolve).
  • gRPC: use a client that re-resolves and balances across all addresses (Go's gRPC dns:/// resolver with round-robin), and set a server-side maximum connection age so clients reconnect periodically. (gRPC has the details.)
  • In Go, http.Transport has no max-lifetime setting; calling transport.CloseIdleConnections() on a timer is a common workaround.

What to monitor

Treat DNS like any other dependency you call over the network: give it a Timeout, measure it, alert on it.

⏱️
Lookup latencyRecord DNS time separately (the hooks above). A jump from ~1 ms to seconds is the clearest DNS signal there is.
❌
Error mixCount NXDOMAIN, SERVFAIL and timeouts separately. NXDOMAIN spikes mean a config typo or a search-domain storm; timeouts mean an overloaded or unreachable resolver.
🖥️
Resolver healthQuery rate, cache hit rate and latency of your cluster DNS (CoreDNS exports all three). It should scale with your fleet, not be an afterthought.
📅
The boring stuffDomain expiry dates, DNSSEC signature expiry, certificate CAA records, and who has access to the registrar account. These cause the outages that take whole companies offline.
Practitioner§12

Pitfalls gallery

#1

Hardcoding an IP to “avoid DNS”

It works until the provider moves the service — and cloud and SaaS endpoints move all the time, often without notice. You've also given up load spreading and failover. If DNS is slow, fix the DNS path (a local cache, §07), don't bypass it.

✕ Don't
// "DNS was flaky, so…"
const API = "http://203.0.113.7/v1";
✓ Do
const API = "https://api.payments.example/v1";
// …and run a local caching resolver if lookups are slow
#2

No deadline on name resolution

A lookup is a network call and can hang for seconds (§06). In Go, net.LookupHost has no deadline of its own; use a Resolver method with a context, or a Dialer with a timeout, which covers the lookup too. In Node, dns.lookup has no timeout option at all — only the OS settings in /etc/resolv.conf apply — so put an overall timeout on the request.

✕ Don't
addrs, err := net.LookupHost(host) // waits as long as the OS lets it
✓ Do
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
#3

Your own cache with no expiry

A Map from hostname to IP, filled once, is the "JVM caches forever" bug (§08) reinvented. Every cache needs an expiry — the record's TTL, capped at something sensible — or the next failover will skip your service.

✕ Don't
const ipCache = new Map();
async function ipFor(host) {
  if (!ipCache.has(host)) ipCache.set(host, (await lookup(host)).address);
  return ipCache.get(host); // ← valid forever
}
✓ Do
// store { ips, expires } and check expires > Date.now()
// (the cached lookup in §07)
#4

Treating “doesn't exist” and “didn't answer” the same

NXDOMAIN is a definite answer: the name doesn't exist, and retrying immediately won't change that. A timeout or SERVFAIL says nothing about the name — the resolver just failed. Code that validates email domains, webhook URLs or customer-configured hosts must tell these apart, or a resolver blip will mark thousands of good addresses as invalid.

var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
    switch {
    case dnsErr.IsNotFound: // NXDOMAIN (or no records): reject the input
    case dnsErr.IsTimeout:  // resolver didn't answer: retry later, don't reject
    }
}

In Node the same split is err.code === "ENOTFOUND" vs "EAI_AGAIN" (from dns.lookup) or "ETIMEOUT" (from dns.resolve*).

#5

The missing trailing dot in a zone file

In zone files and many DNS tools, a name without a trailing dot is relative to the zone. So inside the example.com zone, a CNAME to cdn.example.net actually means cdn.example.net.example.com. — a name that doesn't exist.

✕ Don't
www  IN  CNAME  cdn.example.net     ; → cdn.example.net.example.com.
✓ Do
www  IN  CNAME  cdn.example.net.    ; the dot means "complete name"
#6

A CNAME next to other records

Adding a CNAME at the apex, or at a name that already has MX or TXT records, breaks the "a CNAME stands alone" rule (§04). Some providers refuse; others accept it and resolvers then behave unpredictably — mail is the usual casualty. Use your provider's ALIAS/flattening feature or move the service to a subdomain.

#7

Assuming one IP, or only IPv4

A name can return several addresses, IPv4 and IPv6, in any order. Code that takes addrs[0] and connects to it alone has no fallback when that one server is down, and code that only reads A records breaks on IPv6-only networks. Let the standard dialers do it: Go's net.Dial and Node's net.connect try the addresses in turn, and race IPv6 against IPv4 (Node ≥ 20 does this by default).

#8

Testing with dig, deploying with getaddrinfo

dig speaks only DNS: it ignores /etc/hosts, the search-domain order your app uses, and the differences between glibc and musl (Alpine). "It resolves in dig" doesn't prove your app can resolve it. Test the way your program resolves: getent ahosts name, or a one-liner in your app's language from inside the same container (§09).

#9

Expecting a DNS change to move existing traffic

You changed the record and lowered the TTL, but the old server still takes traffic: clients with open keep-alive connections, connection pools and gRPC channels never look the name up again (§11). Keep the old target running, give connections a maximum age, and watch traffic drain before you turn anything off.

Practitioner§13

Check yourself

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 6
A record has TTL 3600. You change its IP at 12:00. A resolver cached the old answer at 11:50. Until when can its users get the old IP?
QUESTION 2 / 6
Predict the timing (Node 22, default thread pool, the resolver takes 2 s to answer each lookup)
import dns from "node:dns";
const t0 = Date.now();
for (let i = 0; i < 8; i++) {
  dns.lookup(`host${i}.example.com`, () => {
    console.log(i, Date.now() - t0); // when does the LAST one print?
  });
}
QUESTION 3 / 6
In a Kubernetes pod (ndots:5, default search list), your Go service calls api.stripe.com. How many DNS queries before it gets the real answer (counting A and AAAA)?
QUESTION 4 / 6
Your DNS dashboard refuses to save example.com CNAME myapp.herokudns.com. Why?
QUESTION 5 / 6
You ran dig new.example.com before creating the record (NXDOMAIN), then created it a minute later. The SOA's negative-cache time is 3600 s. What do users of that same resolver see?
QUESTION 6 / 6
You moved api.example.com to a new IP with TTL 60. Ten minutes later, your Go service still sends traffic to the old server. Most likely cause?
Answered 0 / 6
Foundations§14

Cheat sheet

📒
DNS = distributed, cached phone bookNames → records. The tree: root → TLD → the domain's authoritative servers. Each level only knows who to ask next.
🔁
One question, many hopsYour OS asks a recursive resolver once; the resolver walks the tree and caches every step. Cold ≈ 50–200 ms, cached ≈ 0–30 ms.
⏳
Changes wait for the old TTLNothing is pushed. Lower the TTL a full old-TTL before a migration, keep the old server up afterwards.
🚫
NXDOMAIN is cached tooFor the SOA's negative-cache time. Create records before anything looks them up.
🔗
CNAME stands aloneNot at the apex, not next to MX/TXT. Use ALIAS/flattening or www + redirect.
📦
UDP 53, then TCPLost packet = a 5 s retry by default. Big answers truncate and retry over TCP — allow both.
🧵
Node: lookup ≠ resolvedns.lookup = getaddrinfo on the thread pool (≈2 at once by default), used by http/fetch. dns.resolve* = c-ares, async, no /etc/hosts.
🐹
Go: pure-Go or cgoGODEBUG=netdns=go|cgo(+1). Use net.Resolver with a context for deadlines and custom servers.
☸️
Kubernetes ndots:5External names try 3 search domains first: 8 queries instead of 2. Trailing dot, lower ndots, or a node-local cache.
🔌
Connections outlive answersLookups only happen on new connections. Give pools a max age if you rely on DNS changes.
I want to…Command
See the answer and TTLdig +noall +answer name
Just the valuesdig +short name [A|AAAA|MX|TXT…]
Bypass cachesdig @ns1.your-dns-provider name
Walk the tree myselfdig +trace name
Resolve the way my app doesgetent ahosts name
Reverse lookupdig -x 192.0.2.10
Test DNS over TCPdig +tcp name
See which resolver I usecat /etc/resolv.conf, resolvectl status, scutil --dns
In codeGoNode
Resolve like the OSnet.LookupHost (pure-Go by default on Linux)dns.lookup (getaddrinfo, thread pool)
Talk DNS directly&net.Resolver{PreferGo: true}new dns.promises.Resolver() (c-ares)
Deadliner.LookupHost(ctx, …) with context.WithTimeoutnew Resolver({ timeout, tries })
Custom DNS serverResolver.Dialresolver.setServers([...])
Not found vs failurednsErr.IsNotFound / IsTimeoutENOTFOUND vs EAI_AGAIN / ETIMEOUT
Measure lookupshttptrace DNSStart / DNSDonesocket "lookup" event
Built-in cachenonenone
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkingHTTP/1.1 → 2 → 3
Read →