Back to guides
Basics

One Packet Out, One Packet Back.

DNS uses both, on the same port 53. UDP carries almost every lookup, TCP carries the answers that will not fit, and your resolver changes lanes without telling you. Every claim below is a command you can paste, on Linux or Windows.

By Ozy-666, creator and operator of dnsdoh.art · Published · Updated · 15 min read
SMALL ANSWER · UDP, the default BIG ANSWER · falls back to TCP Your device One UDP query packet NO HANDSHAKE One UDP reply packet the answer fits in the buffer Done in one round trip the vast majority of lookups Your device Reply will not fit TC=1 · TRUNCATED Retry over TCP handshake first, then the same question Full answer arrives size no longer limited by one packet Same port, two transports. UDP keeps the common case cheap; TCP carries whatever will not fit.

The truncated reply is not an error. It is the server saying: this answer exists, but you will have to collect it over TCP.

The short answer is UDP first, TCP when the answer will not fit in one packet. The useful answer is that you can watch the switch happen in about thirty seconds, so the rest of this page is the commands that show it.

What the split looks like on a real resolver

Textbook answers describe the mechanism but rarely say how often each path is taken. Here is fifteen minutes of live port-53 traffic on this site’s public resolver, taken from a packet capture and reduced to totals - no addresses, no query names, just counts.

Measured on this resolver15 minutes, 29 August 2026
Queries arriving over UDP17,424  (98.3%)
Queries arriving over TCP309  (1.7%)
Replies with the truncation bit set5 of 17,735  (0.03%)
Median UDP reply size100 B  (95th 191 B, max 895 B)
UDP replies larger than 512 bytes9  (0.05%)
UDP replies larger than 1232 bytes0

This is one resolver over one window, not the internet, and a resolver with a different client mix would produce different proportions. It is enough to set expectations: the TCP path is real and it is required, but on ordinary traffic it is the exception, and the fallback that the rest of this page demonstrates has to be provoked deliberately before you will see it.

Check it yourself: Linux

All four commands use dig, from the dnsutils or bind-utils package. Windows ships different tools and gets its own section below. They are pointed at this site's resolver, but any resolver address works - swap in 9.9.9.9 or your router and the behaviour is the same.

1. Which transport did the lookup actually use?

dig prints it, and almost nobody looks. The transport is on the SERVER line:

$ dig @194.180.189.33 example.com A +noall +comments +stats
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
; EDNS: version: 0, flags:; udp: 1232
;; SERVER: 194.180.189.33#53(194.180.189.33) (UDP)
;; MSG SIZE  rcvd: 100

Three numbers matter here. (UDP) is the transport that carried the answer. udp: 1232 is the largest UDP reply this server is willing to send. rcvd: 100 is how big the answer actually was - a hundred bytes against a twelve-hundred-byte ceiling, which is why the question almost never comes up.

2. Force the truncation and look at the TC flag

Shrink the buffer you advertise and ask for something big. The root zone's DNSSEC keys are a convenient large answer, and +ignore tells dig not to follow the fallback, so the truncated reply stays on screen:

$ dig @194.180.189.33 . DNSKEY +dnssec +bufsize=512 +ignore \
    +noall +comments +stats
;; flags: qr tc rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
; EDNS: version: 0, flags: do; udp: 512
;; MSG SIZE  rcvd: 331

$ dig @194.180.189.33 . DNSKEY +dnssec +noall +comments +stats
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 4, AUTHORITY: 0, ADDITIONAL: 1
; EDNS: version: 0, flags: do; udp: 1232
;; MSG SIZE  rcvd: 1167

The tc in the first flags line is the whole mechanism. With a 512-byte budget the server sends one key and sets the flag; with the default budget the same question returns all four keys in 1,167 bytes and no flag at all. The truncated reply here still carries one record, because a server may include as much as fits. Quad9, Cloudflare and Google answer the identical command with 28 bytes - header, question and the EDNS record, no answer section. Both are correct; the flag is the signal, not the size. The choice is not only cosmetic, though: whatever a truncating server puts in that datagram is exactly what a forged source address gets back for free, which is why the amplification post-mortem measures the two styles side by side across eighteen of the fattest TXT sets on the internet.

3. Watch the resolver change lanes on its own

Drop +ignore and ask for an answer that overflows the normal buffer. Microsoft's TXT records are a reliable example at the time of writing:

