Frontend Labs
⋯

Chapter 18 — DNS: turning names into addresses

In one sentence

DNS is a giant, distributed, cached phone book that turns example.com into 93.184.216.34 — and it is involved in more outages than almost anything else.

The problem

You remember example.com. The network needs 93.184.216.34. Something must translate — for hundreds of millions of names, changing constantly, for billions of queries per second, with no single machine holding the list.

The idea, in plain language

Delegate. Nobody knows everything; everybody knows who to ask next.

   You:  "Who is www.example.com?"

   ROOT SERVER:        "No idea. But I know who runs .com -- ask them."
   .COM SERVER:        "No idea. But I know who runs example.com -- ask them."
   EXAMPLE.COM SERVER: "That's me. www.example.com is 93.184.216.34."

Three questions, each to a more specific authority. Exactly like finding a person by asking the country, then the city, then the street.

The name hierarchy

Read a domain name right to left:

       www . example . com .
        |      |        |   |
        |      |        |   +-- the ROOT (usually invisible; the trailing dot)
        |      |        +------ TOP-LEVEL DOMAIN (TLD): com, org, uk, io
        |      +--------------- the DOMAIN somebody registered
        +---------------------- a HOST or subdomain within it

The four players

Role What it does Example
Stub resolver The tiny client inside your OS. Asks one server, caches a little. Your laptop
Recursive resolver Does all the legwork (root → TLD → authoritative), caches everything Your router, your ISP, 1.1.1.1, 8.8.8.8
Root servers Know who runs each TLD. 13 logical addresses, hundreds of physical machines. a.root-servers.net
Authoritative server Holds the real answers for a specific domain ns1.example.com

Your device almost never talks to root servers. It asks one recursive resolver, which does everything else.

Caching is what makes it work

Every answer comes with a TTL (time to live) — how long you may remember it.

   First lookup of example.com:     3 or 4 round trips, maybe 100+ ms
   Next lookup within the TTL:      0 round trips -- served from cache

Caching happens at every level: your browser, your OS, your router, your ISP's resolver. This is why the internet doesn't collapse.

The operational consequence you must know: if you change a DNS record, the old answer stays cached everywhere until its TTL expires. So before a migration, lower the TTL (to e.g. 60 seconds) and wait for the old TTL to pass. Then make the change. Then raise it again.

The record types you'll actually meet

Type Meaning Example
A name → IPv4 address example.com. A 93.184.216.34
AAAA name → IPv6 address example.com. AAAA 2606:2800:...
CNAME this name is an alias for that name www.example.com. CNAME example.com.
MX mail servers for this domain example.com. MX 10 mail.example.com.
NS which servers are authoritative example.com. NS ns1.example.com.
TXT arbitrary text — used for SPF, DKIM, domain verification
PTR address → name (reverse lookup)
SOA admin info for the zone, including the negative-cache TTL

Practice (35 min) — this is the most useful tool chapter in Part 4

A. Basic lookups:

dig example.com                     # full output -- read the ANSWER section
dig example.com +short              # just the answer
dig example.com A +short
dig example.com AAAA +short
dig example.com MX +short
dig example.com NS +short
dig example.com TXT +short

B. Watch the whole delegation chain. This is the single best DNS learning command:

dig +trace example.com

You will literally see it start at the root servers, get referred to .com, then to example.com's own servers, then get the answer. Run this now. It makes the whole chapter concrete.

C. See caching in action:

dig example.com | grep -E "Query time|ANSWER SECTION" -A2
# run it again immediately
dig example.com | grep "Query time"

The second query time should be dramatically lower — and watch the TTL count down between runs.

D. Ask a specific server (bypasses your local cache):

dig @1.1.1.1 example.com +short          # Cloudflare
dig @8.8.8.8 example.com +short          # Google
dig @ns1.example.com example.com +short  # straight to the source (authoritative)

If different servers give different answers, you've found a propagation or split-horizon issue.

E. Reverse lookup:

dig -x 93.184.216.34 +short

F. Watch DNS on the wire:

sudo tcpdump -i any -n 'udp port 53'
# in another terminal:
dig example.com

You'll see the query go out and the response come back — usually one packet each.

G. See your own resolver settings:

cat /etc/resolv.conf              # Linux
scutil --dns | head -20           # macOS
resolvectl status                 # Linux with systemd-resolved

