Networking & APIs· 1 of 13
DNS
Every answer is a lease you cannot call back. The numbers on the lease decide how you migrate, how you fail and how your load spreads.
The question
Section titled “The question”Your DNS provider stops answering for an hour. Users of a name with a one-day TTL never notice. Users of a name with a five-minute TTL are all failing by minute five, unless their resolver serves stale answers. Now a different bug: the provider stays up but publishes the record with no addresses. The failure spreads at exactly the same speed, serve-stale does nothing, and after you fix the record, users keep failing for another quarter of an hour. Why do these outages behave so differently, and which numbers decide it?
The answer is that a DNS answer is a lease: a copy that a cache may keep for a fixed time, that you cannot revoke, and that every layer between you and the user may shorten, stretch or ignore. The next section sets out what a lease is made of, in one pass; the rest of the page is about what happens to those leases during migrations, outages and load balancing.
Why this problem exists
Section titled “Why this problem exists”DNS data lives in zones. A zone is the part of the name tree that one operator runs from one set
of servers: example.in and every name under it. Each piece of data in a zone is a record,
written one per line with five fields:
shop.example.in. 300 IN A 192.0.2.10; name TTL class type valueThe type says what the value means: A is an IPv4 address, AAAA an IPv6 one, CNAME says “this
name is an alias, ask for that other name instead”, NS names the servers that run a zone, SOA
holds the zone’s own settings and TXT is free text. The class is always IN. All records with the
same name and type, say three A records for shop, form one set that is fetched, cached and
expired as a unit. The TTL (time to live) is how many seconds anyone who receives that set may
keep using it without asking again.
Two kinds of server handle records. An authoritative server holds a zone’s records and answers
only for its own zones; it never asks anyone else. A recursive resolver holds no zones: it is
the server your machine is configured to ask (your ISP’s, 1.1.1.1, 8.8.8.8, CoreDNS in a
cluster), and it finds answers by asking authoritative servers and caching what it learns. Your
program talks to neither directly. It calls the operating system’s getaddrinfo(), a small library
(the stub resolver) that sends the question to the resolver listed in /etc/resolv.conf and
returns the addresses.
A resolver with an empty cache, asked for shop.example.in, starts at the root, whose server
addresses it ships with, and follows referrals: replies that say “not mine, ask these servers”.
| Step | Resolver asks | Reply |
|---|---|---|
| 1 | a root server | a referral: “ask the .in servers” |
| 2 | a .in server |
a referral: “ask ns1.example.in, at 192.0.2.53” |
| 3 | ns1.example.in |
the answer: 192.0.2.10, TTL 300 |
Referrals are cached too, for their own TTLs (the root hands out its referrals with 172,800 s, two
days), so the next lookup for any .in name starts at step 2. A second user who asks the same
resolver for shop.example.in 120 seconds later gets the answer from memory, with a TTL of 180: a
resolver hands on only the time its copy has left. That user waits only for the round trip to the
resolver (12–20 ms from our test network to 1.1.1.1, against 105–141 ms for a miss), and ns1
never hears about them, or about the next ten thousand, until the 300 seconds run out. That cache is
the whole reason DNS scales, and it means the authoritative server only ever sees cache misses.
So one number sets the load on your name servers. Each cache asks at most once per TTL, so a popular name costs about number of caches ÷ TTL queries. With 5,000 resolver caches asking for your API’s name:
| TTL | Queries reaching your name servers |
|---|---|
| 86,400 s (1 day) | about 3.5 per minute |
| 300 s | about 1,000 per minute, 17 per second |
| 60 s | about 83 per second |
The same number also sets two other things. It is the upper bound on how long a change takes to reach well-behaved caches, and it is how long users survive when your name servers are unreachable, because cached copies are then all that keeps the name alive. A short TTL buys fast changes and pays for them in query volume (often billed per million) and in a shorter survival time. There is no TTL that is good at all three; you pick per record.
“5,000 caches” also hides something. A public resolver address is anycast: one address served
from many cities, and behind each city many independent cache machines. They fetch at different
moments, so the same name shows a different remaining TTL depending on which one you hit. When we
asked three public resolvers for example.com at the same moment in October 2026, they reported
264, 81 and 300 seconds left. Caches also evict early and are split by user location: a
measurement study by Moura and colleagues found that for about 30% of clients the cache gave no
protection at all during an outage (IMC 2018).
The obvious model, and why it breaks
Section titled “The obvious model, and why it breaks”The model most engineers carry is: the TTL is how long a change takes to reach users. Lower the
TTL when you change a record and the change arrives quickly. Code written with that model either
resolves once at startup or resolves on every request. Both clients below talk to a small
authoritative server we wrote for this page, serving the made-up zone shop.example.test:
# runnable version: examples/dns/python/naive_client.pyclass ResolveOnceClient: """Naive 1: look the name up when the process starts, then never again."""
def __init__(self) -> None: self.address, ttl = resolve(API) # the TTL is right here, and we throw it away print(f"t=0.0s [ once] startup: {API} -> {self.address} (TTL {ttl} s, ignored)")
def address_for_request(self) -> str: return self.address
class ResolveEveryTimeClient: """Naive 2: ask DNS before every request. Always fresh, and a lookup on every request."""
def __init__(self) -> None: self.lookups = 0
def address_for_request(self) -> str: self.lookups += 1 return resolve(API)[0]// runnable version: examples/dns/js/naive-client.mjs/** Naive 1: look the name up when the process starts, then never again. */class ResolveOnceClient { static async create() { const client = new ResolveOnceClient(); const [address, ttl] = await resolve(API); // the TTL is right here, and we throw it away client.address = address; console.log(`t=0.0s [ once] startup: ${API} -> ${address} (TTL ${ttl} s, ignored)`); return client; } async addressForRequest() { return this.address; }}
/** Naive 2: ask DNS before every request. Always fresh, and a lookup on every request. */class ResolveEveryTimeClient { lookups = 0; async addressForRequest() { this.lookups++; return (await resolve(API))[0]; }}The record has a 2-second TTL. At 0.9 s it moves to a new server; at 3.6 s, well past one TTL, the old server is switched off:
t=0.0s [ once] startup: api.shop.example.test. -> 203.0.113.10 (TTL 2 s, ignored)t=0.9s operator: new server is up, api now points to 198.51.100.20t=3.6s operator: waited longer than the TTL, switching off the old server 203.0.113.10t=3.6s [ once] request 9 -> 203.0.113.10 FAILED: connection refused (nothing runs there now)t=3.6s [every] request 9 -> 198.51.100.20 200 OK
resolve-every-time made 10 DNS lookups for 10 requests; the server answered 11 queries in total.Resolve-once fails for ever, and its log says “connection refused”, which sends people to debug the network. Resolve-every-time is correct and puts a DNS round trip in front of every request. But the model behind both is wrong in four more ways, and the rest of the page is about them:
- The TTL that matters is the one already cached. Lowering it in the same edit as the change does nothing for copies fetched before the edit.
- Layers rewrite the TTL. Resolvers cap it, runtimes replace it with their own number.
- Connections outlive DNS. A pooled or HTTP/2 connection never asks DNS again until it closes.
- “No” is cached too, and so are failures. A name looked up before it existed, or during a bad publish, stays broken after the fix.
The core idea
Section titled “The core idea”Every DNS answer is a lease with a countdown. Whoever receives it may hand it on with less time left, cut the time short, stretch it, or ignore the countdown entirely, and nobody can call it back early. So the useful question is never “what is my TTL?” but “what is the longest any holder of this answer might keep it?” Plan migrations, failovers and outages around that number.
Follow one answer from the source to your code. The number on the right is how long that layer will keep using it:
The authoritative server’s TTL is only the first row. The rows below it are set by software you may not control, and the bottom row is not DNS at all. That is why “we lowered the TTL to 60 seconds” so often ends with traffic on the old server an hour later.
How it works, piece by piece
Section titled “How it works, piece by piece”What each layer does to a TTL
Section titled “What each layer does to a TTL”Resolvers cap TTLs, in both directions. Resolver software clamps positive and negative TTLs to configured limits. A negative TTL is how long a resolver keeps a “no such name” answer, and serve-stale means answering from an expired copy when the authoritative servers cannot be reached; each has its own section below. Defaults, as of October 2026:
| Resolver | Longest positive TTL kept | Longest negative TTL kept | Serve-stale |
|---|---|---|---|
| BIND 9 | 604,800 s (7 days), max-cache-ttl |
10,800 s (3 h), max-ncache-ttl |
off (stale-answer-enable no) |
| Unbound | 86,400 s (1 day), cache-max-ttl |
3,600 s (1 h), cache-max-negative-ttl |
off (serve-expired no) |
Sources: the BIND 9 reference and the
unbound.conf manual.
Both also have a minimum TTL option (min-cache-ttl, cache-min-ttl), off by default, which
some operators turn on to cut load. A resolver with a 300-second minimum turns your careful 60-second
TTL into 300 seconds, and you will never see it in your own tests.
Runtimes replace the TTL with their own number. The standard getaddrinfo() call returns
addresses and no TTL at all, so anything built on it has to invent one:
- Java caches successful lookups for 30 seconds and failed ones for 10, whatever the record
says (
networkaddress.cache.ttlandnetworkaddress.cache.negative.ttl); older versions running a security manager cached for ever (InetAddressCachePolicy). - Chrome keeps an answer from the OS resolver for 60 seconds, because the OS never told it the TTL; with its built-in resolver it uses the TTL, with a 60-second floor (Eric Lawrence, 2022).
- Node’s
dns.lookup(), whichfetchandhttpuse, keeps nothing and runsgetaddrinfo()on libuv’s four-thread pool, so slow DNS can stall unrelated file and crypto work.dns.resolve4()with{ ttl: true }asks the network and returns real TTLs (Node docs). - glibc keeps nothing either; a machine caches only if it runs a local daemon such as
systemd-resolvedornscd.
Connections ignore DNS completely. A database pool, an HTTP keep-alive connection, an HTTP/2
or gRPC channel resolves once when it connects and then never again. A service with a pool of 20
connections that live for hours keeps talking to the old address for hours, even if every cache
above it switched within a minute. In a migration, the old server must close its connections (an
HTTP/2 GOAWAY, a pool’s maximum lifetime, a restart) before the clients will look the name up
again. Watch the old server’s open connections, not the clock.
Changing a record: the old TTL decides
Section titled “Changing a record: the old TTL decides”When you change a record, nothing is pushed anywhere. Each cache keeps serving its copy until that copy expires, then fetches the new value at the next lookup. The copies were fetched at random moments within one TTL, so their expiries are spread evenly over the next full TTL. “Propagation” is that spread of independent timers.
That gives the arithmetic. With a TTL of 3,600 s, if you switch the old server off 10 minutes after the change, about 50 of every 60 minutes of cache lifetime are still ahead: roughly five caches in six still point at the old server. Lowering the TTL in the same edit changes nothing, because the copies already out there carry the old TTL. The only thing that helps is lowering it at least one old TTL before the change, so that by the moment of the change every copy in the world is a short one.
Try both in the simulation. The defaults lower the TTL in the same edit as the address change; then slide the lead time up.
The lead time is the whole trick. At zero, the sim switches the old server off with 176 of 200 caches still pointing at it, and 28% of all lookups in the next 80 minutes fail; the last cache switches an hour after the change. With a lead of 60 minutes, which is one old TTL, every cache holds a 60-second copy by the time of the change, the last one switches within about two minutes, and the only failures come from the 2% of clients that never resolve again. A lead of 30 minutes is half a fix: 68 caches still on the old address, 6% of lookups failing.
The price is in the query counter: with 60-second copies, the same caches send about fifty times as many queries. That is why the TTL goes back up once the move is done. The full plan:
- One old TTL ahead (plus margin for resolvers that impose a minimum), lower the TTL to 60 s.
- Change the address. Keep the old server running.
- Ask your authoritative server directly (
dig @ns1.your-provider.example api.example.in) to confirm the change, and at least two public resolvers to see what users get. - Make the old server close idle connections, then wait until its traffic is near zero. The tail is pinned clients: find them in its access log.
- Switch it off and raise the TTL again.
Changing name servers is slower still. Look inside the referral from step 2 above. A parent
zone does not store your records. Where example.in begins, the .in zone stores only a
delegation: NS records naming the servers that run the child zone. When those servers have
names inside the child zone, there is a loop: to find the address of ns1.example.in you must ask
the example.in servers, which means ns1. So the parent also hands out their addresses, called
glue. (A zone hosted at a DNS provider names the provider’s servers, which live in the
provider’s own zone, so it needs no glue.) The delegation therefore lives in two places: the parent
zone holds NS records that point to your servers, and your own zone holds its own copy:
The .com servers hand out delegations with a TTL of 172,800 seconds, two days, so a resolver
that learned the old delegation an hour before your switch can keep asking the old provider for 47
more hours. Most resolvers then prefer the child zone’s own NS records once they have asked it
(they are child-centric, as Moura and colleagues measured in
Cache Me If You Can, IMC 2019), so the old provider’s
copy of your NS records can extend that further. Keep the old provider serving an identical zone
for at least two days, and update the NS records in both zones.
Negative caching: “no” is cached, and so is “I could not find out”
Section titled “Negative caching: “no” is cached, and so is “I could not find out””Every DNS reply starts with a header carrying a result code, the way an HTTP response carries a status, followed by sections, among them the answer (records for the question) and the authority (records about who is responsible). Three codes matter here: NOERROR (0), SERVFAIL (2, “I could not get an answer”) and NXDOMAIN (3, “no such name”). A resolver that asks for a name that does not exist gets one of two negative answers:
- NXDOMAIN (result code 3): the name does not exist, for any type.
- NODATA: the name exists but has no records of the type asked for. There is no code for it: the answer is NOERROR with an empty answer section.
Caching “no” exists because most of these questions repeat: typos, scanners, retries, and the search-suffix lookups covered under Kubernetes below would otherwise all be cache misses. A “no” has no record to carry a TTL, so it borrows one from the zone’s SOA (start of authority) record. Every zone has exactly one SOA, at its top, holding the zone’s settings, and the authoritative server puts it in the authority section of every negative answer:
;; ->>HEADER<<- status: NXDOMAIN;; ANSWER SECTION: (empty);; AUTHORITY SECTION:example.in. 3600 IN SOA ns1.example.in. hostmaster.example.in. 2026102301 7200 900 1209600 300; SOA TTL 3600, then: primary server, contact, serial, refresh, retry, expire, minimum (300)Serial, refresh, retry and expire tell secondary servers when to copy the zone. The last field is
the minimum, and the resolver caches the “no” for the smaller of the SOA record’s TTL and that
minimum (RFC 2308): here min(3,600, 300) = 300 seconds,
capped by its own limit from the table above. Many real zones use far longer values: in October
2026, example.com allowed a negative answer to be cached for 1,800 s and wikipedia.org for
3,600 s. Our toy zone uses 5 seconds; the demo asks for a name a moment before the operator
creates it:
t=4.1s lookup beta.shop.example.test.: NXDOMAIN, cached for 5.0 s moret=4.1s operator: created beta.shop.example.test. -> 198.51.100.30t=5.6s lookup beta.shop.example.test.: still NXDOMAIN, cached for 3.5 s moret=7.1s lookup beta.shop.example.test.: still NXDOMAIN, cached for 2.0 s moret=8.6s lookup beta.shop.example.test.: still NXDOMAIN, cached for 0.5 s moret=10.1s lookup beta.shop.example.test.: 198.51.100.30Two less known rules make negative answers reach further than the name you asked for:
- An NXDOMAIN covers everything below it. If
beta.example.indoes not exist, nothing under it can, so a resolver may answer NXDOMAIN forapi.beta.example.infrom that one cached answer (RFC 8020). - In a signed zone, one “no” covers a range of names. DNSSEC, covered at the end of this
section, signs records so a resolver can check they came from the zone’s owner. A signature can
only cover data that exists, so to sign a “no” the zone sorts its names and signs the gaps
between them as NSEC records. A zone holding
alpha,gammaandzulupublishesalpha NSEC gamma(“there is nothing betweenalphaandgamma”) andgamma NSEC zulu, and a question aboutbetagets the first one as its proof. Resolvers that use those proofs aggressively (RFC 8198; on by default since BIND 9.12, per APNIC) answer NXDOMAIN for any name in that range without asking:beta,deltaandfoxall come from that one cached record. Createbetaand a resolver that cached thealpha–gammaproof can deny it to users who never asked for it before.
Failures are cached as well. When a resolver cannot get an answer at all (timeouts, SERVFAIL, a DNSSEC validation failure), RFC 9520 (December 2023) now requires it to remember the failure for at least 1 second and at most 5 minutes, and not to ask again meanwhile. The reason is load: without it, every user lookup turns into fresh queries to servers that are already down. The IMC 2018 study above saw retries raise traffic to the authoritative servers up to eight times during an attack, and Cloudflare reported about 30 times the usual queries for Facebook’s names on its 1.1.1.1 resolver while they were gone in October 2021 (Cloudflare, 2021).
When the authoritative side fails
Section titled “When the authoritative side fails”When your name servers stop answering, nothing breaks at first. Each cache keeps answering from its copy, and fails only when that copy expires and it cannot fetch a new one. Without serve-stale, the share of users failing grows roughly as time since the outage ÷ TTL: all of them after one TTL.
Serve-stale (RFC 8767) changes that: when the
authoritative servers cannot be reached, the resolver hands out the expired copy instead of an
error, and keeps trying to refresh in the background. In numbers: a resolver fetched
api.example.in (TTL 300) at 10:00:00, and your name servers went down at 10:03. At 10:06 a user
asks; the copy expired at 10:05 and the refresh times out. Without serve-stale the user gets
SERVFAIL. With it, the user gets the old address with a TTL of 30 seconds, so the client asks again
at 10:06:30, and the same thing repeats until a refresh succeeds or the stale window runs out; the
RFC suggests keeping expired data for one to three days. It is off by default in BIND and Unbound
(the table above); large public resolvers use some form of it, which is part of why outages look
different from different networks.
The TTL sets how fast the failure spreads; serve-stale sets how much of it users see. With a 300-second TTL and no serve-stale, every cache in the sim is failing by minute five. At 3,600 s, 58% of lookups fail after half an hour and almost all by the end of the hour. Serve-stale on half the resolvers halves the failure, and on all of them hides it completely. And the moment the servers come back, every failing cache recovers on its next lookup.
Now publish bad data instead. On 19–20 October 2025, a race between two of DynamoDB’s DNS
automation workers left the record for dynamodb.us-east-1.amazonaws.com with no addresses: a
slow worker applied a stale plan at the moment a faster one’s clean-up deleted that plan. From 11:48 PM
PDT nothing could resolve the endpoint, including AWS services that depend on DynamoDB. The record
was restored by 2:25 AM, and clients recovered as their cached answers expired, by 2:40 AM
(AWS post-event summary).
An empty answer is a valid answer. Serve-stale only applies when the authoritative servers cannot be reached, so it never kicks in, and the “no addresses” answer is cached as a negative answer for the negative TTL, which outlives the fix:
The spread during the outage is the same as before. The difference is after the fix at +50 min: 83% of lookups are still failing ten minutes later, and the last failure comes about 15 minutes after the fix, one negative TTL. A dead name server is better than a wrong one. The lesson for the code that writes DNS: check that a plan is still the newest one at the moment you apply it, refuse to publish an empty set for a name that has always had addresses, and have a tested way to put a known-good record back by hand.
How resolvers choose a name server
Section titled “How resolvers choose a name server”Every zone lists several name servers, and the resolver picks one per query. How it picks decides what a slow or dead name server costs you.
Resolvers keep a running round-trip time for each server address and mostly prefer fast ones, but not exclusively. Unbound picks at random among all servers within 400 ms of the fastest, assumes 376 ms for a server it knows nothing about, and remembers what it learned for 15 minutes (Unbound docs). With three name servers measured at 20, 90 and 600 ms, the band runs up to 420 ms: queries are spread at random over the first two, and the 600 ms server gets none until Unbound forgets its measurement and tries it again. Measured across the internet, most resolvers send some queries to every listed name server over time, and only about half show a preference for the faster ones (Müller et al., IMC 2017).
Three consequences:
- Every name server must be equally strong. A far-away or small server you added “as a backup” gets a real share of production queries. The study’s advice is anycast behind every listed name server address.
- A dead name server costs latency, not answers. A resolver that tries it waits for a timeout (a few hundred milliseconds) and then tries another; until its RTT data marks the server bad, a share of cold lookups pay that wait. Our resilient resolver below shows the same cost: a dead first upstream added about 400 ms.
- Two providers survive what one cannot. On 21 October 2016 the Mirai botnet flooded Dyn, a large DNS provider, and names that used only Dyn became unreachable for many users for hours (APNIC). Because resolvers already spread queries across all listed name servers and fail over between them, listing name servers from two independent providers, both serving identical records, turns a provider outage into extra latency. The cost is keeping two providers in sync, which gets harder with DNSSEC (both must sign with keys the parent vouches for; see RFC 8901).
DNS as a load balancer, and why it is lumpy
Section titled “DNS as a load balancer, and why it is lumpy”Returning several addresses for one name, or different addresses to different askers, is the cheapest global load balancing there is. Its unit of balancing is the problem: the authoritative server decides once per resolver cache per TTL, and every user behind that cache gets the same answer until it expires.
Two facts make that worse than it sounds. Resolver populations are lopsided: a few large ISP and
public resolvers carry most users. And clients often do not choose at random from the list: glibc’s
getaddrinfo() sorts addresses by RFC 6724 rules, which can put the same address first for every
client in a network regardless of the order DNS sent
(CERN,
glibc bug 24282), and many clients
connect only to the first. Here are four servers behind one name, with resolvers whose sizes fall
off the way real ones do:
Three things to take away. The imbalance at any moment is set by how big the largest resolvers are and how many clients take the first address, not by the TTL: with ten resolvers the busiest server peaks at about 3.5 times its share, and some server is always above 1.5 times. The TTL sets only how long one server stays hot: up to 10 minutes at 300 s, under 2 minutes at 30 s, most of an hour at 1,800 s. And averages lie: over two hours the busiest server looks only 1.19 times busier than fair, so dashboards built on averages miss the overload that matters. Size servers behind DNS balancing for the peak, and put a real load balancer behind each address.
Failover through DNS is slow by construction. A health-checked record is only as fast as its chain: Route 53 checks every 30 seconds and needs 3 failures by default, about 90 seconds (10-second checks are an option), and AWS recommends a 60-second TTL on such records (AWS, as of October 2026). Add Java’s 30 seconds or Chrome’s 60, and open connections that never move, and two to three minutes is the realistic best case.
Geographic answers see the resolver, not the user. An authoritative server that answers by location sees where the question came from:
A user in Chennai whose public resolver reaches you from Singapore is sent to Singapore. EDNS
Client Subnet (ECS, RFC 7871), one of the optional
extras a query can carry (EDNS, explained in the next section), lets the resolver pass on the first
24 bits of the user’s IPv4 address: for a user at 198.51.100.86 it sends 198.51.100.0/24. That
fixes the answer and splits the cache: each answer is now valid only for one subnet, so a resolver
serving users from 1,000 subnets may hold 1,000 copies of one name. One CDN’s measurements found
the cache needed for ECS answers grew by up to an order of magnitude, with hit rates under half of the normal ones
(Al-Dalky, Rabinovich and Schomp, 2019).
Use DNS to pick a region; let a load balancer pick the server.
Size limits: UDP, truncation and TCP
Section titled “Size limits: UDP, truncation and TCP”DNS answers travel over UDP on port 53 by default: one packet for the question, one for the
answer, no connection. The original standard capped UDP answers at 512 bytes, which is plenty for
addresses: an answer with one A record measured 72 bytes in our tests. EDNS(0)
(RFC 6891) is the extension mechanism added later. The
client appends a small pseudo-record, OPT, to its query, and one of its fields says how large a UDP
answer the client can accept; dig prints it as ; EDNS: version: 0, flags: do; udp: 1232, where
do asks for DNSSEC signatures and udp: 1232 is the buffer size. ECS above travels in the same
OPT record.
A bigger buffer has a catch. A packet larger than a link can carry (1,500 bytes on Ethernet, less over tunnels and VPNs) is split into IP fragments that travel separately and are put back together at the end. Lose one fragment and the whole answer is lost. Only the first fragment carries the UDP ports, so many firewalls drop the rest, and an attacker who times it right can substitute a forged later fragment. Since DNS Flag Day 2020 major resolvers advertise 1,232 bytes, a size that never needs fragmenting: IPv6 guarantees that every link carries 1,280-byte packets, minus 40 bytes of IPv6 header and 8 of UDP header.
An answer that does not fit is not sent in part. The server replies with only the header and the TC (truncated) flag, and the client asks again over TCP, which costs at least one more round trip for the TCP handshake. What pushes answers past the line is mostly DNSSEC, the next section: a zone’s public keys (its DNSKEY set) are large with RSA keys, and while a key is being replaced the old and new keys are published together.
The A record and the toy TXT record are our own measurements; the DNSKEY sizes are APNIC’s (Huston, 2021). Our resilient resolver hits the TXT case against the toy server:
t=0.4s 2. an answer too big for UDP UDP answer from 127.0.0.1:8053 was truncated (39 bytes, TC=1); retrying over TCP TCP answer: 2835 bytes, 12 records got 12 TXT strings, 2388 charactersTCP is not optional: RFC 9210 (2022) requires every resolver and server to support it and every network to allow it. A firewall that blocks TCP port 53 breaks only the big answers, typically DNSSEC-signed ones and domains with many verification TXT records, which makes it one of the more confusing partial outages. Keep hot names small: a handful of addresses, no long TXT records on the name your API clients look up.
DNSSEC: what it buys and what it costs
Section titled “DNSSEC: what it buys and what it costs”Without signatures, a resolver accepts any answer whose 16-bit ID, source port and question match its query. An attacker who knows a resolver is about to ask can flood it with forged replies, each guessing a different ID and port out of 65,536 IDs times tens of thousands of ports. One hit poisons that cache for every user of it, for the whole TTL.
DNSSEC (RFC 4033) adds public-key signatures. A zone has a key pair: the private key stays with whoever signs the zone, and the public key is published in the zone as a DNSKEY record. Each record set gets a signature, an RRSIG record, computed over the set with the private key; anyone holding the public key can check that the set is exactly what the owner signed. A signed answer arrives as the set plus its signature:
shop.example.in. 300 IN A 192.0.2.10shop.example.in. 300 IN RRSIG A 13 3 300 20261106000000 20261023000000 41520 example.in. Mg5r...; ^ covers the A set, algorithm 13 (ECDSA P-256),; valid 23 Oct to 6 Nov 2026, made with key 41520 of example.inThat leaves one question: why trust the DNSKEY? Whoever can forge the A record can forge a key too.
So the parent vouches for it. The .in zone publishes a DS record holding a hash of
example.in’s key and signs it with its own key; .in’s key is vouched for by a DS in the root;
and the root’s key is built into every validating resolver. Checking a name means walking that
chain:
Keys are replaced from time to time, a key rollover: publish the new DNSKEY next to the old one, wait at least one DNSKEY TTL so every cache holds both, sign with the new key, and remove the old one later. Most zones run two keys. The key-signing key (KSK) signs only the zone’s DNSKEY set and is the one the parent’s DS points to, so replacing it means changing the DS at the parent; the zone-signing key signs everything else and can be replaced without the parent.
It protects only users whose resolver validates: in 2025 about 36% of users sat behind validating resolvers, while only about 7% of domains had a signed delegation (CircleID, citing APNIC, February 2026). What it costs:
- Bigger answers. Every answer carries signatures: 256 bytes per RRSIG with RSA-2048, 64 with ECDSA P-256. With RSA keys, key-set answers cross the 1,232-byte line above and need TCP.
- More lookups on a cold cache. The resolver must also fetch DNSKEY and DS records for every zone on the way down. They cache well, so the cost lands on cold caches and rarely used zones.
- It fails closed. A wrong signature anywhere in the chain makes the name disappear for every
validating resolver’s users, while every server is up. On 5 May 2026 DENIC, the
.deregistry, rolled its signing key with a new system that generated a different key on each of three hardware security modules but published only one. Validating resolvers returned SERVFAIL for.denames, including unsigned domains (for those,.deserves a signed proof that no DS record exists, an NSEC-style “no” like the ones above, and those signatures failed too), and the failure grew for about three hours as cached answers expired, until operators such as Cloudflare switched validation off for.de(DENIC, Cloudflare). - Validation itself can be attacked. KeyTrap (CVE-2023-50387, patched in February 2024) was a flaw in the specification: a zone with many keys and signatures made the validator try every combination, and one crafted packet could stall BIND for up to 16 hours (ISC advisory, research paper).
The root’s own KSK was replaced this month: as of October 2026, KSK-2024 signs the root’s key set from 11 October 2026 (ICANN).
Choosing TTLs
Section titled “Choosing TTLs”Almost every DNS decision an application team makes is a TTL. Where I would start:
| Record | TTL | Why |
|---|---|---|
| API or app endpoint you control | 300 s; 60 s from one old TTL before planned work | Moves are planned; load and outage survival matter the rest of the time |
| Health-checked failover or weighted records | 30–60 s | Their whole job is to change fast; pay the query cost |
Apex (example.in itself) and www behind a CDN |
whatever the CDN sets, often 300 s | The CDN’s own names change behind it |
| MX, TXT, CAA, verification records | 1 hour to 1 day | Rarely change; long TTLs ride out a provider outage |
| NS records in your own zone | 1–2 days, to match the parent | Short child NS TTLs only add load |
| SOA minimum (negative TTL) | 60–300 s | A name tested too early heals within minutes |
How to choose. Long TTLs for what rarely changes, because they protect you in a DNS outage and cost nothing. Short TTLs only where change speed is the point. And the TTL that matters on the day of a change is the one you set one old TTL earlier.
Build it yourself
Section titled “Build it yourself”Two pieces, each closer to production than the naive clients. Everything is standard library only,
with a shared dnswire module for the binary messages, and runs against the toy server on your
own machine.
A cache that honours TTLs, including negative ones. The interesting part is where the expiry comes from: the shortest TTL in a CNAME chain for a positive answer, the SOA rule for a negative one.
# runnable version: examples/dns/python/ttl_cache.py def _ask(self, name: str, rtype: str) -> Entry: _, query = w.build_query(name, rtype) reply = w.parse_message(w.ask_udp(*self.server, query)) now = time.monotonic() # Follow any CNAMEs in the answer; the chain is only as fresh as its shortest-lived link. records = [r for r in reply.answer if r.type in (rtype, "CNAME")] addresses = [r.value for r in records if r.type == rtype] if reply.rcode == "NOERROR" and addresses: return Entry(addresses, "NOERROR", now + min(r.ttl for r in records)) # NXDOMAIN (no such name) or NODATA (name exists, no records of this type). # Negative TTL = the smaller of the SOA record's TTL and its MINIMUM field (RFC 2308). soa = next((r for r in reply.authority if r.type == "SOA"), None) negative_ttl = min(soa.ttl, soa.data["minimum"]) if soa else 0 # no SOA: do not cache return Entry([], reply.rcode if reply.rcode != "NOERROR" else "NODATA", now + negative_ttl)// runnable version: examples/dns/js/ttl-cache.mjs async ask(name, type) { const { query } = w.buildQuery(name, type); const reply = w.parseMessage(await w.askUdp(...this.server, query)); const now = performance.now(); // Follow any CNAMEs in the answer; the chain is only as fresh as its shortest-lived link. const records = reply.answer.filter((r) => r.type === type || r.type === 'CNAME'); const addresses = records.filter((r) => r.type === type).map((r) => r.value); if (reply.rcode === 'NOERROR' && addresses.length) { return { addresses, rcode: 'NOERROR', expires: now + Math.min(...records.map((r) => r.ttl)) * 1000 }; } // NXDOMAIN (no such name) or NODATA (name exists, no records of this type). // Negative TTL = the smaller of the SOA record's TTL and its MINIMUM field (RFC 2308). const soa = reply.authority.find((r) => r.type === 'SOA'); const negativeTtl = soa ? Math.min(soa.ttl, soa.data.minimum) : 0; // no SOA: do not cache return { addresses: [], rcode: reply.rcode === 'NOERROR' ? 'NODATA' : reply.rcode, expires: now + negativeTtl * 1000 }; }Run against the same move as the naive clients:
t=0.9s operator: new server is up, api now points to 198.51.100.20t=1.8s request 5 -> 203.0.113.10 (old) 200 OKt=2.3s request 6 -> 198.51.100.20 (new) 200 OKt=3.6s operator: waited longer than the TTL, switching off the old server 203.0.113.10
10 requests, 2 DNS lookups, 8 answered from the cache.Two lookups instead of ten, and the switch happened on its own when the copy expired, 1.4 seconds after the change and before the old server went away. Part 2 of the same run is the negative-caching output shown earlier.
A resolver that survives bad days. The production-shaped version tries several upstreams with a short timeout each, checks that the reply matches the question, retries over TCP on truncation, clamps silly TTLs between a floor and a ceiling, and serves stale when everything is down:
# runnable version: examples/dns/python/resilient_resolver.py def resolve(self, name: str, rtype: str = "A") -> list[str]: key, now = (name.lower(), rtype), time.monotonic() entry = self.cache.get(key) if entry and now < entry.expires: return entry.values try: values, ttl = self._ask_upstreams(name, rtype) except LookupFailed as err: if entry and now < entry.stale_until: # serve stale: an old answer beats no answer entry.expires = now + STALE_TTL print(f" ! every upstream failed ({err}); serving the expired answer for {STALE_TTL} s") return entry.values raise ttl = max(MIN_TTL, min(MAX_TTL, ttl)) self.cache[key] = Entry(values, now + ttl, now + ttl + MAX_STALE) return values// runnable version: examples/dns/js/resilient-resolver.mjs async resolve(name, type = 'A') { const key = `${name.toLowerCase()}|${type}`; const now = performance.now(); const entry = this.cache.get(key); if (entry && now < entry.expires) return entry.values; let values, ttl; try { [values, ttl] = await this.askUpstreams(name, type); } catch (err) { if (err instanceof LookupFailed && entry && now < entry.staleUntil) { // serve stale: an old answer beats no answer entry.expires = now + STALE_TTL * 1000; console.log(` ! every upstream failed (${err.message}); serving the expired answer for ${STALE_TTL} s`); return entry.values; } throw err; } ttl = Math.max(MIN_TTL, Math.min(MAX_TTL, ttl)); this.cache.set(key, { values, expires: now + ttl * 1000, staleUntil: now + (ttl + MAX_STALE) * 1000 }); return values; }The demo’s first upstream is a port where nothing listens, and later the toy server stops answering altogether:
t=0.0s 1. the first upstream is down; the second answers api.shop.example.test. -> ["203.0.113.10"] in 406 mst=0.8s 3. the authoritative server goes down; the cached answer expires ! every upstream failed (127.0.0.1:5399 ETIMEDOUT; 127.0.0.1:8053 ETIMEDOUT); serving the expired answer for 30 st=3.8s 4. a name we never cached, while everything is down failed, nothing stale to fall back on: 127.0.0.1:5399 ETIMEDOUT; 127.0.0.1:8053 ETIMEDOUTThe dead upstream cost a full 400 ms timeout before the healthy one answered, which is why the
timeout per attempt must be short. Step 3 is serve-stale doing its job; step 4 is its limit: it
can only replay answers it has already seen. Full code, the toy server and run instructions:
examples/dns/.
How real systems do it
Section titled “How real systems do it”Kubernetes multiplies every external lookup. Each pod’s /etc/resolv.conf lists search
suffixes such as my-namespace.svc.cluster.local and sets options ndots:5
(Kubernetes docs): a
name with fewer than five dots is first tried with every suffix. A lookup of api.stripe.com is
tried as api.stripe.com.my-namespace.svc.cluster.local and two more suffixes, each an NXDOMAIN,
before the real name; with A and AAAA asked separately, one lookup becomes eight queries to
CoreDNS. Write external names with a trailing dot (api.stripe.com.), or lower ndots for the pod
with dnsConfig.
nginx resolves upstream names once, unless told otherwise. A hostname written directly in
proxy_pass is resolved at startup and kept: resolve-once, in a config file. As of October 2026,
open-source nginx can re-resolve upstream servers since 1.27.3
(nginx docs):
resolver 10.0.0.2 valid=30s; # re-resolve at most every 30 s, whatever the TTL saysupstream payments { zone payments 64k; # resolved addresses live in shared memory server api.partner-bank.example resolve;}Managed DNS sells the lease as a product. Route 53’s routing policies (weighted, latency, geolocation, failover, multivalue with up to eight healthy addresses) are all ways of choosing which lease to hand to which resolver (Route 53 docs). Every one of them inherits the limits above: decisions per resolver, delivered one TTL late.
Gotchas and war stories
Section titled “Gotchas and war stories”“It works on my laptop.” Your dig asks one resolver, which may have fetched a fresh copy a
second ago. After a change, ask the authoritative server directly for the truth, then two or three
public resolvers for what users see. dig +norecurse @8.8.8.8 name shows only what that resolver
already has cached.
A TTL change that never happened. Some DNS dashboards apply the TTL to new records only, or round it up to a plan minimum. Before a migration, check the TTL your authoritative server actually serves, not the one in the form.
Every name sits under zones you do not run. The .de failure took down domains whose owners
did nothing wrong, and the October 2025 DynamoDB failure took down services that only depended on
an endpoint. Know which zones and which DNS-backed endpoints your critical path depends on, and
what their TTLs give you when they fail.
Trade-offs and when not to use it
Section titled “Trade-offs and when not to use it”- Freshness against load and resilience. Short TTLs mean fast changes, more queries, a bigger bill and less time to survive a provider outage. You choose per record.
- DNS steering is coarse. It decides per resolver and reacts in TTLs; a load balancer decides per connection or request and reacts in seconds. Use DNS to choose a region.
- DNSSEC trades availability for authenticity. It stops forged answers for validating users and turns any signing mistake into a total outage for them.
- Encrypted DNS moves trust. DNS over HTTPS or TLS hides lookups from the network and shows them all to one resolver operator. It does not prove the answer is genuine; DNSSEC does that.
When should you not lean on DNS? Where endpoints change every few seconds, such as autoscaling pods, every layer’s caching works against you: use a service registry or a load balancer with health checks. And DNS cannot make per-request decisions; it never sees requests.
Interview corner
Section titled “Interview corner”- “You changed a record and some users still reach the old server an hour later. Why?” Caches hold the old copy for up to the old TTL; runtimes and pools keep it longer. Strong answers add the plan: lower the TTL one old TTL ahead, keep the old server until its traffic and connections drain, and check the authoritative server directly.
- “Your DNS provider is down. What do users see, and when?” Nothing until copies expire, then failures grow with time ÷ TTL, unless resolvers serve stale. Follow-up: “How do you design for it?” Name servers from two providers, long TTLs on records that rarely change.
- “How would you do global failover with DNS?” Health-checked short-TTL records, and the honest numbers: about 90 s detection plus a TTL plus runtime caches, with pinned clients never moving. Pair it with load balancers in each region.
- “Is DNS UDP or TCP?” Both: UDP first, TCP for truncated answers above 1,232 bytes and for zone transfers, and TCP support is mandatory.
- “Should we turn on DNSSEC?” What it protects (forged answers, for validating users), what it costs (size, cold-cache lookups, fail-closed outages), and how to do it safely (automated rollovers, ECDSA).
Check yourself
Section titled “Check yourself”1. A record has a TTL of 3,600 s. At 14:00 you change the address and set the TTL to 60 s in the same edit. When can you safely switch off the old server?
Not before about 15:00, and only once its traffic and open connections have dropped to near zero. Caches that fetched the record shortly before 14:00 got it with the old 3,600-second TTL. To cut over quickly you had to lower the TTL at 13:00 or earlier.
2. A deploy pipeline polled report.example.in for 10 minutes before the record was created at 09:30. The zone’s SOA has TTL 3600 and minimum 900. At 09:40 some users still get NXDOMAIN. Why, and when does it end?
Every resolver the pipeline’s checks went through cached the NXDOMAIN for min(3600, 900) = 900 s, so the last one, cached shortly before 09:30, can last until about 09:45, plus any runtime cache. Resolvers that were never asked fetched the new record at once.
3. Your name servers are unreachable for 40 minutes. Record A has a TTL of 300 s, record B a TTL of 86,400 s. What happens to each, and what does serve-stale change?
Users of A start failing as copies expire and nearly all fail within five minutes; users of B almost never notice, since few copies expire in 40 minutes. Serve-stale keeps A working for users whose resolver had a copy, but cannot help a cache that never had one.
4. A bug publishes your API’s record with no addresses for 20 minutes. Your negative TTL is 3,600 s and your resolvers serve stale. How long are users affected?
Serve-stale does not apply: the servers answered, and an empty answer is valid. Every cache that fetched during those 20 minutes cached “no addresses” for up to 3,600 s (or the resolver’s cap, one hour in Unbound), so users can fail for up to an hour after the fix. A short SOA minimum keeps this tail short.
5. Four servers behind one name with round-robin DNS. Monitoring shows each server averaging about 25% of traffic, yet one of them falls over every few minutes. How?
The authoritative server decides per resolver cache, a few big resolvers carry most users, and many clients take the first address. At any moment one server can hold several big resolvers’ users, and the hot spot moves every TTL, so averages look even. Size for the peak, or put a load balancer behind each address.
- A DNS answer is a lease you cannot recall. Plan for the longest time any holder might keep it: resolver caps, runtime caches (Java 30 s, Chrome 60 s) and open connections.
- After a change, copies live for up to the old TTL. Lower it one old TTL ahead, change, drain the old server’s connections, then switch it off.
- “No” is cached: NXDOMAIN and NODATA for min(SOA TTL, SOA minimum), and resolution failures for 1 s to 5 min. NXDOMAIN covers the subtree, and NSEC proofs cover ranges.
- In an outage, failures grow with time ÷ TTL; serve-stale hides an unreachable server but never an empty record, which also lingers for the negative TTL after the fix.
- Resolvers spread queries over all name servers: make each equally strong, and use two providers for critical names. DNS balancing is per resolver and lumpy; failover takes minutes.
- Answers above 1,232 bytes go to TCP; DNSSEC adds size, lookups and a fail-closed mode.
Where to go next
Section titled “Where to go next”Further reading
Section titled “Further reading”Specs & official docs
- RFC 1034 and RFC 1035 — the original design and message format.
- RFC 2308, negative caching and RFC 9520, caching resolution failures — how “no” and “could not find out” are cached.
- RFC 8020, NXDOMAIN cut and RFC 8198, aggressive use of the validated cache — why one negative answer covers more names.
- RFC 8767, serve-stale — answering from an expired cache.
- RFC 6891, EDNS(0), DNS Flag Day 2020 and RFC 9210, DNS over TCP — size limits, 1,232 bytes and mandatory TCP.
- RFC 4033, DNSSEC and RFC 8901, multi-signer DNSSEC — the chain of trust, and signing with two providers.
- RFC 7871, EDNS Client Subnet — passing part of the user’s address for location-based answers.
- BIND 9 configuration reference and
unbound.conf— TTL caps and serve-stale defaults. - Unbound: timeout and server selection — the 400 ms band.
- ICANN: root KSK rollover — the October 2026 change of the root key.
- Kubernetes: DNS for Services and Pods, Node.js
dns, OpenJDKInetAddressCachePolicyand nginx upstream module — what platforms do with names and TTLs. - Route 53 routing policies and health check intervals — managed DNS steering and failover timing.
- Microsoft: prevent dangling DNS and subdomain takeover — the attack and the checklist.
Research & engineering
- Moura et al., When the Dike Breaks (IMC 2018) — what caches and retries do during a DDoS on authoritative servers.
- Moura et al., Cache Me If You Can (IMC 2019) — how TTLs really behave, and child-centric resolvers.
- Müller et al., Recursives in the Wild (IMC 2017) — how resolvers choose among name servers.
- Al-Dalky, Rabinovich and Schomp, ECS behaviour of resolvers (2019) — what ECS does to cache size and hit rate.
- CERN: glibc and DNS load balancing and glibc bug 24282 — how address sorting defeats round robin.
- Eric Lawrence: Chromium’s DNS cache (2022) — the browser’s own TTL rules.
- APNIC: RSA-4096 and DNSSEC response sizes (2021) and CircleID on DNSSEC deployment (2026) — what DNSSEC costs and who uses it.
- ISC: KeyTrap, CVE-2023-50387 and the KeyTrap paper — validation as an attack surface.
- AWS: DynamoDB disruption in us-east-1 (October 2025) — an empty record published by DNS automation.
- DENIC: the .de outage of 5 May 2026 and Cloudflare’s view of it — a DNSSEC signing failure at a TLD.
- APNIC: the attack on Dyn (2016) — why one DNS provider is one point of failure.
- APNIC: aggressive NSEC caching in BIND 9.12 (2018) — RFC 8198 switched on by default.
Explainers
- Julia Evans: How updating DNS works —
digexperiments showing propagation as caches expiring. - Cloudflare: the October 2021 Facebook outage — how a withdrawn route became a DNS failure, and the resolver load that followed.
Senior Dev Notes
System design, explained like a mentor would.
Written by Uday. Every page is researched, reviewed by a human, and every code example was run before it was published.