DevLift
Back to Blog

How DNS Resolution Works Under the Hood

DNS translates domain names to IPs via a hierarchical delegation tree. Here's what actually happens between typing a URL and your browser connecting.

Admin
January 19, 202610 min read0 views

How DNS Resolution Works Under the Hood

You type github.com into your browser and hit Enter. A millisecond later, your browser starts downloading content from an IP address halfway around the world. But wait — your browser didn't know that IP address. Nobody told it. So how did it find out?

The answer is the Domain Name System — a globally distributed, hierarchical database that translates human-readable names into machine-routable addresses. DNS is one of the most critical pieces of internet infrastructure, yet most developers treat it as a black box. Let's open that box.

The Big Picture

DNS is not a single server — it's a delegation tree. At the top sit 13 root nameserver identities, a. through m.root-servers.net, run by 12 independent operators (Verisign runs both A and J; ICANN runs L; NASA Ames runs E). Below them are TLD (Top-Level Domain) nameservers for .com, .net, .io, etc. Below those are authoritative nameservers for each specific domain. And sitting between you and all of this is a recursive resolver — usually run by your ISP, Google (8.8.8.8), or Cloudflare (1.1.1.1).

Rendering diagram...

The recursive resolver is the workhorse. It does all the legwork — asking the root servers, then the TLD servers, then the authoritative servers — and hands the final answer back to your OS. Your browser just waits.

Step 1: Check Every Cache First

Before a single network packet leaves your machine, DNS checks multiple caches in sequence. This is where the vast majority of lookups terminate.

Browser cache: Chrome, Firefox, and Safari maintain their own DNS caches. In Chrome, you can inspect it at chrome://net-internals/#dns. Chrome's numbers are in net/dns/host_resolver_manager_job.cc, and they are floors and defaults rather than caps:

net/dns/host_resolver_manager_job.cc
// Default TTL for successful resolutions with HostResolverSystemTask.
const unsigned kCacheEntryTTLSeconds = 60;
 
// Default TTL for unsuccessful resolutions with HostResolverSystemTask.
const unsigned kNegativeCacheEntryTTLSeconds = 0;
 
// Minimum TTL for successful resolutions with HostResolverDnsTask.
const unsigned kMinimumTTLSeconds = kCacheEntryTTLSeconds;

So when Chrome goes through the OS (which reports no TTL) it assumes 60 seconds for a hit and caches a miss for zero seconds; when Chrome runs its own resolver it takes the server's TTL but never trusts anything shorter than 60 seconds.

OS cache: If the browser cache misses, the query goes to the operating system's stub resolver. On Linux this is handled by systemd-resolved or nscd. On macOS it's mDNSResponder. These maintain their own TTL-based caches.

/etc/hosts: Before any network query, the OS checks the hosts file. This is why 127.0.0.1 localhost works without DNS, and why you can override any domain locally by adding an entry. The lookup order is controlled by /etc/nsswitch.conf on Linux.

Recursive resolver cache: If the OS has no cached answer, it forwards the query to the configured recursive resolver. The resolver maintains a large shared cache, serving millions of users. Popular domains like google.com are almost always cached here, making resolution sub-millisecond.

💡

TTL (Time To Live) is set by the domain owner in their DNS records. A TTL of 300 means resolvers must discard the cached answer after 5 minutes. Lowering TTL before a migration gives you faster propagation; raising it after reduces resolver load.

Step 2: The Recursive Resolution Dance

When the recursive resolver has no cached answer, it starts the iterative resolution process. This is the interesting part.

Rendering diagram...

The Root Nameservers

There are 13 root nameserver names, A through M (a.root-servers.net through m.root-servers.net). Each name is anycast across many physical sites, so the count of names tells you almost nothing about the count of machines. root-servers.org publishes a live figure; when I checked on 2026-08-03 it read "the root server system consists of 2002 operational instances operated by the 12 independent root server operators." Your query lands on whichever instance BGP considers closest.

The root server doesn't know github.com's IP. It knows who manages .com — and returns NS records pointing to Verisign's TLD nameservers (a.gtld-servers.net through m.gtld-servers.net).

The TLD Nameservers

The .com TLD nameservers know who manages github.com, not the IP itself. They return NS (nameserver) records. Today dig +short github.com NS returns four Route 53 nameservers (ns-1283.awsdns-32.org, ns-421.awsdns-52.com, ns-520.awsdns-01.net, ns-1707.awsdns-21.co.uk) plus four NS1 nameservers (dns1.p08.nsone.net through dns4.) — GitHub runs two providers in parallel.

AWS encodes the nameserver's address in its name, which is a useful trick to know: ns-1283 means 205.251.(192 + 1283/256).(1283 % 256)205.251.197.3, and dig +short ns-1283.awsdns-32.org agrees.

The Authoritative Nameserver