DNS over UDP, TCP, and encrypted

  • Normally UDP port 53 — one small packet each way, fast.
  • If the answer is too big, the server sets a "truncated" flag and the client retries over TCP port 53. You must allow TCP/53 through firewalls; not doing so causes bizarre intermittent failures.
  • DoT (DNS over TLS, port 853) and DoH (DNS over HTTPS, port 443) encrypt your queries. DoH is hard to distinguish from normal web traffic — good for privacy, awkward for network administrators who rely on internal DNS.

The classic DNS problems

Symptom Likely cause
"It works on my machine but not theirs" DNS cached differently; different resolver
Site works by IP but not by name DNS failure — test with dig
Change made but not visible TTL hasn't expired; check with dig against multiple resolvers
Intermittent slow page loads One of the configured DNS servers is dead; each lookup waits ~5 s before trying the next
Works externally, fails internally (or vice versa) Split-horizon DNS, or a VPN not pushing internal DNS
"DNS_PROBE_FINISHED_NXDOMAIN" The name genuinely doesn't exist (or a typo)

A useful habit: when anything network-related breaks, test DNS first with dig, and test raw connectivity separately with an IP address. That immediately splits the problem in half.

Build it in Go (60 min) — a DNS client from raw bytes

dig is a wrapper around a surprisingly small protocol. This program builds a DNS query byte by byte, sends it in one UDP datagram, and decodes the reply, including the "message compression" pointers that confuse everyone the first time. It uses no DNS library: only encoding/binary and a UDP socket.

The wire format you're implementing (RFC 1035):

 HEADER (12 bytes)   ID | flags (QR, RD, RA, TC, rcode) | QDCOUNT | ANCOUNT | NSCOUNT | ARCOUNT
 QUESTION            name as labels: 7 e x a m p l e 3 c o m 0 | QTYPE (A=1) | QCLASS (IN=1)
 ANSWER records      name (often a 2-byte POINTER: 0xC0 0x0C) | TYPE | CLASS | TTL | RDLENGTH | RDATA

Save as dnsquery/main.go:

// dnsquery: build a DNS query by hand, send it over UDP, and decode the answer.
// No DNS library -- just the bytes RFC 1035 describes.
//
//	go run ./dnsquery example.com
//	go run ./dnsquery -type AAAA -server 8.8.8.8 example.com
//	go run ./dnsquery -type CNAME www.github.com
package main

import (
	"encoding/binary"
	"errors"
	"flag"
	"fmt"
	"math/rand/v2"
	"net"
	"os"
	"strings"
	"time"
)

var qtypes = map[string]uint16{"A": 1, "NS": 2, "CNAME": 5, "MX": 15, "TXT": 16, "AAAA": 28}

func main() {
	server := flag.String("server", "1.1.1.1", "recursive resolver to ask")
	qtype := flag.String("type", "A", "record type: A, AAAA, CNAME, NS, MX, TXT")
	flag.Parse()
	if flag.NArg() != 1 {
		fmt.Println("usage: dnsquery [-server IP] [-type T] name")
		os.Exit(2)
	}
	t, ok := qtypes[strings.ToUpper(*qtype)]
	if !ok {
		fmt.Println("unsupported type", *qtype)
		os.Exit(2)
	}

	id := uint16(rand.N(1 << 16))
	query := buildQuery(id, flag.Arg(0), t)
	fmt.Printf("query: %d bytes  % x ...\n", len(query), query[:12])

	conn, err := net.Dial("udp", net.JoinHostPort(*server, "53"))
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	defer conn.Close()
	conn.SetDeadline(time.Now().Add(3 * time.Second)) // UDP has no retries: we own the timeout

	start := time.Now()
	if _, err := conn.Write(query); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	buf := make([]byte, 1232) // the EDNS-era "safe" UDP DNS size
	n, err := conn.Read(buf)
	if err != nil {
		fmt.Println("no answer:", err)
		os.Exit(1)
	}
	fmt.Printf("reply: %d bytes from %s in %v\n\n", n, *server, time.Since(start).Round(time.Microsecond))
	if err := printReply(buf[:n], id); err != nil {
		fmt.Println("bad reply:", err)
		os.Exit(1)
	}
}

// buildQuery writes the 12-byte header plus one question.
func buildQuery(id uint16, name string, qtype uint16) []byte {
	b := make([]byte, 12, 512)
	binary.BigEndian.PutUint16(b[0:], id)
	binary.BigEndian.PutUint16(b[2:], 0x0100) // flags: RD=1 ("recursion desired")
	binary.BigEndian.PutUint16(b[4:], 1)      // QDCOUNT = 1 question
	// a name is a sequence of length-prefixed labels: 7example3com0
	for _, label := range strings.Split(strings.TrimSuffix(name, "."), ".") {
		b = append(b, byte(len(label)))
		b = append(b, label...)
	}
	b = append(b, 0)
	b = binary.BigEndian.AppendUint16(b, qtype)
	b = binary.BigEndian.AppendUint16(b, 1) // class IN (internet)
	return b
}

