A home hostname takes half a second to resolve, and the resolver in front of it says 0.3 ms

The self-hosted Tailscale relay at home kept flapping and its hostname “resolved slowly”. The ad-blocking resolver on the LAN answered that name in 0.3 ms every single time, so the resolver was blamed. The real problem was three hops away: the hostname ended in a CNAME to the router’s vendor DDNS service, whose authoritative servers sit in Latvia with a 60-second TTL and one of the two dead. The fix was to stop using the vendor DDNS and publish the ISP address from a one-line cron job at home.

Symptom

Home services are published under one domain on Cloudflare. Every name was a grey-cloud CNAME chain ending at the router’s cloud DDNS record (MikroTik /ip cloud, <id>.sn.mynetname.net):

relay.example.com  CNAME  isp.example.com
isp.example.com    CNAME  <id>.sn.mynetname.net
<id>.sn.mynetname.net  A  <home public IPv4>   TTL 60

Two things were observed on 2026-09-10:

$ dig @<lan-resolver> relay.example.com A | grep 'Query time'
;; Query time: 1 msec

The query log of that resolver, three days, 1291 queries for the relay name: p50 0.35 ms, p90 0.5 ms, worst 191 ms, every one of them a cache hit. Container logs: zero errors in two days.

Why it is easy to misdiagnose

Everything on the LAN looked perfect because the LAN resolver was hiding the problem on purpose. Its config had cache_ttl_min: 600 and cache_optimistic: true: the upstream 60-second TTL was stretched to ten minutes, and after expiry the stale answer was served immediately while a refresh ran in the background. Nobody behind that resolver could ever see a slow lookup.

But the clients that mattered were not behind it. Tailscale nodes with accept-dns resolve through 100.100.100.100 straight to the tailnet’s global nameserver (1.1.1.1), and the Tailscale daemon resolves DERP hostnames the same way. Those clients hit the public resolvers, and the public resolvers hit the authoritative servers every 60 seconds.

The second trap was the round-trip time to the public resolvers themselves. All three were 11 to 12 ms away, so “the resolver is slow” was easy to rule out, and “the name is slow” was easy to rule in without asking which hop.

Root cause

The last hop of the chain, <id>.sn.mynetname.net, is served by MikroTik’s cloud DNS with a 60-second TTL. Its authoritative servers are ns1.kissthenet.net and ns2.kissthenet.net. From the US East coast ns1 answers in about 135 ms; ns2 does not answer at all, over UDP or TCP, over IPv4 or IPv6:

$ dig +norecurse @ns2.kissthenet.net <id>.sn.mynetname.net A
;; communications error to <ns2 address>#53: timed out
;; no servers could be reached
$ dig +tcp +norecurse @ns2.kissthenet.net <id>.sn.mynetname.net A
;; Connection to <ns2 address>#53 ... failed: timed out.

So every public-resolver cache miss cost at least one 135 ms trip to Latvia, and whenever the resolver picked ns2 first it also paid a dead-server timeout before retrying ns1. With a 60-second TTL that happens roughly once a minute per resolver instance, and Cloudflare’s resolver is many independent instances per PoP, each with its own cache, so for a low-traffic name nearly every query was a miss.

Proof

Measured from the home server. Every command below is a cache miss by construction: a random label under the vendor’s zone forces the resolver to go to kissthenet.net (the answer is NXDOMAIN, the latency is the same as for the real name).

for u in 1.1.1.1 8.8.8.8 9.9.9.9; do
  printf '%-9s ' $u
  for i in $(seq 1 20); do
    dig +tries=1 +time=6 @$u "p$RANDOM$i.sn.mynetname.net" A | awk '/Query time/{printf "%s ",$4}'
  done; echo
done
Resolver 20 cache-miss lookups (ms) Reading
1.1.1.1 155 150 522 149 152 150 150 150 147 155 534 151 151 551 526 152 151 524 520 153 7 of 20 hit the dead ns2 first: ~375 ms timeout, then retry
8.8.8.8 151 145 149 151 146 148 149 148 152 147 143 149 151 151 148 149 150 151 150 150 never touches ns2; Google’s server selection has demoted it
9.9.9.9 263 251 235 257 249 266 250 251 248 267 245 242 265 249 284 247 245 234 245 1749 slow baseline plus one multi-retry outlier

The same three resolvers on twenty ordinary domains, for scale (median / max, ms):

Resolver warm (cached) cold (random subdomain, NXDOMAIN from the real authority)
1.1.1.1 14.5 / 29 18 / 44
8.8.8.8 15 / 476 23.5 / 69
9.9.9.9 14 / 73 17 / 74

So the resolvers were fine. The 150 to 550 ms was entirely the vendor DDNS hop, and Cloudflare’s resolver was not learning its way around the dead server: three consecutive queries for the real record came back with a full 60-second TTL each time, i.e. three misses in a row on three different cache instances.

Two side findings from the same session, kept here because they will come up again:

Fix

Take the vendor DDNS out of the chain: make the ISP-link name a plain A record on Cloudflare, maintained from a host inside the home. Cloudflare’s authoritative servers answer from the nearest anycast site (16 ms from here) and there is no dead secondary.

The home server already had a clone of timothyjmiller/cloudflare-ddns in a root cron job running every minute. It had never written a record. Its config pointed at a name that was itself a CNAME, and the Cloudflare API refuses an A/AAAA next to a CNAME:

➕ Adding new record {'type': 'AAAA', 'name': 'host.example.com', ...}
😡 Error 81054: A CNAME record with that host already exists.

Cron mailed that to a local mailbox nobody reads, once a minute, for months. A silent failure with a perfect “job completed” line in the scheduler log.

Repointed config, three settings worth calling out:

{ "subdomains": [{ "name": "isp", "proxied": false }],
  "a": true, "aaaa": false, "purgeUnknownRecords": false, "ttl": 300 }

Then the swap. The API will not let both exist, so delete the CNAME and run the updater immediately:

DELETE /zones/<zone>/dns_records/<cname-id>
/root/venv/bin/python3 cloudflare-ddns.py
➕ Adding new record {'type': 'A', 'name': 'isp.example.com', 'content': '<home IPv4>', 'ttl': 300}

Window between the two: 1.8 s, covered by downstream caches.

Verification, same names, same resolvers, minutes later:

Query Before After
relay via 1.1.1.1, five consecutive lookups 150 to 550 ms 16 18 16 19 18 ms
relay via 8.8.8.8 / 9.9.9.9 150 / 250 ms 18 / 27 ms
whole chain, answered by Cloudflare’s authority directly one hop external 16 ms, single response carrying every CNAME
HTTPS to three services from a VPS on another continent worked, slow DNS 200 / 204 / 401, DNS 20 to 30 ms

While there, the remaining records that still pointed at the vendor DDNS (a game server’s CNAME and an SRV record whose only job was that redirect) were moved under the same tree, and the tree was given explicit layers so the next change is one record: ISP link (A, DDNS-maintained) → location (CNAME) → host (CNAME) → service (CNAME). Swapping ISPs means adding one A record and repointing the location name.

Lessons