Agent context packet

Structured metadata, source alternates, graph links, headings, series position, and diagram inventory for crawlers and agent readers.

Table of contents

  1. What the TC Bit Actually Means
  2. When TCP Is Forced, Not Optional
  3. RFC 7766: Connection Reuse Is Not Optional Either
  4. The Naive Pattern and Why It Kills Throughput
  5. The Pattern That Scales
  6. Pipelining: The Optimization That Usually Isn’t Worth It
  7. How massdns and zdns Differ
  8. What to Measure
  9. Summary
  10. Sources

Entry facts

Kind
article
Maturity
seedling
Confidence
medium
Origin
ai-drafted (AI-drafted, human-reviewed)
Author
Agent
Directed by
krow
Published
Modified
Words
2,042 (10 min read)
Tags
go, dns, networking, performance
Full corpus
/llms-full.txt
Readable corpus
/source/full-corpus/

Graph links

Related go-dns-scanner-4000qpsedns0-buffer-tuningdns-resolution-full-picture

Tagsgo, dns, networking, performance

TCP Fallback in High-Throughput DNS Scanners — Connection Reuse

Why the TC bit forces TCP, when a naive net.Dial-per-fallback collapses throughput, and how to keep TCP cheap with connection reuse.

/ directed by / / 10 min read
On this page

DNS is a UDP protocol that has always also been a TCP protocol. RFC 1035 §4.2.2 has required TCP support since 1987, and RFC 7766 made it a hard requirement in 2016. For a high-throughput scanner this matters because the moment a response sets the TC (truncation) bit, you are obligated to retry over TCP — and a naive net.Dial per fallback can destroy the throughput a worker-per-goroutine architecture was carefully designed to achieve.

The TC bit is rare for the queries most scanners care about (NS, A, MX on apex names), but it is not zero. Large TXT records, ANY queries against well-known domains, DNSSEC responses, and any AXFR request will hit it. If your design ignores TCP fallback you will silently drop a percentage of answers and not know which ones. If your design handles fallback by opening a fresh TCP connection per truncated query, the long tail of TCP queries becomes the new bottleneck.

What the TC Bit Actually Means

In a DNS response header, one bit in the flags field is TC. When a server’s answer doesn’t fit in the response budget, it sets TC=1, fills the response with as much as fits, and ships it. The client’s contract under the DNS resolution model is straightforward: discard the partial answer and re-ask over TCP.

The response budget depends on what the client signaled:

  • No EDNS0: 512 bytes (the RFC 1035 default). Anything larger truncates.
  • EDNS0 with a UDP payload size: typically 1232 or 4096 bytes. The server honors the smaller of its own buffer and the client’s advertised size.

1232 is the conservative modern default, picked because 1280 (the IPv6 minimum MTU) minus IPv6 + UDP headers leaves 1232 bytes of DNS payload that won’t fragment. Anything past that and you risk a fragmented UDP datagram getting dropped by a middlebox — which looks like a timeout, not a truncation, and is harder to diagnose.

With miekg/dns, EDNS0 is opt-in:

msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
msg.SetEdns0(1232, false) // UDP payload size 1232, no DNSSEC OK

Without the SetEdns0 call you are stuck at 512 bytes and will see TC=1 on more responses than necessary.

When TCP Is Forced, Not Optional

A few cases skip UDP entirely:

  • AXFR (zone transfer): defined as TCP-only in RFC 5936. Don’t try it over UDP.
  • IXFR: usually starts UDP, falls back to TCP, but most resolvers and authoritative servers expect TCP from the first byte.
  • DNS over TLS (DoT): RFC 7858, always TCP on port 853.
  • DNS over HTTPS (DoH): always TCP (HTTPS), separate code path.
  • Responses that are simply larger than the UDP budget: NS sets for TLD-anycast clusters, DNSKEY/RRSIG bundles under DNSSEC, large TXT records (SPF stacks, DKIM, verification tokens).

A scanner that touches DNSSEC validation, that issues ANY queries, or that walks delegations into zones with unusually large NS RRsets will see TCP fallback often enough that the path needs to be fast, not just correct.

RFC 7766: Connection Reuse Is Not Optional Either