func printReply(msg []byte, wantID uint16) error {
	if len(msg) < 12 {
		return errors.New("short header")
	}
	if id := binary.BigEndian.Uint16(msg[0:]); id != wantID {
		return fmt.Errorf("ID mismatch: sent %d got %d (spoofed or stale reply?)", wantID, id)
	}
	flags := binary.BigEndian.Uint16(msg[2:])
	rcode := flags & 0xF
	qd, an := binary.BigEndian.Uint16(msg[4:]), binary.BigEndian.Uint16(msg[6:])
	fmt.Printf("flags: QR=%d AA=%d TC=%d RD=%d RA=%d  rcode=%s  answers=%d\n",
		flags>>15&1, flags>>10&1, flags>>9&1, flags>>8&1, flags>>7&1, rcodeName(rcode), an)
	if flags>>9&1 == 1 {
		fmt.Println("TC=1: answer truncated -- a real resolver now retries over TCP")
	}

	off := 12
	for i := 0; i < int(qd); i++ { // skip the echoed question
		_, next, err := readName(msg, off)
		if err != nil {
			return err
		}
		off = next + 4
	}
	for i := 0; i < int(an); i++ {
		name, next, err := readName(msg, off)
		if err != nil {
			return err
		}
		if next+10 > len(msg) {
			return errors.New("truncated record header")
		}
		typ := binary.BigEndian.Uint16(msg[next:])
		ttl := binary.BigEndian.Uint32(msg[next+4:])
		rdlen := int(binary.BigEndian.Uint16(msg[next+8:]))
		rdata := next + 10
		if rdata+rdlen > len(msg) {
			return errors.New("truncated rdata")
		}
		fmt.Printf("%-30s TTL=%-6d %-5s %s\n", name, ttl, typeName(typ), rdataString(msg, typ, rdata, rdlen))
		off = rdata + rdlen
	}
	return nil
}

// readName decodes a possibly-compressed name. A byte >= 0xC0 starts a
// 2-byte pointer to an earlier offset: that is DNS "message compression".
func readName(msg []byte, off int) (string, int, error) {
	var labels []string
	next := -1 // where parsing continues after the name (set at first pointer)
	for hops := 0; hops < 20; hops++ {
		if off >= len(msg) {
			return "", 0, errors.New("name runs past end")
		}
		l := int(msg[off])
		switch {
		case l == 0:
			if next < 0 {
				next = off + 1
			}
			return strings.Join(labels, ".") + ".", next, nil
		case l&0xC0 == 0xC0:
			if off+1 >= len(msg) {
				return "", 0, errors.New("bad pointer")
			}
			if next < 0 {
				next = off + 2
			}
			off = int(binary.BigEndian.Uint16(msg[off:]) & 0x3FFF)
		default:
			if off+1+l > len(msg) {
				return "", 0, errors.New("label runs past end")
			}
			labels = append(labels, string(msg[off+1:off+1+l]))
			off += 1 + l
		}
	}
	return "", 0, errors.New("compression loop")
}

func rdataString(msg []byte, typ uint16, off, n int) string {
	switch typ {
	case 1, 28: // A, AAAA
		return net.IP(msg[off : off+n]).String()
	case 2, 5: // NS, CNAME
		name, _, err := readName(msg, off)
		if err != nil {
			return "?"
		}
		return name
	case 15: // MX: preference + name
		name, _, _ := readName(msg, off+2)
		return fmt.Sprintf("%d %s", binary.BigEndian.Uint16(msg[off:]), name)
	case 16: // TXT: one or more length-prefixed strings
		var parts []string
		for i := off; i < off+n; {
			l := int(msg[i])
			if i+1+l > off+n {
				break
			}
			parts = append(parts, fmt.Sprintf("%q", msg[i+1:i+1+l]))
			i += 1 + l
		}
		return strings.Join(parts, " ")
	}
	return fmt.Sprintf("% x", msg[off:off+n])
}

func typeName(t uint16) string {
	for k, v := range qtypes {
		if v == t {
			return k
		}
	}
	return fmt.Sprint(t)
}