$ dig @194.180.189.33 microsoft.com TXT +noall +comments +stats
;; Truncated, retrying in TCP mode.
;; flags: qr rd ra; QUERY: 1, ANSWER: 61, AUTHORITY: 0, ADDITIONAL: 1
;; SERVER: 194.180.189.33#53(194.180.189.33) (TCP)
;; MSG SIZE  rcvd: 4918

One line of warning, and the transport on the SERVER line has changed to TCP without anyone asking. Add +ignore to the same command and you see what UDP alone would have given you: tc set, 16 of the 61 records, 1,212 bytes. The full answer is four times larger than any single UDP reply this resolver will send. Quad9 and Cloudflare return 4,890 bytes for the same question and take the same detour.

4. Count the packets the two paths cost

This is the part that makes the design obvious. Capture port 53 in one terminal, run the identical lookup twice in another - once as it normally goes, once forced onto TCP:

# terminal 1
$ sudo tcpdump -i lo -n 'port 53'

# terminal 2
$ dig @127.0.0.1 example.com A          # 2 packets
$ dig @127.0.0.1 example.com A +tcp     # 10 packets

Two packets over UDP: the question, the answer. Ten over TCP for the identical question, and every one of the extra eight is bookkeeping - a three-packet handshake, an acknowledgement of the query, an acknowledgement of the answer, and a three-packet close. Here is the TCP capture with the noise stripped out:

127.0.0.1.5861 > 127.0.0.1.53: Flags [S]   length 0
127.0.0.1.53 > 127.0.0.1.5861: Flags [S.]  length 0
127.0.0.1.5861 > 127.0.0.1.53: Flags [.]   length 0
127.0.0.1.5861 > 127.0.0.1.53: Flags [P.]  length 54   A? example.com. (52)
127.0.0.1.53 > 127.0.0.1.5861: Flags [.]   length 0
127.0.0.1.53 > 127.0.0.1.5861: Flags [P.]  length 102  2/0/1 A ... (100)
127.0.0.1.5861 > 127.0.0.1.53: Flags [.]   length 0
127.0.0.1.5861 > 127.0.0.1.53: Flags [F.]  length 0
127.0.0.1.53 > 127.0.0.1.5861: Flags [F.]  length 0
127.0.0.1.5861 > 127.0.0.1.53: Flags [.]   length 0

Look at the two amber lines. The DNS message is 52 bytes but the TCP segment carries 54, and the 100-byte answer arrives in 102. Those two extra bytes are the length prefix that DNS over TCP puts in front of every message, because a stream has no packet boundaries to mark where one message ends. It is a small detail that catches people writing their own DNS client: over UDP the datagram is the message, over TCP you have to read the length first.

Check it yourself: Windows

Windows ships two DNS tools and they answer different halves of this question. nslookup is the one that shows the truncation, and its d2 debug mode prints the size of every message. Run it interactively and ask for the same oversized TXT set:

C:\> nslookup
> server 194.180.189.33
> set d2
> set type=txt
> microsoft.com
------------
SendRequest(), len 31
    HEADER:
        opcode = QUERY, id = 3, rcode = NOERROR
        header flags:  query, want recursion
        questions = 1,  answers = 0,  authority records = 0,  additional = 0
------------
truncated answer
------------
Got answer (4879 bytes):
    HEADER:
        opcode = QUERY, id = 3, rcode = NOERROR
        header flags:  response, want recursion, recursion avail.
        questions = 1,  answers = 61,  authority records = 0,  additional = 0

A 31-byte question, then truncated answer in plain English, then a second exchange carrying all 61 records. Windows does exactly what dig does and needs one word to say it. Now force TCP from the start with set vc - short for “virtual circuit”, the 1980s name for a connection - and ask the identical question again:

> set vc
> microsoft.com
------------
SendRequest(), len 31
    HEADER:
        opcode = QUERY, id = 4, rcode = NOERROR
        header flags:  query, want recursion
        questions = 1,  answers = 0,  authority records = 0,  additional = 0
------------
------------
Got answer (4879 bytes):
    HEADER:
        opcode = QUERY, id = 4, rcode = NOERROR
        header flags:  response, want recursion, recursion avail.
        questions = 1,  answers = 61,  authority records = 0,  additional = 0

