DNS on the wire
The same thing, byte by byte (optional): DNS Wire Format - the same message in actual bytes, from a real dig example.com on this machine (query id 2432).
Before you start. DNS Resolution showed your resolver doing the asking for you, one hop at a time. Here you watch what that one question, and its answer, actually look like the moment they leave a machine as raw numbers.
1987: a postcard sized by the worst network on the path
Paul Mockapetris signed off on RFC 1035 in November 1987, at the University of Southern California’s Information Sciences Institute. That document still defines every DNS message sent today. Buried inside it is one plain rule: a DNS answer sent by the fast, no-handshake delivery method, UDP, may be at most 512 bytes.
That number was a bet. The internet of 1987 was a patchwork of Ethernet, dial-up links and satellite hops, and some of those links refused to carry more than a few hundred bytes whole, splitting anything bigger into two separate chances to get lost instead of one. 512 was the largest piece Mockapetris could be confident every link then in use would carry unsplit. It held for over a decade.
Then DNS grew new jobs. By the late 1990s an answer might carry a security signing key, and answers that size stopped being rare. Every one hit the 512-byte ceiling, got stamped “too long,” and had to be asked for again the slow way - that stamp, and the redial it triggers, is most of what this lesson is about.
In August 1999, Paul Vixie, then at the Internet Software Consortium, wrote the fix: RFC 2671, a note a machine could attach to its own question saying “actually, I can take more than 512 bytes back.” A server that understood it used it; a server that had never heard of it just ignored the note and answered the old way. One clause, twelve years later, moved the ceiling without breaking anything already running.
That is the whole shape of this lesson: a message kept small on purpose, a hard edge where small turns into “too small,” and a patch that moved the edge without anyone having to touch it.
Every letter, counted: the telegram
A computer works in bytes: whole numbers from 0 to 255, nothing else, in an order both ends agreed on in advance. Write one as hex and you get two characters, 00 to ff: 07 is seven, 65 is 101, which also happens to be the letter e in the numbering everyone agreed on in the 1960s. Same byte, two readings; the format decides which applies where.
The board calls the question a telegram, and that is exactly right: every letter is counted rather than separated by dots, because a dot would cost a byte and then need special handling the moment a name contained one. example.com goes out as 07 example 03 com 00: “7 letters coming, here they are, 3 letters coming, here they are, done.” Eleven characters become thirteen bytes, and the dot you typed is never actually there. Every DNS message, question or answer, is built from the same five sections in the same order, starting with a header, always fixed at 12 bytes:
flowchart TD
A[Header, 12 bytes fixed] --> B[Question: name, type, class]
B --> C[Answer records, if any]
C --> D[Authority records, if any]
D --> E[Additional records: glue, EDNS0]
A query leaves the last three sections empty and still fits in 29 bytes total: 12 for the header, 13 for the counted name, 4 more saying what you want to know about it. Nothing wasted, nothing self-describing, nothing that needs punctuation to parse.
The ditto mark
A telegram charges by the word, so you never repeat one. An answer almost always needs to repeat the name you just asked about, once per address it hands back, and DNS never actually retypes it. Instead the answer writes two bytes that mean “same name as the one already sitting earlier in this message,” a compression pointer, the ditto mark on the board. Two bytes instead of thirteen, and it works for any name anywhere earlier in the message, not just the question.
That saving looks like a rounding error until you ask for something big. The internet’s thirteen root name servers come back with twenty-six of these pointers in one answer, each standing in for an eighteen-byte name spelled out in full. Put every name back and the reply would run past 650 bytes, over Mockapetris’s ceiling, on the single most-sent message in all of DNS. A saving from 1987 is still load-bearing today.
The hard edge, and Vixie’s patch
Ask for something too big while telling the server you can only take the classic 512 bytes, and you get an empty-looking reply back: a 12-byte header, your question echoed, and one stamped bit, TC, truncated. No error, no partial answer - “ask me again the slow way,” TCP, which pays for a handshake but has no size limit once it is open.
Vixie’s fix is the note that avoids that redial: a client attaches one extra record to its question, saying how much it can actually receive. 1232 bytes is what almost everything uses now, the largest reply that IPv6’s guaranteed minimum network step can carry unsplit. The root’s signing keys, which would have tripped the old 512-byte stamp on every cold start, now come back at 853 bytes, comfortably under 1232, never touching TCP.
You now know
- A DNS message is bytes in a fixed order the two ends agreed on in advance; hex is just a two-character way of writing one.
- A name like
example.comis sent as counted chunks,07 example 03 com, never as the dots you typed. - Every message, question or answer, is the same five sections in the same order, starting with a fixed 12-byte header.
- A compression pointer lets an answer say “same name as earlier in this message” in two bytes instead of retyping it.
- RFC 1035 capped a UDP answer at 512 bytes in 1987 because that was the largest piece every network of the day could carry whole.
- EDNS0, Paul Vixie’s 1999 fix, let a machine ask for more than 512 bytes back without breaking any machine that had never heard of the idea.
Stop here if you want. What follows is the same telegram, byte by byte, with a real query and reply measured off this machine.
Second read: the telegram in raw bytes
Built by hand on this machine and mailed to 1.1.1.1, no dig involved:
query : 29 bytes
0a 04 01 00 00 01 00 00 00 00 00 00
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
00 01 00 01
Line one is the 12-byte header. (A different run from the drawing’s, so the tag here is 0a 04, not 2432.)
| bytes | field | value here | what it is |
|---|---|---|---|
| 0–1 | ID | 0a 04 | a random tag. The reply must carry the same one, which is how you know it answers your question |
| 2–3 | FLAGS | 01 00 | sixteen bits, unpacked below |
| 4–5 | QDCOUNT | 00 01 | one question |
| 6–7 | ANCOUNT | 00 00 | no answers (you’re asking, not telling) |
| 8–9 | NSCOUNT | 00 00 | no authority records |
| 10–11 | ARCOUNT | 00 00 | no additional records |
01 00 as sixteen bits is 0000 0001 0000 0000: QR=0 (question), OPCODE=0, AA=0, TC=0,
RD=1 (recursion desired, the DNS Resolution bit), RA=0, Z=0, RCODE=0.
Line two is the name, the telegram exactly:
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
↑ e x a m p l e ↑ c o m ↑
"7 coming" "3 coming" "done"
The reserved top two bits of a length byte (see the ditto mark below) leave six bits, so a
label caps at 2⁶ − 1 = 63 characters. Line three: 00 01 type A (an IPv4 address), 00 01
class IN (the other classes are museum pieces, except one used in
One address, a thousand doors). 12 + 13 + 4 = 29.
The reply, and what the ditto mark actually buys
The same run, 61 bytes back:
reply : 61 bytes
0a 04 81 80 00 01 00 02 00 00 00 00
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01
c0 0c 00 01 00 01 00 00 00 59 00 04 68 14 17 9a
c0 0c 00 01 00 01 00 00 00 59 00 04 ac 42 93 f3
header : id=0x0a04 flags=0x8180 QD=1 AN=2 NS=0 AR=0
flags : QR=1 OPCODE=0 AA=0 TC=0 RD=1 RA=1 RCODE=0
answer 1: NAME=c00c (pointer -> offset 12) TYPE=1 CLASS=1 TTL=89s RDLENGTH=4 RDATA=68 14 17 9a -> 104.20.23.154
answer 2: NAME=c00c (pointer -> offset 12) TYPE=1 CLASS=1 TTL=89s RDLENGTH=4 RDATA=ac 42 93 f3 -> 172.66.147.243
Against the query:
0a 04came back unchanged - the only thing tying reply to question, since UDP has no connection (TCP vs UDP, and the 3-way handshake); matching the id is the entire anti-forgery story, which is why it must be random and not a counter.81 80=1000 0001 1000 0000: QR=1, RD=1 echoed, RA=1 (server offers to do the walk), RCODE=0. AA=0:1.1.1.1is a resolver, not the authority.- ANCOUNT is now 2 - two
Arecords, free client-side load balancing:example.comis served from more than one address at once. - The question is echoed back verbatim, so the answer is self-contained.
- Each answer is
c0 0c+ 10 fixed bytes + 4 of address.00 00 00 59is TTL 89s;68 14 17 9ais 104.20.23.154 - an IPv4 address is four bytes, the rest is display formatting.
c0 0c is the ditto mark: top two bits 11, remaining 14 bits an offset - 12, where the
question’s name starts. “This name is whatever is at byte 12,” two bytes instead of thirteen.
Measured on a real capture rather than asserted, the thirteen root name servers:
answers: 13 actual reply: 242 bytes
compression pointers used: 26
each replaces an 18-byte name ("root-servers.net" spelled out) with 2 bytes
bytes saved: 26 x (18-2) = 416
uncompressed size would be: 658 bytes -> exceeds the classic 512-byte UDP limit? True
Twenty-six pointers, one per record’s NAME and one inside each RDATA. Without them the root referral - DNS’s single most-sent message - would be 658 bytes and could not fit one packet, needing the fallback below on every cold start, everywhere.
The postcard has a hard edge, and you can see it
The stamp from the story, caught in the act - root signing keys, told to accept only 512 bytes:
$ dig +noedns +ignore @1.1.1.1 . DNSKEY
;; flags: qr tc rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0
;; MSG SIZE rcvd: 17
tc is set. ANSWER: 0. Seventeen bytes - a header and the echoed question, no answer at
all: not an error, a control message saying ask again another way. Same question with
EDNS0 and with TCP:
$ dig +ignore @1.1.1.1 . DNSKEY
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, ADDITIONAL: 1
; EDNS: version: 0, flags:; udp: 1232
;; MSG SIZE rcvd: 853
$ dig +tcp @1.1.1.1 . DNSKEY
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, ADDITIONAL: 1
;; MSG SIZE rcvd: 853
853 bytes, three answers, identical either way. dig normally redials over TCP itself the
instant it sees tc; +ignore stops it here so you can catch the stamp. Ask for more than
the path can carry and a middlebox may silently drop the fragments - a timeout, not an
error. The ladder, in full: UDP at your size → if tc, redial over TCP → TCP has no size
limit (a 2-byte length prefix per message), and pays a handshake for it.
What a lookup costs on the wire, by hand. The 29 and 61 are DNS only; each rides inside a UDP header (8 bytes) inside an IPv4 header (20 bytes):
query on the wire 20 + 8 + 29 = 57 bytes
reply on the wire 20 + 8 + 61 = 89 bytes
one lookup, total 146 bytes, 2 packets, 1 round trip
the same over TCP: 3 handshake packets (≈ 1 RTT, ~180 bytes)
+ 2-byte length prefix on each message
+ the FIN exchange ≈ 5 packets and 2 RTTs before
the answer is in hand
A lookup is smaller than one TCP handshake, and a page touches a dozen names - the whole
reason DNS is UDP first. Only the tc case pays the extra round trip, and the 512 → 1232 →
853 numbers above say how rarely.
The same record, re-measured
Your resolver here is your own router (192.168.29.1, c0a8:1d01 inside its IPv6 address).
The capture behind this lesson recorded TTL = 32s; running it again today on the same name:
answer 1: ... TTL=89s ... -> 104.20.23.154
answer 2: ... TTL=89s ... -> 172.66.147.243
and from the authoritative servers directly, via dig +trace, example.com. 300 IN A.
Addresses unchanged. The authoritative TTL is 300 seconds; 89 is what’s left of one
cache’s 300-second countdown, and 32 was the same thing sampled at a different moment. A TTL
from a resolver is always a remainder, never the setting - only the authoritative server
gives that, which is what the last line of dig +trace shows.
Try it (reproduce the 29-byte query yourself)
# force the bare 29-byte query - no EDNS0, so it fits the classic no-frills case:
dig +noedns example.com
# capture it on the wire in another terminal while the dig above runs:
sudo tcpdump -nn -X -c 2 udp port 53
# or hand-build the exact same bytes yourself, no dig involved:
python3 teaching/code/dns_telegram.py
Match the hex tcpdump -X prints against the label-by-label breakdown above (07 example 03 com 00) - same telegram, three different ways to see it.
If you cannot get root on this machine, tcpdump is not required - every hex dump in this
lesson was produced without it, by building the packet in userspace and printing what came
back. dig will also show you the outgoing query without any capture at all:
# print the query dig is about to send, then the reply - no root needed
dig +qr example.com
# catch the "TOO LONG - CALL ME" stamp in the act. +ignore stops dig from
# silently retrying over TCP, which is what it normally does the instant it sees tc.
dig +noedns +ignore @1.1.1.1 . DNSKEY | grep -E "flags:|MSG SIZE"
dig +tcp @1.1.1.1 . DNSKEY | grep -E "flags:|MSG SIZE"
# the compression saving, measured rather than asserted: 13 names in
# roughly a quarter of a kilobyte. (dig reports 253 rather than the 242
# above because it adds its own 11-byte EDNS0 record; strip that and the
# two agree.)
dig @1.1.1.1 root-servers.net NS | grep -E "ANSWER:|MSG SIZE"
# the configured TTL (authoritative, a round number) vs what a cache
# has left of it (a remainder, different every time)
dig +trace example.com | tail -3
dig @1.1.1.1 +noall +answer example.com
Then break it on purpose. In dns_telegram.py, change one 07 to 06: the name now
reads as exampl plus a stray e, the server cannot parse it, and you get silence, not an
error - the correct behaviour for a protocol whose replies are unauthenticated packets from anywhere.
Go deeper
- RFC 1035 section 4 - message format (the actual spec, very readable)
- Julia Evans - DNS packets
Related
DNS Resolution (the resolver walk shown byte by byte here), TCP vs UDP, and the 3-way handshake (why the query id has to do the matching this lesson relies on), How a parcel finds any house on earth (the MTU limits behind 512 and 1232), Inside your building (your router's life) (a real path’s MTU, measured), One address, a thousand doors (the two-A-record pattern seen in the capture).
The full Networking list is in CURRICULUM.
Retrieval practice
9 questions. Answer each one before you open it.
How does “example.com” appear on the wire?
07 example 03 com 00 - length-prefixed labels, terminated by a zero byte. No dots exist in the packet.
What does the NAME field value C0 0C in an answer record mean?
The top two bits (C0) mark it as a pointer; 0C = offset 12 - “the name is at byte 12”, i.e. the question’s name. Message compression, the ditto mark.
Your query had RD=1 but the resolver’s queries to root/TLD servers have RD=0. Why?
You ask recursively (“do it for me”); the resolver works iteratively, following referrals itself - it doesn’t want the root to recurse.
A response arrives with TC=1. What happens next?
The answer was truncated (didn’t fit the UDP size limit) - the client retries the same query over TCP.
Account for all 29 bytes of a query for example.com, and all 61 of its two-answer reply.
Query: 12 header (id, flags, and four 2-byte counts) + 13 name (07 + example + 03 + com + 00) + 4 type and class = 29. Reply: the same 29 echoed back - header plus the question verbatim - plus two answer records of 16 bytes each. Each record is 2 (the c0 0c pointer) + 2 type + 2 class + 4 TTL + 2 RDLENGTH + 4 of address = 16. 29 + 32 = 61.
Why do the top two bits of a label’s length byte have to be 11 for a compression pointer, and what does that cost the format?
Because the length byte and a pointer occupy the same position, so they must be distinguishable. Reserving the pattern 11 in the top two bits makes any byte ≥ c0 a pointer and anything below it a length. The cost is that a label length only has six bits left, capping any single label at 2⁶ − 1 = 63 characters - a limit still enforced everywhere, and a direct consequence of the ditto-mark trick.
A pointer is 2 bytes and the remaining 14 bits are an offset into this same message, so a name can be reused from anywhere earlier in it.
The thirteen-root-server referral is 242 bytes with compression and would be 658 without. Why is that difference structural rather than a nice saving?
658 exceeds the classic 512-byte UDP limit, so the uncompressed version could not fit in one packet. Every resolver on earth needs that referral before it can answer anything, so without compression the single most-frequent message in DNS would set tc and require a TCP redial on every cold start, everywhere. A space optimisation from 1987 is what keeps DNS a one-packet-each-way protocol.
A truncated reply came back as 17 bytes with ANSWER: 0. Why is that not an error, and why does 1232 keep appearing as an EDNS0 buffer size?
It is a control message: header plus echoed question, tc set, no payload - “what you asked for does not fit, ask again another way.” The client then retries over TCP, where a 2-byte length prefix replaces the size limit entirely. 1232 = the 1280-byte IPv6 minimum MTU minus 48 bytes of IPv6 and UDP headers: the largest UDP payload that provably will not need IP fragmentation on any conforming IPv6 path. Advertising more risks fragments being silently dropped by a middlebox, which fails as a timeout rather than an error.
A resolver reports TTL 89 for a record whose zone file says 300. Which number is wrong?
Neither. 300 is the configured lifetime; 89 is what is left of one particular cache’s countdown of it. A TTL read from a recursive resolver is always a remainder, never a setting - which is also why the same query a second later can report a larger number if it lands on a different machine behind the same address (DNS Resolution). To see the configured value you must ask the authoritative server, which is exactly what the last line of dig +trace does.