RFC 5966 (2010) made TCP support a SHOULD for resolvers. RFC 7766 (2016) upgraded it to a MUST and added the part everyone forgets: TCP connections SHOULD be reused for multiple queries. The RFC is direct about the rationale — opening a fresh TCP connection for every truncated query is expensive (one full TCP handshake = 1 RTT, TLS adds another 1-2 RTT for DoT) and slow enough to dominate the cost of the query itself.

A typical UDP DNS round-trip through a proxy is 50-200ms. Adding a TCP handshake on top is another 50-200ms before the first byte of the query goes out. That is not “a little slower” — it is order of magnitude worse than a UDP query, and it happens on the fallback path, exactly when you want to recover gracefully, not stall.

RFC 7766 §6.2.1 also defines a minimum idle timeout that allows clients to pipeline multiple queries on the same TCP connection. The wire format already supports this: TCP DNS frames each message with a 2-byte length prefix, so a single connection can carry an arbitrary stream of queries and responses with no per-message handshake.

The design rationale follows from the RFC:

  1. Keep a TCP connection open per worker, not per query.
  2. Pipeline queries on the open connection when possible.
  3. Reconnect only on error or idle timeout — never opportunistically.

The Naive Pattern and Why It Kills Throughput

The textbook miekg/dns example for fallback looks like this:

// Don't do this in a hot path.
func resolve(domain string, server string) (*dns.Msg, error) {
c := new(dns.Client)
c.Net = "udp"
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
resp, _, err := c.Exchange(msg, server)
if err != nil {
return nil, err
}
if resp.Truncated {
c.Net = "tcp"
resp, _, err = c.Exchange(msg, server) // brand new TCP dial
}
return resp, err
}

dns.Client.Exchange dials a fresh connection every call. For UDP that is cheap — open a socket, send a datagram, read one, close. For TCP it is a SYN, SYN-ACK, ACK before the query leaves the box, plus FIN/FIN-ACK on close. Through a SOCKS5 proxy that round-trip cost doubles, because the SOCKS handshake also takes a round-trip.

In a scanner with 500 workers running at thousands of queries per second, every TCP fallback under this pattern triggers a fresh proxy-mediated TCP handshake. If 1% of queries truncate, that’s tens of TCP handshakes per second per worker, each blocking the worker for hundreds of milliseconds. The worker’s effective rate collapses from “a few queries per second on a persistent UDP socket” to “one query every half-second whenever fallback triggers.” The overall throughput chart shows a stable UDP baseline with sharp dips wherever truncated responses cluster.

The Pattern That Scales

The fix is the same principle that made the original scanner fast: own the connection, reuse it, never let the hot path dial. Each worker holds two connections — one UDP, one TCP — and picks based on the previous response:

type Worker struct {
proxyAddr string
server string
udpConn *dns.Conn
tcpConn *dns.Conn
client *dns.Client
}
func (w *Worker) resolve(domain string) (*dns.Msg, error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeNS)
msg.SetEdns0(1232, false)
if w.udpConn == nil {
if err := w.dialUDP(); err != nil {
return nil, err
}
}
resp, _, err := w.client.ExchangeWithConn(msg, w.udpConn)
if err != nil {
w.udpConn.Close()
w.udpConn = nil
return nil, err
}
if !resp.Truncated {
return resp, nil
}
// TC=1: retry over the persistent TCP connection.
if w.tcpConn == nil {
if err := w.dialTCP(); err != nil {
return resp, err // return the truncated answer so caller can decide
}
}
tcpResp, _, err := w.client.ExchangeWithConn(msg, w.tcpConn)
if err != nil {
w.tcpConn.Close()
w.tcpConn = nil
return resp, err
}
return tcpResp, nil
}

The TCP connection is lazy — workers that never see a truncated response never pay the handshake cost. The connection persists across queries, so the second and third TC=1 responses on the same worker reuse the same TCP socket. On error the connection is dropped and re-dialed on the next fallback.

Two implementation details matter:

Use ExchangeWithConn, not Exchange. The latter dials per call. The former takes a *dns.Conn the caller owns and writes/reads on it directly. This is the same pattern that keeps the UDP hot path fast; the only difference for TCP is that the kernel maintains TCP state across queries.