Same question, same 61 records, same 4,879 bytes - and the truncated answer line is gone, because there was no UDP attempt to truncate. That one missing line is the whole difference between the two transports, visible without a packet capture. The same thing works as a one-liner if you prefer: nslookup -debug -type=txt microsoft.com 194.180.189.33, with -vc added to force TCP.

Why Windows says 4,879 bytes and dig says 4,918

The same 61 records, two tools, two numbers. Neither is wrong, and the gap is worth understanding because it is the EDNS record from earlier in this page showing up on the scales. Windows nslookup sends no EDNS at all - look at additional = 0 in its output. dig sends one, and gets one back. Three runs separate the pieces:

$ dig @194.180.189.33 microsoft.com TXT +tcp
;; MSG SIZE  rcvd: 4918   # EDNS record + DNS cookie
$ dig @194.180.189.33 microsoft.com TXT +tcp +nocookie
;; MSG SIZE  rcvd: 4890   # EDNS record, no cookie
$ dig @194.180.189.33 microsoft.com TXT +tcp +noedns
;; MSG SIZE  rcvd: 4879   # no EDNS at all, same as Windows

4,879 bytes of actual DNS answer. The EDNS record adds 11, and the DNS cookie inside it adds 28 more, which is the 4,918 dig reports by default. It also explains a number from earlier: Quad9 and Cloudflare answer the same question in 4,890 bytes because they return the EDNS record but do not send a cookie back. Three resolvers, three totals, one answer - and every byte accounted for.

PowerShell can force TCP, but cannot show you the truncation

