Skip to the solution
EasySystem Design15 min read

How does DNS resolution work?

1.1k views

The problem

Question presented to candidate: "When I type www.example.com into a browser, how does my computer find out which IP address to connect to? Walk me through every server involved, and tell me where caching happens and what that means when we change a DNS record."

What a strong answer should cover:

  • The cast: the stub resolver on the device, a recursive resolver (ISP, company, or public such as 1.1.1.1 or 8.8.8.8), and the root, TLD and authoritative name servers, each answering with a referral or an answer
  • Iterative queries from the recursive resolver down the hierarchy versus the recursive query the stub sends to it
  • Caching at every layer (browser, OS, recursive resolver) governed by the record TTL, including negative caching of NXDOMAIN; a warm cache answers in zero upstream queries
  • Record types that matter in design: A/AAAA, CNAME (and why CDNs use it), NS, plus TTL strategy before a migration (lower it in advance)
  • Transport: UDP port 53 by default, TCP fallback for large answers, EDNS(0), and encrypted DNS (DoT, DoH)
  • DNS as a system-design tool and risk: GeoDNS and weighted records for traffic steering, and DNS as a single point of failure

Code / implementation expected: No โ€” a clear walk-through of the lookup path and caching is the answer; the example simulates a resolver for anyone who wants to see it step by step.

  • networking
  • dns

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 11 sections

Read the code

A recursive resolver: referrals, TTL cache, CNAME chain, negative caching
// A recursive resolver walking simulated root, TLD and authoritative servers,
// with a TTL cache, QNAME minimisation, a CNAME chain and negative caching.
// Servers are in-process tables; the clock is a number we move by hand.

const SERVERS = {
  root: { zone: ".", delegations: { "com.": "tld-com", "net.": "tld-net" } },
  "tld-com": { zone: "com.", delegations: { "example.com.": "ns1.example.com" } },
  "tld-net": { zone: "net.", delegations: { "cdnprovider.net.": "ns.cdnprovider.net" } },
  "ns1.example.com": {
    zone: "example.com.",
    records: {
      "www.example.com.": { type: "CNAME", value: "shop.cdnprovider.net.", ttl: 3600 },
      "api.example.com.": { type: "A", value: "203.0.113.10", ttl: 300 },
    },
    soa: { ttl: 3600, minimum: 900 },
  },
  "ns.cdnprovider.net": {
    zone: "cdnprovider.net.",
    records: { "shop.cdnprovider.net.": { type: "A", value: "198.51.100.25", ttl: 60 } },
    soa: { ttl: 300, minimum: 300 },
  },
};
const DELEGATION_TTL = 172800; // 2 days, a typical value for TLD and domain NS records

let now = 0;
const cache = new Map(); // key -> { value, expires }
let upstream = 0;

function cacheGet(key) {
  const e = cache.get(key);
  if (!e) return null;
  if (e.expires <= now) { cache.delete(key); return null; }
  return { value: e.value, ttlLeft: e.expires - now };
}
function cachePut(key, value, ttl) { cache.set(key, { value, expires: now + ttl }); }

function ask(server, qname, qtype, log) {
  upstream++;
  log.push(`    ask ${server.padEnd(18)} ${qtype.padEnd(5)} ${qname}`);
  const s = SERVERS[server];
  if (s.delegations) {
    for (const [zone, ns] of Object.entries(s.delegations)) {
      if (qname === zone || qname.endsWith("." + zone)) return { referral: zone, ns };
    }
    return { nxdomain: true, negTtl: 900 };
  }
  const r = s.records[qname];
  if (r) return { answer: r };
  return { nxdomain: true, negTtl: Math.min(s.soa.ttl, s.soa.minimum) };
}

// Find the deepest zone whose name server we already know.
function closestKnownZone(name) {
  const labels = name.split(".").filter(Boolean);
  for (let i = 0; i < labels.length; i++) {
    const zone = labels.slice(i).join(".") + ".";
    const hit = cacheGet("NS " + zone);
    if (hit) return { zone, server: hit.value };
  }
  return { zone: ".", server: "root" };
}

function resolve(name, log, depth = 0) {
  const neg = cacheGet("NX " + name);
  if (neg) { log.push(`    negative cache hit, ${neg.ttlLeft}s left`); return { rcode: "NXDOMAIN" }; }
  const hit = cacheGet("RR " + name);
  if (hit) {
    log.push(`    cache hit ${hit.value.type} ${hit.value.value}, ${hit.ttlLeft}s left`);
    if (hit.value.type === "CNAME") return resolve(hit.value.value, log, depth + 1);
    return { rcode: "NOERROR", ip: hit.value.value, ttl: hit.ttlLeft };
  }
  let { zone, server } = closestKnownZone(name);
  const labels = name.split(".").filter(Boolean);
  for (;;) {
    // QNAME minimisation: reveal only one label more than the zone we are in.
    const zoneLen = zone === "." ? 0 : zone.split(".").filter(Boolean).length;
    const minimal = labels.slice(labels.length - Math.min(labels.length, zoneLen + 1)).join(".") + ".";
    const isFull = minimal === name;
    const res = ask(server, isFull ? name : minimal, isFull ? "A" : "NS", log);
    if (res.referral) {
      cachePut("NS " + res.referral, res.ns, DELEGATION_TTL);
      zone = res.referral; server = res.ns;
      continue;
    }
    if (res.nxdomain) { cachePut("NX " + name, true, res.negTtl); return { rcode: "NXDOMAIN", negTtl: res.negTtl }; }
    cachePut("RR " + name, res.answer, res.answer.ttl);
    if (res.answer.type === "CNAME") {
      log.push(`    CNAME -> ${res.answer.value}`);
      return resolve(res.answer.value, log, depth + 1);
    }
    return { rcode: "NOERROR", ip: res.answer.value, ttl: res.answer.ttl };
  }
}

function query(t, name) {
  now = t;
  const before = upstream;
  const log = [];
  const r = resolve(name, log);
  const result = r.rcode === "NOERROR" ? `${r.ip} (TTL ${r.ttl}s)` : "NXDOMAIN";
  console.log(`t=${t}s  ${name} -> ${result}  [upstream queries: ${upstream - before}]`);
  for (const l of log) console.log(l);
}

console.log("Part 1: cold, warm and partly expired lookups");
query(0, "www.example.com.");
query(10, "www.example.com.");
query(20, "api.example.com.");
query(75, "www.example.com.");
query(80, "nope.example.com.");
query(500, "nope.example.com.");
query(1000, "nope.example.com.");
console.log(`total upstream queries: ${upstream}`);

console.log("\nPart 2: how long a record change takes to reach everyone");
for (const ttl of [86400, 3600, 300, 60]) {
  const resolvers = 1000; // caches that fetched the old record at evenly spread times
  let stale = 0;
  for (let i = 0; i < resolvers; i++) {
    const fetchedAgo = (i * ttl) / resolvers; // seconds before the change
    if (ttl - fetchedAgo > 600) stale++; // still holding the old IP 10 minutes after the change
  }
  console.log(`  TTL ${String(ttl).padStart(5)}s: worst case ${ttl}s stale; ${(stale / 10).toFixed(1)}% of caches still old after 10 min`);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 1 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextEasy

What is the difference between a reverse proxy and a forward proxy?

Continue