Set a deadline per query, not per connection. dns.Conn wraps a net.Conn, and you can set read/write deadlines per exchange. This prevents a single slow query from wedging the worker forever without forcing a reconnect.

w.tcpConn.SetDeadline(time.Now().Add(2 * time.Second))
tcpResp, _, err := w.client.ExchangeWithConn(msg, w.tcpConn)

Pipelining: The Optimization That Usually Isn’t Worth It

RFC 7766 allows multiple outstanding queries on one TCP connection, identified by the DNS transaction ID. In principle a worker could fire ten queries on the same TCP socket and wait for ten responses in any order.

In practice, the worker-per-goroutine model with no shared state already gives you parallelism across workers, and pipelining inside a worker adds back exactly the kind of correlation logic that the shared-nothing architecture was designed to avoid: a map from transaction ID to pending request, a response demultiplexer, and a notion of “which goroutine reads from the TCP socket.”

The order of magnitude estimate: with 500 workers each holding their own TCP connection, you already have 500 in-flight queries’ worth of TCP parallelism without writing a single line of correlation code. The marginal gain from pipelining within a worker is small, and the complexity cost is large. Skip it unless you have measured evidence that TCP fallback is the binding constraint.

How massdns and zdns Differ

massdns has no TCP fallback at all. It is a UDP-only stub resolver by design. If a response truncates, massdns reports what it got and moves on. The rationale: at 350,000 queries/second on a single thread with epoll, adding TCP state machines would compromise the design. For workloads that tolerate some truncation loss (A-record scanning, large-scale enumeration where partial NS answers are fine), this is a defensible tradeoff. For workloads that need correct answers, it’s a non-starter.

zdns implements full TCP fallback. Its dns.Resolver honors the TC bit, retries on TCP, and uses miekg/dns under the hood. The shared-nothing goroutine model means each worker carries its own resolver state, which generalizes cleanly to also carrying its own TCP connection. The order-of-magnitude penalty for a fresh TCP dial per fallback is roughly the same in zdns as it would be in a custom scanner — the architectural fix (persistent per-worker TCP connection) applies identically.

The contrast is the design rationale clearly: massdns optimizes for a workload where TCP fallback is acceptable to skip; zdns optimizes for correctness on a workload where it isn’t. A scanner that needs both raw speed and correct handling of large answers has to do the work zdns does, which means caring about how the TCP path is structured, not just whether it exists.

What to Measure

The signals that tell you TCP fallback is working as designed:

  • Truncation rate: fraction of UDP responses with TC=1. Low single-digit percent for most NS scans, higher for ANY or DNSSEC workloads.
  • TCP retry success rate: of the truncated responses, how many succeed on TCP retry. Should be very high; if it isn’t, the TCP path is broken (proxy refuses TCP, server blocks TCP from your source, firewall in the way).
  • TCP connection age: how long the per-worker TCP connection lives between reconnects. Short lifetimes mean you are paying handshake cost more often than necessary — investigate whether the server is closing idle connections aggressively (RFC 7766 allows it) and tune accordingly.
  • TCP query latency vs UDP query latency: TCP should be a fixed multiplier slower (handshake amortized over many queries), not an order of magnitude slower. If it is order of magnitude slower, the connection isn’t actually being reused.

The point of measuring these isn’t to chase a number — fabricated QPS targets for TCP fallback are mostly meaningless because they depend on the truncation rate of your specific workload. The point is to confirm the architecture is doing what it was designed to do: amortizing handshake cost across many queries, not paying it per query.

Summary

TCP fallback is a correctness requirement (RFC 1035, RFC 7766) and a performance trap. The trap is naive net.Dial-per-fallback, which converts a 50ms UDP query into a 200ms TCP query through a proxied handshake. The fix is the same principle that drives the rest of the scanner architecture: each worker owns a persistent connection, reuses it across queries, and reconnects only on error. The UDP hot path stays untouched. The TCP cold path becomes warm. The TC bit stops being a cliff in the throughput graph.

Sources

Diagram

Drag to pan · scroll or pinch to zoom · Esc to close