PS> $r = Resolve-DnsName -Name microsoft.com -Type TXT `
        -Server 194.180.189.33 -TcpOnly -DnsOnly
PS> "records returned: $($r.Count)"
records returned: 61

61, agreeing with both nslookup runs and with dig - three tools on two operating systems on the same answer. But the cmdlet's complete parameter set is -Name -Type -Server -DnsOnly -CacheOnly -DnssecOk -DnssecCd -NoHostsFile -LlmnrNetbiosOnly -LlmnrFallback -LlmnrOnly -NetbiosFallback -NoIdn -NoRecursion -QuickTimeout -TcpOnly: no switch for the EDNS buffer, and nothing that reports the TC bit or the message size. -TcpOnly forces the transport, it does not reveal it. That is why the demonstration above uses nslookup.

Two things that look like errors and are not. nslookup reverse-resolves the server address before anything else, so an address with no PTR record gives you Server: UnKnown, or the address in square brackets, and an NXDOMAIN block at the top of the run - unrelated to the query you asked. And on a non-English Windows the Server: and Name: labels are translated while the debug dump, including truncated answer, stays in English.

If you want the packet counts from step 4, pktmon is built in, and Wireshark with the capture filter port 53 shows the same handshake either way.

Where 512 and 1232 come from, and which one your resolver uses

The 1987 specification, RFC 1035, capped a UDP DNS message at 512 bytes - a size that was safe on the networks of the day. Answers then grew: more addresses per name, IPv6, and above all DNSSEC signatures, which can multiply a response several times over. RFC 6891 added EDNS0, a pseudo-record on the query that says "I can accept UDP answers up to this many bytes", and the same extension became the carrier for the DNSSEC-OK flag, DNS cookies and client subnet.

Advertising a large buffer brought its own failure. A UDP datagram bigger than the path allows gets split into IP fragments, fragments are dropped by plenty of middleboxes and are easier to tamper with, and the symptom is a lookup that silently never completes. DNS Flag Day 2020 settled on 1232 bytes, derived from the 1280-byte minimum IPv6 MTU minus 48 bytes of IPv6 and UDP headers. RFC 9715, published in January 2025, revisits the arithmetic and recommends 1400 instead, on the grounds that the interior of the internet runs at an MTU of 1500.

Deployment has not followed. Six public resolvers, the same query, five runs each, reading the buffer each one advertises in its reply:

ResolverAdvertised UDP buffer
dnsdoh.art 194.180.189.331232
Quad9 9.9.9.91232
Cloudflare 1.1.1.11232
OpenDNS 208.67.222.2221410
Google 8.8.8.8512
AdGuard DNS 94.140.14.140

Five different answers from six resolvers, and none of them is 1400. The 1232 group is the DNS Flag Day default. OpenDNS sits close to the newer recommendation. Google advertises 512, the original floor. AdGuard advertises 0, which is what a responder sends when it is not offering a UDP size at all - the practical effect is that a client with a large answer to fetch ends up on TCP sooner rather than later.

One command reads it for whichever resolver you actually use:

$ dig example.com A +bufsize=4096 +noall +comments | grep udp:
; EDNS: version: 0, flags:; udp: 1232

Asking for 4,096 and being told 1232 is the point of the test: the number that governs your lookups is the smaller of what you request and what the server will send. We have written up what happens when a client keeps asking for 4 KB anyway, which is how a clamp like this surfaces a bug that was invisible while everyone stayed under the limit.

What the fallback actually costs

The extra packets have to be paid for in time, and the size of the bill depends entirely on how far away the resolver is. Fifty paired runs, the same query alternating UDP and TCP so that both see the same network conditions, wall clock around the whole command:

n = 50 paired runsUDP medianTCP medianDifferenceUDP faster
Quad9, 6.31 ms away30.3 ms36.7 ms+6.4 ms41 / 50
Same host, no network23.6 ms23.7 ms+0.2 ms30 / 50

Read the difference column, not the medians: both include the time it takes to start a dig process, which is most of the absolute number and cancels out between the pairs. Against Quad9 the TCP path costs 6.4 ms more. The measured round-trip time to Quad9 over twenty pings was 6.31 ms average. Those two numbers matching is the arithmetic you would predict: the handshake is one extra round trip, so it costs one RTT, and nothing else about TCP shows up at this size. On the same host, where a round trip is essentially free, the two transports are indistinguishable and UDP wins 30 of 50 - a coin flip.

There is a trap in measuring this, and it is worth knowing because it is the obvious way to do it. dig prints its own Query time, and if you use that number you will conclude TCP is free:

Same 30 pairs against Quad9UDPTCPUDP faster
Wall clock around the command30.2 ms36.9 ms27 / 29
dig's own reported Query time6.0 ms6.0 ms7 / 30

The two disagree by one round trip because dig is not counting the handshake. In the BIND 9 source, Query time is the difference between time_recv and time_sent, and time_sent is stamped when the query is handed to an already-connected socket - after the TCP connection is established. The number is honest about what it measures; it just is not the number you want when comparing transports.

Blocking TCP port 53 breaks DNS

This is the practical reason the question gets asked at all. A firewall rule that permits UDP 53 and drops TCP 53 looks reasonable if you believe DNS is a UDP protocol, and it works for months, because the census above is what ordinary traffic looks like - almost nothing needs the fallback. Then something does: a domain adds enough TXT records, a zone gets signed, a client asks for DNSKEY. The lookup gets a truncated reply, retries over TCP, and hangs until it times out. The failure is intermittent and name-specific, which makes it one of the more annoying things to diagnose.

RFC 7766 settled the argument in 2016: support for TCP is a required part of the protocol, not an optional extra. Both transports on port 53, in both directions, or DNS is not working - it is only appearing to work.

The one-line test is any lookup with +tcp forced. If dig @your.resolver example.com +tcp times out while the plain version answers, something in the path is dropping TCP 53.

What encrypted DNS changes

The encrypted transports redraw this map, and the truncation dance mostly disappears. DNS over TLS and DNS over HTTPS run on TCP from the first byte, so there is no size cliff and no fallback: any answer streams through. The handshake that made TCP unattractive in 1987 is paid once and then amortised across every query on the same connection, and session resumption makes reconnecting cheap.

DNS over QUIC and DNS over HTTP/3 return to UDP, but with encryption and reliability built into QUIC rather than left to the application. A lost packet delays only its own query, and a resumed connection can send the first query without waiting. Forty years on, the answer to "TCP or UDP" has become: UDP again, this time with the handshake, proof of who is answering, and no packet-size cliff. Which of them is worth running, and what each costs in bytes and milliseconds, is measured in the transport comparison.

None of this changes the answer to the original question for plain DNS. Port 53 still uses UDP first and TCP when it must, and it is still readable by every network between you and the resolver.

Your lookups still travel somehow

Plain port-53 DNS, UDP or TCP, is readable and rewritable by every network in between. The encrypted transports above close that, and switching takes a couple of minutes.

Set up encrypted DNS

Related: how a DNS lookup actually works, what a DNS resolver does, the 4 KB UDP limit that exposed a TCP bug, and why UDP makes spoofing possible.