When examining how DNS switches from UDP to TCP, we're dealing with a well-specified behavior in RFC 1035 and later clarified in RFC 6891 (EDNS0). The key mechanism isn't about length alone, but about the TC
(Truncated) flag in the DNS header.
Here's what really happens:
- Client sends UDP query (typically limited to 512 bytes without EDNS0)
- Server detects response will exceed UDP limits
- Server sets the
TC=1
flag in response header and truncates the response - Client receives truncated response and initiates TCP connection to same port 53
- Server completes full response over TCP
A truncated DNS response header looks like:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12345 ;; flags: qr rd ra tc; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0
Here's how a resolver might handle this in code:
def make_dns_request(domain, qtype="A"): try: # First try UDP response = udp_query(domain, qtype) if response.flags.tc: # Truncation flag set return tcp_query(domain, qtype) return response except DNSError as e: # Fallback to TCP on UDP failure return tcp_query(domain, qtype)
- The server never initiates TCP - it's always client-driven
- Modern DNS with EDNS0 can negotiate larger UDP packets (up to 4096 bytes)
- AXFR (zone transfer) queries always use TCP by default
Watch the transition in action:
$ dig +ignore +bufsize=512 example.com TXT ;; Truncated, retrying in TCP mode.
As you correctly noted, NAT makes server-initiated TCP impractical. The DNS specification wisely puts the connection initiation burden on the client, which already has network context.
For developers working with DNS libraries, understanding this fallback mechanism is crucial for implementing robust DNS resolution that handles all edge cases properly.
When a DNS query exceeds UDP's 512-byte payload limit (per RFC 1035), the protocol must handle truncation gracefully. Modern implementations follow RFC 6891 (Extension Mechanisms for DNS - EDNS0) which expands this limit, but fallback mechanisms remain critical.
The DNS server signals truncation by setting the TC (Truncated) flag in the UDP response header. This triggers the client to reissue the query via TCP. Here's the exact flow:
1. Client sends UDP query (e.g., for large TXT records)
2. Server detects response exceeds 512 bytes (or EDNS0 buffer size)
3. Server returns truncated response with TC=1
4. Client initiates TCP connection (typically port 53)
5. Client resends original query over TCP
6. Server returns complete response via TCP
The TC flag (bit 1 in DNS header's second byte) is the key mechanism. When set:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
^
Truncation flag (bit 3)
Observing the behavior with diagnostic tools:
$ dig +ignore +noedns example.com TXT
;; Truncated, retrying in TCP mode.
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 65243
;; flags: qr rd ra; QUERY: 1, ANSWER: 15, AUTHORITY: 0, ADDITIONAL: 1
Here's pseudocode for client-side handling:
function resolveDNS(query) {
let response = sendUDPQuery(query);
if (response.header.TC) {
log("Response truncated, switching to TCP");
response = sendTCPQuery(query);
}
return processResponse(response);
}
Network configurations that may interfere:
- TCP port 53 blocking
- Middleboxes dropping DNS-over-TCP packets
- MSS clamping issues with large responses
- Timeout discrepancies between UDP and TCP
While the core mechanism remains, newer developments affect behavior:
- EDNS0 buffer size negotiation
- DNS-over-TLS (DoT) and DNS-over-HTTPS (DoH) implementations
- TCP fast open for DNS