Skip to content
AtomicReps

Resolution is a protocol, not a lookup

A lesson from Wire Protocols & Applied Cryptography. Play it above, or read it through below.

  • TTL is a ceiling. Nothing schedules the flip.

    The common read: The TTL is 300, so five minutes after I change the record every cache re-reads it, and a cutover is an event I can plan a maintenance window around.

    Nothing in DNS pushes.

    RFC 2181 settles what a TTL is in one clause, that it "specifies a maximum time to live, not a mandatory time to live", so the number you publish is permission to hold an answer for that long and never an instruction to re-ask the moment it elapses; a cache that fetched your record one second before you changed it is entitled to serve the old address for the whole window, and a cache that fetched it thirty seconds earlier is on a different clock for the same name.

    There is also no single cache to reason about. RFC 9499 gives separate definitions to the stub resolver, the recursive resolver, the full-service resolver and the forwarder, and one request from a browser inside a container behind a corporate forwarder passes through one of each, every one of them holding its own copy with its own expiry, and you own maybe two.

    Resolution succeeding is not evidence that any of them is fresh. RFC 8767 lets a recursive resolver answer from expired cache while the authoritative servers are unreachable, re-advertising a deliberately short TTL, so "DNS was fine during the incident" can describe a resolver serving data your zone had already stopped publishing.

    That arrives as "the cutover finished twenty minutes ago and one percent of orders are still landing on the retired origin", filed against sticky sessions, or against the load balancer, or against the engineer who ran the migration, in a room watching a graph that flattens near zero and then refuses to touch it.

    Lower the TTL days before you need it, keep the retired origin serving until the tail is actually empty rather than until the number says it should be, and price the tail honestly: you are not waiting for propagation, you are waiting out the longest clock owned by someone who has never heard of your deploy.

  • Nothing is an answer, and it is cached on a clock you did not set.

    The common read: Nothing existed at that name, so nothing was cached, and the record I just created is live the moment the provisioning API returns.

    The absence was cached, and it was cached before your record existed.

    A resolver told that a name does not exist writes that down as data: RFC 2308 makes the SOA the source of the lifetime, taking "the minimum of the MINIMUM field of the SOA record and the TTL of the SOA itself", and redefines SOA MINIMUM outright as "the TTL to be used for negative responses", so the number deciding how long your new name stays broken sits in a record most application engineers have never opened and certainly never set.

    The same document advises 1 to 3 hours in practice and calls values over a day problematic, which puts the realistic exposure of one premature check at a coffee break and the pathological one at a working day.

    RFC 8020 then widens the blast radius past the name you asked for: on NXDOMAIN "all names and resource record sets (RRsets) at or below that node SHOULD be considered unreachable", so a miss on one label is legitimately a miss on everything under it, including the child you are about to create. Retrying buys nothing, because the retry is answered out of the same cache entry and never reaches your zone at all.

    That arrives as "the ACME challenge will not validate, the record is definitely there, I am looking at it in the console", filed against the certificate tooling or the provider's API, closed by the CI run that finally passed on the fourth attempt an hour later, and never once connected to the readiness probe that asked thirty seconds too early.

    Order the operations so the name exists before anything is allowed to ask for it, delete the check that runs first, and choose the order with both bills in front of you: create-then-delete costs you an overlap window and a stale answer, delete-then-create costs you an outage whose length a field in your SOA already decided.

  • One name is not one query.

    The common read: A hostname costs one query, so the same call costs the same one query wherever it runs, and a resolver that hangs means the network is down.

    A name is not a query, it is a query plan. glibc reads ndots from /etc/resolv.conf, where the manual records the default as 1, and applies the rule flatly, that queries having fewer than ndots dots will be attempted using each component of the search path in turn; Kubernetes ships pods with options ndots:5 and a three entry search list, so api.stripe.com carries two dots, loses the comparison, and is asked as three cluster-local names that cannot exist before it is ever asked as itself.

    Then double all of it, because the same manual records that "glibc performs IPv4 and IPv6 lookups in parallel since glibc 2.9", which is why single-request exists as an option to switch that off; RFC 8305 then governs what the client does with the pair once it is establishing a connection, sending the AAAA query "immediately followed by the A query", declining to wait for both, and allowing a Resolution Delay whose recommended value is 50 milliseconds for the case where A answers first.

    None of that costs anything while the nameserver answers, including when it answers that the name does not exist. The cost appears when one swallows the query instead of refusing it, and then the manual's own two constants take over, and they are not the same kind of number: timeout is 5 seconds of waiting "before retrying the query via a different name server", and attempts is 2 passes over the nameserver list before the resolver gives up.

    The product of those against every name in the plan and both families is not stated here, because the source pack marks the arithmetic MEDIUM and did not verify it beyond glibc; what is safe to say is the shape, seconds rather than milliseconds, and multiplying rather than adding once.

    That arrives as a p99 that sits on a cliff instead of a tail, filed against the payment vendor, because "Stripe is slow from the cluster" is the only sentence the graph supports, and closed with a retry budget that doubles the number of queries the cliff is made of.

    Put the trailing dot on the names that leave the cluster, or lower ndots for the workloads that mostly talk outward, and know what you are spending: the short in-namespace name that everyone's manifests use is exactly what you are giving up.

  • The answer stopped being an address.

    The common read: A DNS answer carries addresses, so the protocol, the port and anything cryptographic are negotiated later, in TLS and in HTTP.

    The answer is not the address any more, it is the terms. RFC 9460 defines SVCB and its HTTPS specialisation, and its parameter list carries the terms of the connection: alpn and no-default-alpn say which protocols the endpoint speaks, port moves the service off the default, ipv4hint and ipv6hint carry addresses so the client does not have to ask twice, and mandatory lets the zone declare which parameters a client must understand or else ignore the record entirely.

    A client that reads one of these before opening a socket knows the protocol, the port and an address at the moment it learns the name resolves, which is the whole reason a first visit can land on HTTP/3 with no earlier connection to have learned it from. The same record then does a second job that a zone apex has needed since the 1990s.

    RFC 2181 allows a label to hold exactly one CNAME or a set of other records and never both, and an apex must hold SOA and NS, so an apex CNAME is not a registrar policy and never was; the ALIAS and ANAME products that appear to solve it are provider side synthesis, resolving your target from their vantage point and handing you the result, which is precisely the wrong vantage point when the target is routing by geography.

    RFC 9460 answers the same need with a mode of its own instead, AliasMode, whose stated primary purpose is aliasing at a zone apex. That arrives as a ticket titled "CDN will not accept our root domain", filed against the CDN, escalated to the registrar, and closed by enabling a flattening feature nobody priced, after which a slice of European users quietly starts reaching a North American edge and the latency regression is blamed on the CDN.

    Publish the HTTPS record and keep A and AAAA for the clients that do not read it, and then wait for the bug nobody files: the two records drift apart, no check compares them because your monitoring was written against the one that existed in 2015, and the person who eventually notices is someone who ran dig for an unrelated reason.

  • The redirect is gone. So is the encryption you thought you enabled.

    The common read: The connection is set up by my server and my edge, so what my zone publishes cannot remove a redirect or decide whether a name is encrypted.

    Two round trips you still draw on the whiteboard are already gone, and both of them left through DNS.

    RFC 9460 says a client SHOULD look up the HTTPS record and, on finding one, SHOULD behave "as if it has received an HTTP 307 (Temporary Redirect)" to the https origin, so the plaintext hop is not redirected, it never happens; the 301 line stops appearing in the access log and the drop reads as lost traffic rather than as work you no longer do.

    The second round trip is newer and is the reason a DNS record is now a security dependency. Encrypted Client Hello became an RFC in March 2026 and is bootstrapped out of DNS: the specification delegates its publication details to RFC 9460, and a companion RFC published the same month defines the ech parameter carrying the configuration list, filling a key that RFC 9460 had only reserved a slot for more than two years earlier.

    A client that has no configuration does not fail, it SHOULD send a GREASE encrypted-client-hello extension and connect, with the real name in the clear where it always was, and RFC 9849 names an attacker who can "strip the ECH configurations from the response" as a downgrade path in exactly the same way, which means the accident and the attack produce one outcome.

    The companion specification adds the operational half: a record set in which some endpoints carry the parameter and some do not is NOT RECOMMENDED, for the same reason. That arrives as a screenshot of a packet capture in a channel called security, a plaintext name on a connection to a service whose ECH toggle has been green for a month, filed against the TLS terminator, which is doing exactly what it was configured to do.

    Publish the record, then verify the record from outside your own network, and read the two indicators as the two different statements they are: the toggle reports your edge's intent, the resolver reports what a client will actually be able to use, and only the second one is checkable by anybody else.

  • The resolver left the OS, and your override went with it.

    The common read: The OS resolver is the one place DNS happens on a machine, so a hosts file entry and the corporate forwarder are still the chokepoint everything passes through.

    The machine no longer has one place where names are resolved. DNS grew three encrypted transports and each of them is a socket your OS stub never opens: DoT on port 853 over TCP, DoH over HTTPS carrying application/dns-message, and DoQ on 853 over UDP. Then it grew two ways for one of them to be selected without you.

    DDR has a client send an SVCB query to _dns.resolver.arpa against the plaintext resolver it was already given, discovers that the same operator runs an encrypted endpoint, and upgrades to it; DNR hands one over through DHCP or a Router Advertisement instead and takes precedence over DDR when both are available. For an application engineer the consequence is not privacy, it is the loss of a chokepoint you have been relying on since before you could drive.

    An entry in /etc/hosts binds the processes that call the OS resolver and nothing else, so a browser carrying its own resolver has already left, and so has any split-horizon answer, since a view is selected by the source address of the query and the query is no longer coming from where you think.

    That arrives as "the staging override works in curl and not in the browser", filed against the frontend build, and in its expensive form as VPN users reaching the public address of an internal service and timing out, escalated as a VPN routing problem by people who have already checked that the name resolves and got a correct answer for the wrong view.

    Stop treating a hosts file or an internal view as an enforcement point and start treating it as a default that some clients will accept, and count what you have left: the network path and the client's own configuration, neither of which is the resolver you run, and both of which cost more to change than a zone edit ever did.