Most DNS lookups involve a single UDP packet out and a single packet back. Because UDP requires no handshake or connection state, it can deliver a complete answer in one round trip. As a result, DNS typically uses UDP first.
However, DNS also runs over TCP, and not only as a rare fallback. When a UDP response is truncated, the resolver retries over TCP. If TCP port 53 is blocked somewhere along the path, those lookups fail while everyday queries keep working.
To demonstrate this behavior, we force that fallback on a NetBeez agent and watch it happen on the wire.
When DNS uses TCP
The original DNS specification (RFC 1035) capped UDP messages at 512 bytes. When an answer exceeded that limit, the server returned a partial response with the TC (truncated) flag set. The client then retried the same query over TCP.
Later, EDNS(0) (RFC 6891) relaxed that limit. With EDNS(0), the client adds an OPT record advertising how large a UDP response it can accept. Since DNS Flag Day 2020, a common default has been 1232 bytes, chosen to avoid IP fragmentation on most paths.
DNS uses TCP in several situations:
- When a UDP response comes back truncated, with the TC flag set.
- For full zone transfers (AXFR), and in some incremental zone-transfer (IXFR) exchanges.
- When the client explicitly requests it, for example with
dig +tcp.
In addition, RFC 7766 makes TCP support mandatory for DNS implementations. TCP is therefore a first-class DNS transport.
What makes a DNS response large?
DNSSEC is a common reason for larger responses. Signed answers carry RRSIG signatures, while DNSKEY queries return public keys. Similarly, large TXT records, including SPF and DKIM records, and long lists of A/AAAA or SRV records can push responses past 512 bytes.
However, DNSSEC alone does not guarantee a large response. Key size also depends on the signing algorithm.
To illustrate the difference, we queried two signed zones and several TXT records from a NetBeez agent through 1.1.1.1. The table shows the response sizes observed during those tests; record contents and sizes can change over time.e, though. Key size depends on the signing algorithm. We queried two signed zones from a NetBeez agent through 1.1.1.1:
| Query (via 1.1.1.1) | Why it’s large | Response size | Over UDP at 512 | Over UDP at 1232 |
|---|---|---|---|---|
| cloudflare.com A +dnssec | ECDSA signature | 185 bytes | fits | fits |
| cloudflare.com DNSKEY +dnssec | ECDSA keys | 313 bytes | fits | fits |
| . DNSKEY +dnssec (root zone) | RSA keys + signature | 1139 bytes | truncated → TCP | fits |
| google.com TXT | 17 TXT records | 1187 bytes | truncated → TCP | fits |
| microsoft.com TXT | 62 TXT records | 4782 bytes | truncated → TCP | truncated → TCP |
Cloudflare’s compact ECDSA keys fit comfortably even in 512 bytes. The root zone’s RSA keys don’t, which makes the root DNSKEY query an easy, reproducible way to trigger truncation.
TXT records show the same problem without any DNSSEC involved. Large organizations publish SPF plus dozens of domain-verification strings for SaaS vendors. google.com’s 1187-byte answer only fits thanks to EDNS(0). microsoft.com’s 4782-byte answer is too large for any practical UDP size, so every client that asks for it over UDP is pushed to TCP, even with dig’s default settings.
Demo: watching TCP fallback happen
To force truncation, we advertise the original 512-byte limit with +bufsize=512 and ask for the root zone’s keys. In one terminal, start a capture:
tcpdump -lni wlan0 -vvv 'host 1.1.1.1 and port 53' In another, run the query:
$ dig @1.1.1.1 . DNSKEY +dnssec +bufsize=512
;; Truncated, retrying in TCP mode.
...
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 4, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags: do; udp: 1232
...
;; SERVER: 1.1.1.1#53(1.1.1.1) (TCP)
;; MSG SIZE rcvd: 1139 dig reports the truncation and that the final answer arrived over TCP. One detail confuses many people: the udp: 1232 line is the server’s OPT record echoed in the response, not the size dig sent. The capture confirms the client advertised 512.
On the wire, the exchange looks like this:
# UDP: query advertising 512 bytes, truncated reply
13:53:24.098328 172.31.0.211.60151 > 1.1.1.1.53: 15745+ [1au] DNSKEY? . ar: . OPT UDPsize=512 DO (40)
13:53:24.116250 1.1.1.1.53 > 172.31.0.211.60151: 15745| q: DNSKEY? . 0/0/1 ar: . OPT UDPsize=1232 DO (28)
# TCP: handshake, query, answer, teardown
13:53:24.117593 172.31.0.211.40643 > 1.1.1.1.53: Flags [S], seq 38195531, length 0
13:53:24.128740 1.1.1.1.53 > 172.31.0.211.40643: Flags [S.], seq 1990782955, ack 38195532, length 0
13:53:24.128877 172.31.0.211.40643 > 1.1.1.1.53: Flags [.], ack 1, length 0
13:53:24.129070 172.31.0.211.40643 > 1.1.1.1.53: Flags [P.], length 42 62921+ [1au] DNSKEY? . ar: . OPT UDPsize=512 DO (40)
13:53:24.142621 1.1.1.1.53 > 172.31.0.211.40643: Flags [.], ack 43, length 0
13:53:24.145228 1.1.1.1.53 > 172.31.0.211.40643: Flags [P.], length 1141 62921$ q: DNSKEY? . 4/0/1 . DNSKEY, . DNSKEY, . DNSKEY, . RRSIG ar: . OPT UDPsize=1232 DO (1139)
13:53:24.145272 172.31.0.211.40643 > 1.1.1.1.53: Flags [.], ack 1142, length 0
13:53:24.147312 172.31.0.211.40643 > 1.1.1.1.53: Flags [F.], length 0
13:53:24.157880 1.1.1.1.53 > 172.31.0.211.40643: Flags [F.], length 0
13:53:24.157999 172.31.0.211.40643 > 1.1.1.1.53: Flags [.], length 0 What to look for:
- The UDP query advertises
UDPsize=512. - The UDP reply carries | after the query ID, tcpdump’s marker for the TC flag, and no answer records. ($ marks the AD bit: 1.1.1.1 validated the answer.)
- The client opens a TCP connection to port 53 and repeats the query.
- The answer arrives as one 1141-byte TCP payload: the 1139-byte DNS message plus the 2-byte length prefix DNS-over-TCP requires.
The same lookup took 2 packets over UDP for small answers; here it takes 12.
Why it matters: cost and a silent failure mode
TCP adds at least one extra round trip for the handshake, plus connection setup and teardown on both ends. In our capture the handshake alone took about 470 ms on Wi-Fi, while the answer arrived about 15 ms after the query was sent. In exchange, TCP avoids IP fragmentation and handles responses of any size.
The bigger risk is a network that allows UDP port 53 but blocks TCP port 53. Firewalls and security policies sometimes do this on the assumption that DNS is UDP-only. Everyday lookups keep working, so the problem goes unnoticed until a large response is needed.
You can reproduce it on a lab machine:
# drop DNS over TCP to 1.1.1.1
nft add table inet dnstest
nft add chain inet dnstest out '{ type filter hook output priority 0; }'
nft add rule inet dnstest out ip daddr 1.1.1.1 tcp dport 53 drop
dig @1.1.1.1 cloudflare.com # works over UDP
dig @1.1.1.1 . DNSKEY +dnssec +bufsize=512 # truncated, TCP retry fails
nft delete table inet dnstest # clean up Here is what it looks like on the NetBeez dashboard:
The result: a DNS service that looks healthy to a UDP-only check, yet fails for DNSSEC-validated lookups or any other large response.
Monitoring both transports with NetBeez
Because UDP and TCP DNS can fail independently, testing only one transport gives an incomplete picture. NetBeez DNS tests let you choose the transport explicitly: set Test Type to UDP or TCP, pick the record type, and point the test at a specific resolver or leave it empty to use the agent’s default DNS servers.
Running the same query over both transports from the same agent shows:
- whether TCP port 53 is reachable from every location,
- how much the TCP handshake adds to resolution time compared to UDP.
If the UDP test passes and its TCP counterpart fails, you have found the silent failure described above, before your users’ DNSSEC lookups do.