Finally, the authoritative nameserver is the source of truth. It holds the actual records configured by the domain owner and returns the answer — an A record (IPv4), AAAA record (IPv6), CNAME (alias), or whatever was queried.

The resolver caches this response with its TTL, returns the answer to your OS, which caches it too, and hands it to your browser.

A cold resolution costs one round trip per tier you have to walk, so the honest answer to "how long does DNS take" is "however long it takes to reach the root, then the TLD, then the authoritative server, and you cannot know that from a blog post." Measure your own path rather than trusting a range: dig +trace github.com shows each hop, and dig github.com prints a Query time: line for the resolver you actually use. On a warm resolver cache one round trip is all you pay, which is why the numbers people quote for DNS are almost always measuring a cache hit.

The DNS Packet Format

DNS primarily uses UDP on port 53. A DNS query is tiny — typically 40-60 bytes. The response has to fit in a single UDP packet, and the size limit has moved twice. The original spec caps it at 512 bytes. EDNS0 let clients advertise a larger buffer, and 4096 became the de-facto default. Then IP fragmentation turned out to be a reliability and security problem, so DNS Flag Day 2020 pushed the recommendation down to 1232 bytes — 1280 (the IPv6 minimum MTU) minus 48 bytes of IPv6 and UDP headers. BIND, Unbound, Knot, NSD and PowerDNS all shipped that as the new default, and you can see it in your own resolver:

$ dig +qr github.com | grep udp:
; EDNS: version: 0, flags:; udp: 1232

UDP is preferred because DNS is request/response with no need for connection setup.

TCP kicks in when:

  • The response exceeds the negotiated payload size (truncation bit is set, client retries over TCP)
  • Zone transfers (AXFR) — copying entire DNS zones between servers
  • DNSSEC responses with large signature payloads

A DNS message has five sections, not four. RFC 1035 §4.1: "The top level format of message is divided into 5 sections (some of which are empty in certain cases)" — and the header counts as one of them. Count the rows below:

SectionContents
Header12 bytes: ID, flags, and the counts for the four sections that follow
QuestionThe query: domain name + type (A, AAAA, etc.) + class (IN for internet)
AnswerResource records answering the query
AuthorityNS records for the zone
AdditionalGlue records (IPs of nameservers, to avoid circular lookups)

The domain github.com is encoded as length-prefixed labels: \x06github\x03com\x00. This avoids delimiter ambiguity and allows compression via back-references within the same packet.

DNS Record Types

Every example below is a live answer as of 2026-08-03, so check them yourself rather than believing me.

TypePurposeExample
AIPv4 addressgithub.com → an IPv4 address that varies by region — GitHub geo-steers it
AAAAIPv6 addressgithub.com has none: dig github.com AAAA returns NODATA (SOA in the authority section, empty answer). cloudflare.com AAAA is a domain that does answer
CNAMECanonical name aliaswww.github.comgithub.com
NSNameserver delegationgithub.com NS ns-1283.awsdns-32.org
MXMail exchangegithub.com MX 0 github-com.mail.protection.outlook.com
TXTArbitrary textSPF, DKIM, domain verification tokens
SOAZone authority metadataSerial, refresh, retry, expire, minimum
PTRReverse DNS (IP → name)4.121.82.140.in-addr.arpalb-140-82-121-4-fra.github.com
SRVService location_https._tcp.example.com → host:port
CAACertificate authority authorizationWhich CAs may issue certs for this domain

That AAAA row is worth pausing on. A domain this large having no IPv6 address at all surprises people, and it is the sort of thing you should verify rather than assume — a "missing" AAAA and a broken AAAA look identical from the application side, but only one of them is a bug.

⚠️

CNAME records cannot coexist with other records at the same name. This is why you can't use a CNAME at the zone apex (@/bare domain) — it would conflict with the required SOA and NS records. DNS providers work around this with proprietary ALIAS or ANAME records that flatten the CNAME at query time.

DNSSEC: The Chain of Trust

Plain DNS has no authentication. Nothing stops a malicious resolver from returning 192.0.2.1 for yourbank.com. DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records.

Each zone signs its records with a private key. The corresponding public key is published as a DNSKEY record. The parent zone signs that public key with its private key, creating a chain of trust from the root down to the authoritative zone.

Root (.) signs → .com DNSKEY
.com signs → github.com DNSKEY
github.com signs → github.com A record

The root zone's DNSKEY is hardcoded in resolvers (the "trust anchor"). When a DNSSEC-validating resolver gets a response, it walks the chain upward, verifying each signature. If any signature fails, the resolver returns SERVFAIL rather than the potentially spoofed answer.

DNSSEC adds DS (Delegation Signer), RRSIG (Resource Record Signature), NSEC/NSEC3 (authenticated denial of existence), and DNSKEY record types. Responses can become several kilobytes, which is one reason large DNS providers still serve DNSSEC opt-in rather than universal.