func rcodeName(r uint16) string {
	switch r {
	case 0:
		return "NOERROR"
	case 2:
		return "SERVFAIL"
	case 3:
		return "NXDOMAIN"
	case 5:
		return "REFUSED"
	}
	return fmt.Sprint(r)
}

Real output:

$ go run ./dnsquery example.com
query: 29 bytes  49 8d 01 00 00 01 00 00 00 00 00 00 ...
reply: 61 bytes from 1.1.1.1 in 17.618ms

flags: QR=1 AA=0 TC=0 RD=1 RA=1  rcode=NOERROR  answers=2
example.com.                   TTL=179    A     172.66.147.243
example.com.                   TTL=179    A     104.20.23.154

$ go run ./dnsquery www.github.com
www.github.com.                TTL=3040   CNAME github.com.
github.com.                    TTL=8      A     20.207.73.82

$ go run ./dnsquery nonexistent-zzzz.example
flags: QR=1 AA=0 TC=0 RD=1 RA=1  rcode=NXDOMAIN  answers=0

What to notice:

  • The whole query is 29 bytes. DNS is fast because it's one small datagram each way, with no handshake.
  • AA=0: the answer came from the resolver's cache, not from the authoritative server. Run it twice and watch the TTL count down. That's the cache ageing in real time.
  • The ID check matters for security. A reply with the wrong ID is rejected. Off-path DNS spoofing attacks (Kaminsky, 2008) work by guessing this 16-bit ID, which is why resolvers also randomise the source port.
  • The CNAME chain is in one reply. The resolver followed www.github.com → github.com for you and returned both records.
  • TC=1 means the answer didn't fit in the UDP packet; real clients retry over TCP port 53. Our 1232-byte buffer is the size the DNS community agreed in 2020 avoids IP fragmentation on almost every path.

Exercises:

  1. Point it at a server that doesn't exist (-server 192.0.2.1). You get a timeout, not an error, because UDP can't tell "no server" from "slow server". This is why a dead resolver in /etc/resolv.conf adds a fixed 5-second delay (the "suspiciously round number" from Chapter 26).
  2. Query an authoritative server directly: find it with go run ./dnsquery -type NS example.com, resolve it, then ask it with -server. Check that AA=1.
  3. Add retries with exponential backoff (1 s, 2 s, 4 s) and query two servers in parallel, using whichever answers first. That is what your OS stub resolver does.
  4. Stretch: implement the iterative algorithm. Start at a root server (198.41.0.4), follow NS referrals in the authority section, and reproduce dig +trace yourself.

Across the series: the HTTPS guide's DNS chapter follows this lookup from the browser's point of view (browser cache, OS cache, DoH, HTTPS records). The Go standard library's resolver is net.Resolver; Chapter 55's dnsdiff lab uses it to query many servers at once.

Common confusions

  • "DNS is slow, that's why my site is slow." Usually only the first lookup. Check with the curl -w timing from Chapter 4 — time_namelookup tells you exactly.
  • "nslookup vs dig?" dig is better: more detail, more scriptable. nslookup is the Windows default and is fine for quick checks.
  • "Changing DNS servers makes the internet faster." It can reduce lookup latency slightly, but it does not increase bandwidth. The main reasons to change are privacy, filtering, and reliability.
  • "A CNAME at the top of my domain doesn't work." Correct — a CNAME can't coexist with other records, and the zone apex needs NS and SOA records. Providers offer ALIAS/ANAME workarounds.

Check yourself

  1. Explain the delegation chain from root to www.example.com.
  2. What is a TTL and why does it matter before a migration?
  3. What's the difference between a recursive resolver and an authoritative server?
  4. When does DNS use TCP?
  5. What does dig +trace show you?

Further reading

  • Zine (best beginner resource, genuinely): "How DNS Works" — Julia Evans (jvns.ca). She also has an excellent free comic/zine on DNS. Start here.
  • Interactive: messwithdns.net — Julia Evans' free playground where you create real DNS records and see what happens. Spend 30 minutes here; it's the fastest way to make DNS click.
  • Video (~15 min): "DNS Explained" — Practical Networking, or Computerphile's "How DNS Works".
  • Article: "What is DNS?" — Cloudflare Learning Center (their whole DNS section is excellent).
  • Book: DNS and BIND — Cricket Liu. The definitive reference if you go deep.

End of Part 4 — Milestone check

  • I can name the four DHCP messages and what each does
  • I know what a 169.254.x.x address means instantly
  • I have watched DORA in tcpdump
  • I can explain the DNS delegation chain
  • I have run dig +trace and understood the output
  • I know why you lower a TTL before a migration

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36