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:
- The Tailscale daemon on the home server logged a
DNS lookup error for "relay.example.com"a few hundred times a day, and its home DERP region bounced between the self-hosted relay and two official regions 5 to 18 times a day. - Querying the LAN resolver (AdGuard Home, upstream of the router’s DNS) for the same name was instant:
$ 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:
- The rumour that “8.8.8.8 gives you better CDN IPs” is about EDNS Client Subnet. Google and Quad9’s
9.9.9.11send your /24 to the authority;1.1.1.1and9.9.9.9do not. On eight CDN-hosted names the returned IPs differed but the RTT winner alternated (Apple 12 vs 28 ms in Google’s favour, a Chinese CDN 40 vs 65 ms in Cloudflare’s), because the Cloudflare PoP was already next door. ECS only matters when the resolver is far from you. - The LAN resolver was already configured
upstream_mode: parallelacross all three; its own log showed Quad9 winning most races for cached names. Nothing to change there.
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 }
aaaa: false. The script detects IPv6 by its own egress address, which is the server’s SLAAC address. The ISP-link name stands for the whole house; IPv6 has no NAT; pointing it at one machine is wrong for every other port. (The old AAAA on this name pointed at the router’s WAN address and had never worked for anything: the router’s input chain refuses 443. Verified from an IPv6-capable vantage point.)purgeUnknownRecords: false. In this script that flag means “if the public IP cannot be detected, delete the records”. For the root of the whole name tree, one hiccup reaching1.1.1.1/cdn-cgi/tracewould take the house offline.ttl: 300instead of the vendor’s 60.
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
- When a name is “slow”, measure per hop, not per resolver.
dig +norecurseagainst each authoritative server in the chain takes a minute and would have ended this on the spot. - A caching resolver in front of you can hide a broken authority completely.
cache_ttl_minplus optimistic caching means the LAN sees 0.3 ms while every outside client sees the truth. Check who is actually asking whom before trusting a local measurement. - Vendor DDNS is a dependency with its own SLA. A 60-second TTL served from one working nameserver on another continent is a latency tax on every lookup of everything under it. If the address is already detectable from inside the network, publish it into the DNS you control.
- Different public resolvers fail differently. Google routed around the dead server; Cloudflare kept hitting it a third of the time. That is not a reason to switch resolvers, it is a reason to fix the zone.
- A cron job that has “completed” every minute for months can still have never done anything. If its output goes to local mail, nobody will know. Read the output once after deploying it.
- Before enabling a “purge stale records” option, read what the script means by stale. Here it meant “delete on detection failure”.