DNS over HTTPS and TLS

Traditional DNS is sent in plaintext. Anyone on your network — your ISP, a coffee shop operator, a nation-state firewall — can see every domain you query. Two protocols fix this:

DNS over TLS (DoT) — RFC 7858. Wraps DNS in TLS on port 853. The query format is identical to TCP DNS, just encrypted. Simple to implement, easy to block (port 853 is distinctive).

DNS over HTTPS (DoH) — RFC 8484. Encodes DNS queries as HTTP/2 or HTTP/3 requests to a standard HTTPS endpoint (e.g., https://1.1.1.1/dns-query). Traffic is indistinguishable from ordinary HTTPS, making it very hard to block without blocking all HTTPS. Firefox turns DoH on by default only in the countries where Mozilla has rolled it out — the US (2020), Canada (2021), Russia and Ukraine (2022) — and the default resolver differs per country: Cloudflare in the US, CIRA in Canada. Elsewhere it is opt-in.

Both protocols authenticate the resolver — you know you're talking to 1.1.1.1, not an impersonator. But they shift trust from your ISP to your DoH/DoT provider.

Code: Querying DNS Programmatically

Here's how to perform raw DNS lookups in Node.js — both the high-level API and the underlying structure. The comments are the output I got running this on Node 22.22.3 on 2026-08-03; the A record in particular will differ for you, which is the point.

dns-lookup.ts
import dns from 'node:dns/promises';
 
// High-level: uses the OS resolver (respects /etc/hosts, system caches)
const addresses = await dns.lookup('github.com', { family: 4 });
// { address: '20.207.73.82', family: 4 }  ← geo-steered, yours will differ
 
// Low-level: bypass OS, query a specific resolver directly
const resolver = new dns.Resolver();
resolver.setServers(['1.1.1.1:53', '8.8.8.8:53']);
// getServers() drops the port when it is 53: ['1.1.1.1', '8.8.8.8']
 
// Query all A records for a domain
const aRecords = await resolver.resolve4('github.com');
// ['20.207.73.82']  ← one address here, not a round-robin set
 
// Query MX records (with priority)
const mxRecords = await resolver.resolveMx('github.com');
// [{ exchange: 'github-com.mail.protection.outlook.com', priority: 0 }]
 
// Reverse DNS lookup (PTR record)
const hostnames = await resolver.reverse('140.82.121.4');
// ['lb-140-82-121-4-fra.github.com']  ← the datacenter is in the name
 
// Measure resolution latency
const start = performance.now();
await dns.lookup('github.com');
const latency = performance.now() - start;
console.log(`DNS resolved in ${latency.toFixed(2)}ms`);
// DNS resolved in 1.94ms  ← warm OS cache, so this measures the cache

Two things fall out of running it rather than reading it. resolve4 returned a single address, not the multi-address round-robin set people assume GitHub serves. And that PTR record spells out which of GitHub's load balancers answered — fra, Frankfurt — which is a free hint about where your traffic is actually terminating.

The key difference: dns.lookup() uses getaddrinfo() — the same system call your browser uses, with all OS-level caching and /etc/hosts applied. dns.resolve4() bypasses the OS and queries a nameserver directly over UDP, giving you raw DNS semantics and the ability to inspect TTLs and all returned records.

Practical Implications

Understanding DNS internals changes how you build and debug:

  • TTL is a contract, not a suggestion. When you lower TTL before a migration, you must wait the old TTL for all caches to expire first. If your TTL was 3600 (1 hour), change it 1 hour before you change the record.

  • DNS is not instant, and it's not global. After you update a record, resolvers around the world will serve stale answers until their cached TTL expires. "DNS propagation" isn't a single event — it's millions of independent cache expirations.

  • Multiple A records = primitive load balancing. Returning multiple IPs for one hostname distributes load via round-robin at the resolver level. Browsers try the first IP and fall back to others on failure (Happy Eyeballs algorithm for IPv4/IPv6).

  • Negative caching matters, and it is probably longer than you think. NXDOMAIN and NODATA responses are cached for min(SOA minimum, SOA record TTL) per RFC 2308 — not for the record's TTL, because there is no record. github.com's SOA has a minimum of 86400 and is served with a TTL of 900, so a negative answer sticks for 15 minutes. If you query a name during a deployment before its record exists, you can be poisoning your own resolver for a quarter of an hour. Check with dig +short github.com SOA before you assume a number.

  • Your debug tool matters. ping github.com uses getaddrinfo() (OS cache). dig github.com @1.1.1.1 bypasses everything and queries the resolver directly. When debugging DNS issues, always use dig — it shows TTLs, all returned records, and the authoritative server that answered.

  • DNS failures are hard to distinguish. A connection timeout could be a dead server or a stale DNS record pointing to an old IP. Always verify resolution independently before concluding the server itself is down.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read