A healthy DNS fallback is two observable transport events: the client receives a UDP reply with the TC (truncated) flag, then repeats the same question to the same server over TCP and receives the complete answer. A timeout by itself proves neither event. If TCP port 53 is blocked, small records may resolve while larger DNSSEC, TXT or multi-record answers fail intermittently.
Three facts decide ownership: did UDP return TC=1, can TCP reach the same endpoint, and does TCP return the full record set? This guide reproduces all three on loopback, then adds a UDP-only negative control that fails exactly after truncation. No production firewall, public zone or resolver cache is changed.
Reproduction used Debian 13.6, BIND and dig 9.20.26, and Node.js 24.18.0. The target reader is an authoritative-DNS or network operator comfortable with a shell, DNS record types and controlled root access. Run the fixture only on a disposable host: BIND launches two local listeners on port 15353, while the negative control uses UDP-only port 15354.
Classic DNS usually starts with UDP because a short query and response fit one datagram. Extension Mechanisms for DNS (EDNS) let a requestor advertise a larger UDP receive size, but that does not make every path safe for a large datagram. ISC documents that dig 9.18 and later changed its default advertised size from 4096 to 1232 bytes, so record the client version and pass an explicit +bufsize when comparing results.
When the authoritative response will not fit the accepted UDP size, the server can return a smaller message with the TC bit set. RFC 7766 requires general-purpose DNS implementations to support UDP and TCP, and explains that blocking TCP can cause resolution failure or application timeouts. The newer RFC 9715 fragmentation guidance treats transport upgrade as part of avoiding fragile fragmented UDP, not as a legacy corner case.
One distinction prevents a common false diagnosis: a UDP timeout is not the same as a truncated UDP response. dig automatically retries after an explicit TC reply; it does not generally convert a silent UDP timeout into TCP. A direct +tcp success beside a normal UDP timeout identifies a UDP-path difference, but it does not prove the normal resolver received the signal needed to retry.
Using the reserved .test namespace keeps the fixture away from public DNS, and the server binds only to 127.0.0.1. Eight TXT strings make the answer deterministic, while +bufsize=512 later forces the old UDP boundary. The marker prevents an accidental collision, and every later block refuses to run without it.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
MARKER=voxfor-dns-tcp-fallback-lab-139
[[ ! -e "$LAB" ]]
install -d -m 0755 "$LAB"
printf '%s\n' "$MARKER" > "$LAB/.marker"
cat > "$LAB/named.conf" <<EOF
options {
directory "$LAB";
listen-on port 15353 { 127.0.0.1; };
listen-on-v6 { none; };
recursion no;
minimal-responses no;
pid-file "$LAB/named.pid";
session-keyfile "$LAB/session.key";
};
controls { };
zone "tcp-lab.test" {
type primary;
file "$LAB/tcp-lab.test.zone";
};
EOF
cat > "$LAB/tcp-lab.test.zone" <<'EOF'
$TTL 60
@ IN SOA ns.tcp-lab.test. hostmaster.tcp-lab.test. (2026081201 60 60 3600 60)
@ IN NS ns.tcp-lab.test.
ns IN A 127.0.0.1
large IN TXT (
"receipt-01-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-02-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-03-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-04-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-05-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-06-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-07-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
"receipt-08-abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789"
)
EOF
chmod 0644 "$LAB/named.conf" "$LAB/tcp-lab.test.zone"
named-checkconf "$LAB/named.conf"
named-checkzone tcp-lab.test "$LAB/tcp-lab.test.zone"
named -g -u root -c "$LAB/named.conf" > "$LAB/named.log" 2>&1 &
echo $! > "$LAB/named.shell.pid"
for _ in {1..40}; do
dig @127.0.0.1 -p 15353 ns.tcp-lab.test A +tcp +time=1 +tries=1 +short \
>/dev/null 2>&1 && break
sleep 0.1
done
kill -0 "$(<"$LAB/named.shell.pid")"
Configuration validation and startup are different claims. named-checkconf catches parser errors; named-checkzone validates the zone; the final query proves the owned process has actually accepted TCP work. On Debian, AppArmor permits dynamic BIND data beneath /var/cache/bind; use a distribution-approved lab location rather than disabling confinement.
Confirm that the positive fixture owns both UDP and TCP on the same address and port before interpreting any DNS answer. The second UDP-only listener does not exist yet.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
LISTENERS=$(ss -H -lntu | awk '$5=="127.0.0.1:15353" {print $1, $5}' | sort -u)
printf '%s\n' "$LISTENERS"
grep -qx 'tcp 127.0.0.1:15353' <<< "$LISTENERS"
grep -qx 'udp 127.0.0.1:15353' <<< "$LISTENERS"
In production, repeat this ownership check at every layer that can terminate the destination: the authoritative daemon, host firewall, cloud security group, load balancer and any network policy. A public authority must work over both transports from outside its own network, not only from localhost.
dig Hides ItBy default, dig notices truncation and continues over TCP, so the final output can conceal the first UDP response. +ignore keeps that initial response visible. The answer count should be zero here and the header must include tc; do not mistake the empty answer section for NXDOMAIN or missing zone data.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
OUT=$(dig @127.0.0.1 -p 15353 large.tcp-lab.test TXT \
+bufsize=512 +ignore +norecurse +time=1 +tries=1)
printf '%s\n' "$OUT"
FLAGS=$(awk '/flags:/{print; exit}' <<< "$OUT")
[[ "$FLAGS" == *" tc;"* ]]
grep -q 'ANSWER: 0' <<< "$FLAGS"
Answer size depends on the owner name, record type, DNSSEC material and additional data. Use a production name that is already reported as failing; do not manufacture an ANY query and assume it represents application behavior. If the problem involves validation, trace the DNSSEC chain separately after transport is proven.
Remove +ignore while keeping the same question and declared buffer. BIND’s dig prints the transition explicitly and returns all eight TXT strings from the TCP response. This is the end-to-end fallback receipt.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
OUT=$(dig @127.0.0.1 -p 15353 large.tcp-lab.test TXT \
+bufsize=512 +norecurse +time=1 +tries=1 2>&1)
printf '%s\n' "$OUT"
grep -q 'Truncated, retrying in TCP mode' <<< "$OUT"
[[ "$(grep -o 'receipt-' <<< "$OUT" | wc -l)" -eq 8 ]]
A separate forced-TCP query answers another question: can the client establish TCP and receive the complete data without depending on the UDP stage? Keep the server, port, owner name and type identical.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
OUT=$(dig @127.0.0.1 -p 15353 large.tcp-lab.test TXT \
+tcp +norecurse +time=1 +tries=1)
printf '%s\n' "$OUT"
FLAGS=$(awk '/flags:/{print; exit}' <<< "$OUT")
[[ "$FLAGS" == *" aa;"* ]]
[[ "$(grep -o 'receipt-' <<< "$OUT" | wc -l)" -eq 8 ]]
+tcp alone is not a fallback test because it skips UDP. Paired with the raw TC receipt and the automatic retry, however, it separates server data from transport reachability. For a delegated public zone, query every authority directly; secondary DNS planning explains why one healthy name server cannot represent the whole delegation.
For the negative control, a valid-looking authoritative header returns TC=1 over UDP while no TCP socket exists. It changes no firewall rule. dig receives the retry signal, attempts TCP on the same port and fails closed with exit status 9.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
cat > "$LAB/udp-tc-only.mjs" <<'EOF'
import dgram from 'node:dgram';
const socket = dgram.createSocket('udp4');
socket.on('message', (query, peer) => {
let end = 12;
while (end < query.length && query[end] !== 0) end += query[end] + 1;
end += 5;
if (end > query.length) return;
const response = Buffer.from(query.subarray(0, end));
const requestFlags = query.readUInt16BE(2);
response.writeUInt16BE(0x8600 | (requestFlags & 0x0100), 2);
response.writeUInt16BE(1, 4);
response.writeUInt16BE(0, 6);
response.writeUInt16BE(0, 8);
response.writeUInt16BE(0, 10);
socket.send(response, peer.port, peer.address);
});
socket.bind(15354, '127.0.0.1');
EOF
node "$LAB/udp-tc-only.mjs" > "$LAB/udp-only.log" 2>&1 &
echo $! > "$LAB/udp.shell.pid"
for _ in {1..20}; do
ss -H -lun | grep -q '127.0.0.1:15354' && break
sleep 0.1
done
set +e
OUT=$(dig @127.0.0.1 -p 15354 broken.tcp-lab.test TXT \
+bufsize=512 +norecurse +time=1 +tries=1 2>&1)
STATUS=$?
set -e
printf '%s\nexit=%s\n' "$OUT" "$STATUS"
[[ "$STATUS" -eq 9 ]]
grep -qi 'connection refused' <<< "$OUT"
Real paths fail differently: a firewall may silently drop the SYN, a load balancer may reset it, or one Anycast site may route TCP to a different backend. The invariant is narrower than the local connection refused message—UDP delivers TC, but the TCP attempt does not return the same complete answer within the declared test window.
APNIC’s 2024 resolver truncation measurements also show why eventual browser success is weak evidence: resolver behavior and fallback reliability differ across networks. Test from more than one external network when the incident is path-dependent.
Start read-only. Record the failing client or recursive resolver, authoritative IP, owner name, type, expected answer identity, UTC window, dig -v, explicit buffer and both transport outcomes. A record-content change made before this comparison destroys the cleanest evidence about whether transport or data owned the failure.
| UDP observation | Direct TCP | Automatic retry | Most likely owner | Next action |
|---|---|---|---|---|
| Complete answer, no TC | Complete | Not needed | No fallback required for this query/path | Reproduce the real failing type and validation state |
TC=1 |
Complete | Complete | Healthy fallback | Retain the receipt and monitor the larger-answer path |
TC=1 |
Timeout/reset/refused | Fails | TCP listener, firewall, load balancer or route | Repair TCP/53 on the exact client-to-authority path |
| UDP timeout | Complete | No retry signal received | UDP path, fragmentation or filtering | Inspect UDP and MTU evidence; do not call it TCP fallback |
| Both transports complete, application fails | Complete | Complete | Stub/resolver behavior, cache, validation or different endpoint | Trace the application’s actual resolver and authority |
Fragmentation belongs in the fourth row, not every TCP failure. Current IETF guidance recommends conservative UDP sizing because oversized fragmented DNS messages are fragile. If a tunnel or encapsulation path is involved, the WireGuard MTU workflow shows how to prove the packet-size boundary without lowering MTU blindly.
Certificate issuance exposes another useful distinction. A CAA lookup can fail because an authority returns SERVFAIL, because validation breaks, or because a large response cannot complete its transport path. The CAA SERVFAIL diagnosis keeps those ownership paths separate. Likewise, an authority-positive but resolver-negative answer can be legitimate DNS negative caching rather than truncation.
Repair only the proven boundary. Public authoritative DNS normally needs destination port 53 over both UDP and TCP from recursive resolvers. Preserve source-port return traffic, apply the same backend and health model to both transports, and monitor TCP connection capacity rather than replacing a block with an unbounded exception. When DNS or network ownership belongs to the provider, a documented handoff should include the exact two-transport receipt; Voxfor’s managed server operations explicitly include DNS settings and network diagnostics for teams that do not administer that layer themselves.
Here is the representative local receipt. It contains the protocol facts and no production names, addresses or secrets.
environment: BIND/dig 9.20.26, Node.js 24.18.0, loopback only
listeners: tcp 127.0.0.1:15353; udp 127.0.0.1:15353; udp 127.0.0.1:15354
udp raw: flags=qr aa tc, answer=0
automatic: Truncated, retrying in TCP mode; final flags=qr aa; TXT strings=8
direct tcp: flags=qr aa; TXT strings=8
udp-only control: TCP connection refused; dig exit=9
verdict: TC observed, positive TCP completion proved, TCP-missing failure reproduced
cleanup boundary: owned PIDs plus exact marker-guarded directory only
Accept the production path only when the real large query returns either a complete UDP answer or an explicit TC response followed by the same complete answer over TCP; direct TCP succeeds against every relevant authoritative endpoint; the expected record identity and validation state match; external tests from representative networks agree; both transports are monitored; and no unrelated firewall, resolver or record change was required to manufacture success.
No. RFC 7766 permits a resolver to use TCP first for local operational reasons or reuse an existing TCP connection. UDP-first followed by TCP is the familiar truncation path, but captures and logs—not an assumption—must identify what the actual client did.
dig +tcp prove TCP fallback works?dig +tcp proves only that a direct TCP query can complete. A fallback proof also needs the initial UDP response with TC=1 and evidence that the normal client repeated the same question over TCP.
A timeout is silence; it does not carry the DNS TC signal. Some resolvers have their own alternate-transport policy, but dig normally retries TCP after truncation, not after an ordinary UDP timeout. Test both transports explicitly before assigning ownership.
Not as a blind repair. A larger advertised UDP size can reduce fallback frequency but can also increase fragmentation risk. Declare the buffer in tests, follow current resolver/server guidance, and keep TCP working because a full DNS implementation cannot depend on every answer fitting one safe UDP datagram.
Yes, a general-purpose public authoritative service must support TCP as well as UDP for DNS. Apply normal capacity, abuse and connection controls, but do not block legitimate TCP/53 merely because most queries are small.
No. DoH and DoT can protect the client-to-recursive leg, while recursive resolvers still query authoritative servers using classic DNS transports. Encrypted client DNS does not repair blocked TCP/53 between a resolver and an authority.
Cleanup stops only the two PIDs recorded inside the marker-owned directory, confirms that neither lab port remains, and removes only the exact fixture. It does not scan for BIND or Node processes by name.
set -Eeuo pipefail
LAB=/var/cache/bind/voxfor-dns-tcp-fallback-lab
[[ -f "$LAB/.marker" ]]
[[ "$(<"$LAB/.marker")" == voxfor-dns-tcp-fallback-lab-139 ]]
for PID_FILE in "$LAB/udp.shell.pid" "$LAB/named.shell.pid"; do
[[ -f "$PID_FILE" ]] || continue
PID=$(<"$PID_FILE")
kill "$PID" 2>/dev/null || true
for _ in {1..20}; do kill -0 "$PID" 2>/dev/null || break; sleep 0.1; done
done
! ss -H -lntu | awk '$5=="127.0.0.1:15353" || $5=="127.0.0.1:15354" {found=1} END{exit found?0:1}'
rm -rf --one-file-system "$LAB"
[[ ! -e "$LAB" ]]
printf 'cleanup=owned_listeners_stopped_marker_scope_absent\n'
If production acceptance regresses after a network change, restore only the previous reviewed TCP/53 listener, load-balancer, firewall or route policy for the affected endpoint, then repeat the same UDP-TC, automatic-retry and direct-TCP receipt from outside the network. Do not roll back by deleting DNSSEC records, shrinking legitimate TXT data, disabling EDNS globally or opening unrelated ports; preserve the failed capture and change record for the owner who must explain the path.