← All writing

writing/August 31, 2026/17 min

DNS Was the Internet's First CDN

Recursive resolvers are caches, TTLs are cache-control, and the internet's phone book was doing edge delivery fifteen years before Akamai. A first-principles tour of DNS and CDNs — ending with a resolver built from raw sockets and the question of whether Cloudflare has already won.

Here is my domain, resolved:

$ dig +short ramanshrivastava.com216.198.79.1 $ dig +short ramanshrivastava.com NSearl.ns.cloudflare.com.elly.ns.cloudflare.com.

Three lines of output, about seven milliseconds of work. In 1982, this same lookup was a nightly download. Every machine on the ARPANET kept a local copy of a single file called HOSTS.TXT, maintained by hand at the Network Information Center at SRI in Menlo Park. You emailed them your hostname, they added a line, and everyone else FTP'd the new file — the whole internet's name-to-address mapping, one text file fetched on a schedule. By the early eighties this was collapsing under its own absurdity: the file kept growing, the FTP server kept melting, and two hosts claiming the same name was a merge conflict for the entire internet.

So in November 1983 Paul Mockapetris published RFC 882 and RFC 883 and invented the Domain Name System. The fix wasn't a bigger server for the file. The fix was to shard the file into a delegation hierarchy and then — this is the part that gets forgotten — to cache the shards everywhere, with an expiry timestamp on every record. Distributed placement of data, close to the people asking for it, with explicit cache lifetimes.

That is a content delivery network. It shipped fifteen years before Akamai existed. That's the claim I want to defend in this post, and by the end I want you to see why one company figured out that the phone book and the delivery truck were the same business — and quietly became both.

Wait — who do you even ask?

The naive question first. Your browser wants ramanshrivastava.com and knows nothing. Who does it even ask?

The honest answer is a chain of increasingly well-informed strangers. Your browser asks the operating system's stub resolver — "stub" because it does no real work; it just forwards the question to whatever recursive resolver it's been configured with (your ISP's, or 8.8.8.8, or 1.1.1.1). The recursive resolver is the one that actually earns its living. If it doesn't already know the answer, it walks the hierarchy from the top:

$ dig +trace ramanshrivastava.com A .                       513406  IN  NS  a.root-servers.net..                       513406  IN  NS  b.root-servers.net.; ... 11 more root servers ...;; Received 239 bytes from 1.1.1.1#53(1.1.1.1) in 7 ms com.                    172800  IN  NS  a.gtld-servers.net.; ... 12 more .com servers  "I don't know, ask the .com people";; Received 845 bytes from 192.58.128.30#53(j.root-servers.net) in 7 ms ramanshrivastava.com.   172800  IN  NS  earl.ns.cloudflare.com.ramanshrivastava.com.   172800  IN  NS  elly.ns.cloudflare.com.; "I don't know either, ask Cloudflare";; Received 365 bytes from 192.52.178.30#53(k.gtld-servers.net) in 20 ms ramanshrivastava.com.   300     IN  A   216.198.79.1;; Received 65 bytes from 172.64.33.161#53(earl.ns.cloudflare.com) in 7 ms

Three hops, and notice that the first two are refusals. The root server doesn't know my IP address — it knows who runs .com. The .com servers don't know it either — they know I've delegated my zone to Cloudflare's nameservers. Only earl.ns.cloudflare.com, the authoritative server for my domain, gives a real answer. Everyone above it hands back a referral: not my department, here's who to ask next.

graph TB B["Browser cache<br/>(holds answers seconds to minutes)"] -->|miss| OS["OS stub resolver + cache<br/>(honors each record's TTL)"] OS -->|miss| R["Recursive resolver — ISP, 8.8.8.8, 1.1.1.1<br/>(the big shared cache)"] R -->|"cold start: 'who owns .com?'"| ROOT["Root servers<br/>13 names, ~2,000 anycast instances"] ROOT -->|"referral: ask the .com servers"| TLD[".com TLD servers<br/>a–m.gtld-servers.net"] TLD -->|"referral: ask Cloudflare"| AUTH["Authoritative nameserver<br/>earl.ns.cloudflare.com"] AUTH -->|"answer: A 216.198.79.1, TTL 300"| R R -->|"cached — next asker skips the whole walk"| OS

A few structural facts worth having straight. There are 13 root server names (a through m), run by 12 independent operators — but over 2,000 physical instances worldwide, because each name is anycast (more on that trick later). Classic DNS runs over single UDP datagrams with a 512-byte limit; bigger answers negotiate up via EDNS0 or retry over TCP. And DNSSEC exists to cryptographically sign this chain, which is all I'll say about DNSSEC.

Why is this ever fast?

Here's the thing the trace hides: almost nobody ever performs that walk. Run dig twice and the second query comes back in under a millisecond, because your recursive resolver kept the answer.

Look at the trace again with caching eyes. Every record carries a TTL — those numbers in the second column. The root NS records: 513,406 seconds. The .com delegation: 172,800 seconds (two days). My A record: 300 seconds. Each number is the record's author saying you may serve a copy of this, this stale, without asking me again. The recursive resolver is a shared read-through cache sitting in front of the authoritative source, absorbing the overwhelming majority of reads. The hierarchy above it is effectively cached into oblivion — your resolver re-walks the root and TLD layers roughly never, because those TTLs are measured in days.

This is precisely the shape of a CDN: an origin that holds the truth, edge caches that hold copies close to the readers, and per-object cache lifetimes set by the origin. TTL: 300 and Cache-Control: max-age=300 are the same sentence in two dialects. DNS just said it in 1983.

The design also explains the most persistent myth in web operations — that changed DNS records "propagate." They don't, and we'll get to that. But first, in the spirit of Feynman's blackboard"What I cannot create, I do not understand" — let's create one.

Build one: a resolver from raw sockets

This is a real iterative resolver in ~140 lines of Python standard library. No dnspython, no libraries at all — it builds DNS packets with struct, fires them over UDP, parses the referrals, and performs the same walk dig +trace showed, starting from a root server. The recursion-desired bit is deliberately off: we're not asking anyone to resolve the name for us.

#!/usr/bin/env python3"""An iterative DNS resolver from raw sockets. No libraries, no recursion bit. Usage: python3 raw_socket_resolver.py example.com Starts at a root server and follows referrals down the hierarchy, the samewalk `dig +trace` shows you: root -> TLD -> authoritative."""import randomimport socketimport structimport sys ROOT_IP, ROOT_NAME = "198.41.0.4", "a.root-servers.net"TYPE_A, TYPE_NS, TYPE_CNAME = 1, 2, 5  def build_query(name: str) -> bytes:    # Header: 16-bit id, flags, then counts (1 question, 0 of everything else).    # Flags are all zero -- crucially RD (recursion desired) is OFF. We are not    # asking the server to resolve the name for us; we are doing the walk.    header = struct.pack(">HHHHHH", random.randint(0, 0xFFFF), 0, 1, 0, 0, 0)    # A name on the wire is length-prefixed labels: 7example3com0    qname = b"".join(        bytes([len(label)]) + label.encode() for label in name.split(".")    ) + b"\x00"    return header + qname + struct.pack(">HH", TYPE_A, 1)  # QTYPE=A, QCLASS=IN  def read_name(msg: bytes, off: int) -> tuple[str, int]:    """Decode a name, following compression pointers.     Names repeat constantly in a DNS response, so instead of spelling    'ns.cloudflare.com' out five times, the protocol writes it once and later    occurrences are a 2-byte pointer (top bits 11) back to the first copy.    """    labels = []    while True:        length = msg[off]        if length & 0xC0 == 0xC0:  # compression pointer            ptr = struct.unpack(">H", msg[off:off + 2])[0] & 0x3FFF            suffix, _ = read_name(msg, ptr)            labels.append(suffix)            return ".".join(labels), off + 2        if length == 0:            return ".".join(labels), off + 1        off += 1        labels.append(msg[off:off + length].decode())        off += length  def read_records(msg: bytes, off: int, count: int) -> tuple[list, int]:    """Parse `count` resource records: (name, type, ttl, data) tuples."""    records = []    for _ in range(count):        name, off = read_name(msg, off)        rtype, _rclass, ttl, rdlen = struct.unpack(">HHIH", msg[off:off + 10])        off += 10        if rtype == TYPE_A:            data = socket.inet_ntoa(msg[off:off + 4])        elif rtype in (TYPE_NS, TYPE_CNAME):            data, _ = read_name(msg, off)  # rdata is itself a (compressed) name        else:            data = msg[off:off + rdlen].hex()        off += rdlen        records.append((name, rtype, ttl, data))    return records, off  def ask(server_ip: str, name: str) -> tuple[list, list, list]:    """One UDP round-trip. Returns (answers, authority, additional).     Classic DNS is a single unacknowledged UDP datagram each way. Real    resolvers negotiate bigger payloads with EDNS0 and retry over TCP when    answers are truncated; we skip both for clarity.    """    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:        sock.settimeout(3)        sock.sendto(build_query(name), (server_ip, 53))        msg, _ = sock.recvfrom(4096)    _, _flags, qd, an, ns, ar = struct.unpack(">HHHHHH", msg[:12])    off = 12    for _ in range(qd):  # skip the echoed question section        _, off = read_name(msg, off)        off += 4    answers, off = read_records(msg, off, an)    authority, off = read_records(msg, off, ns)    additional, _ = read_records(msg, off, ar)    return answers, authority, additional  def resolve(name: str, server_ip: str = ROOT_IP, server_name: str = ROOT_NAME,            depth: int = 0) -> list:    """Walk referrals from a root server until someone gives us an answer."""    if depth > 10:        sys.exit("too many referrals -- giving up")    answers, authority, additional = ask(server_ip, name)     a_records = [r for r in answers if r[1] == TYPE_A]    if a_records:        print(f"asking {server_name} ({server_ip}) about {name} -> answer!")        return a_records     cnames = [r for r in answers if r[1] == TYPE_CNAME]    if cnames:        target = cnames[0][3]        print(f"asking {server_name} ({server_ip}) about {name} "              f"-> CNAME {target}, starting over from the root")        return resolve(target)     # No answer: this is a referral. The authority section names the servers    # one level down; the additional section may carry their IPs ("glue").    ns_names = [r[3] for r in authority if r[1] == TYPE_NS]    if not ns_names:        sys.exit(f"dead end at {server_name} -- no answer, no referral")    glue = {r[0]: r[3] for r in additional if r[1] == TYPE_A}    candidates = [(ns, glue[ns]) for ns in ns_names if ns in glue]    if not candidates:        # No glue: we must resolve the nameserver's own name first -- a whole        # separate walk from the root, just to learn who to ask next.        print(f"  (no glue for {ns_names[0]}; resolving it first)")        candidates = [(ns_names[0], resolve(ns_names[0])[0][3])]    # UDP is fire-and-forget: datagrams get dropped and servers go quiet.    # Real resolvers rotate through a zone's nameservers; so do we.    for next_ns, next_ip in candidates:        print(f"asking {server_name} ({server_ip}) about {name} "              f"-> referred to {next_ns} ({next_ip})")        try:            return resolve(name, next_ip, next_ns, depth + 1)        except TimeoutError:            print(f"  ({next_ns} timed out; trying the next nameserver)")    sys.exit("every nameserver timed out")  if __name__ == "__main__":    if len(sys.argv) != 2:        sys.exit(f"usage: {sys.argv[0]} <hostname>")    for _name, _type, ttl, ip in resolve(sys.argv[1]):        print(f"{sys.argv[1]} -> {ip} (cache this for {ttl}s)")

Run against my domain, it reproduces the dig +trace walk exactly:

$ python3 raw_socket_resolver.py ramanshrivastava.com
asking a.root-servers.net (198.41.0.4) about ramanshrivastava.com -> referred to l.gtld-servers.net (192.41.162.30)
asking l.gtld-servers.net (192.41.162.30) about ramanshrivastava.com -> referred to earl.ns.cloudflare.com (173.245.59.161)
asking earl.ns.cloudflare.com (173.245.59.161) about ramanshrivastava.com -> answer!
ramanshrivastava.com -> 216.198.79.1 (cache this for 300s)

The most instructive run wasn't the happy path, though. Resolving a .md domain, the script hit everything real resolvers deal with hourly: the first .md TLD server silently dropped the UDP query (three-second timeout, rotate to the next one), and the referral to Cloudflare's nameservers came without glue — no IP addresses attached — so the resolver had to stop, run an entire second resolution from the root just to find elly.ns.cloudflare.com's address, then resume the original walk. Every answer, incidentally, came back with TTL 300 — which tells you something about how the whole industry sets TTLs. Hold that thought.

You have the IP. Why is the page still slow?

DNS solved finding things. It did nothing for moving them. If your server is in Virginia and your reader is in Mumbai, every request crosses ~13,000 km of fiber, and no protocol cleverness beats the speed of light in glass — roughly 200,000 km/s, so ~65 ms one way, before a single router queue. A TLS handshake needs multiple round trips before the first byte of your page moves. Physics is the one vendor you can't negotiate with.

The fix arrived from an MIT algorithms course. In 1997 Daniel Lewin, Tom Leighton and colleagues published Consistent Hashing and Random Trees — a scheme for spreading cached objects across many servers such that servers joining or leaving barely disturb the mapping. In 1998 Leighton and Lewin turned it into a company, Akamai, just in time for the era when a single Star Wars trailer could set the whole web on fire. The idea: replicate the origin's content onto thousands of caches near the readers and steer each reader to a nearby copy. The late-90s web called this exotic; today we call it a CDN and it is how effectively all static content is served.

Note what a CDN is not: it's not hosting. Your origin still exists and still holds the truth — the CDN's edge holds expiring copies, exactly as a recursive resolver holds expiring copies of authoritative records. Same architecture, one layer down the stack, bigger payloads.

And the steering trick has converged too. Modern CDNs use anycast: roughly, 330 datacenters all shout "I am 104.16.132.229" into BGP, and the internet's routing fabric delivers each user to the nearest one making that claim. One IP address, hundreds of physical locations, zero coordination per request.

You can watch all of this in response headers. My own site splits the two layers between vendors — Cloudflare answers the where (those earl/elly nameservers), Vercel's CDN serves the actual bytes:

$ curl -sI https://ramanshrivastava.comserver: Vercelx-vercel-cache: HITx-vercel-id: cdg1::22qqm-1788127421354-bd82167efc37age: 387823

x-vercel-cache: HIT — served from the edge cache, my origin never woke up. cdg1 — the Paris edge, because I'm writing this from France; you'd hit a different one. age: 387823 — this copy of my homepage has been sitting in that cache for four and a half days.

Cloudflare's CDN speaks the same sentence in its own dialect, and you can watch a cache fill in real time. First request for a static asset on a Cloudflare-proxied site, then the same request seconds later:

$ curl -sI https://openskill.md/assets/index-B9wF6Yyh.jscf-cache-status: MISS          # edge didn't have it; fetched from origincache-control: public, max-age=31536000, immutable $ curl -sI https://openskill.md/assets/index-B9wF6Yyh.jscf-cache-status: HIT           # now it doesage: 0

(The HTML page itself reports cf-cache-status: DYNAMIC — Cloudflare doesn't cache HTML by default, only assets with cacheable extensions, a default that surprises nearly everyone the first time.)

graph TB U["Reader (Paris)"] --> E["Nearest edge node — cdg1 / CDG<br/>chosen by anycast, not by you"] E -->|"cache HIT<br/>x-vercel-cache: HIT / cf-cache-status: HIT"| F["Response in ~10–30 ms<br/>origin never touched"] E -->|"cache MISS — fetch from origin"| O["Origin server<br/>one region, possibly far away"] O -->|"response + Cache-Control policy"| S["Edge stores a copy<br/>next reader gets the HIT"] S --> G["Response (slower, but only once<br/>per edge per TTL)"]

Put the two diagrams side by side and the thesis stops being a metaphor. Origin with the truth; distributed caches near the askers; per-object expiry set by the origin; a steering mechanism to find your nearest copy. DNS built that pattern for 65-byte answers in 1983. Akamai rebuilt it for gigabytes in 1998. It's caching hierarchies all the way down.

Things engineers actually get wrong

Your resolver sees everything

One more consequence of the architecture before the finale. That recursive resolver doing your walks and holding your cache? It sees every domain you ever visit, timestamped, tied to your IP. By default that's your ISP, which in several countries has meant monetized browsing histories. Switch to 8.8.8.8 and it's Google. This is the quiet reason DNS became a privacy battleground: encrypted DNS (DoH/DoT) hides your queries from the coffee-shop Wi-Fi, but someone still terminates them — you're choosing which observer, not whether one exists.

Which is exactly why Cloudflare launched 1.1.1.1 on April 1, 2018 — free, KPMG-audited no-logging commitments, and consistently the fastest public resolver measured by DNSPerf. There's even a nice architectural tell buried in its privacy stance: 1.1.1.1 refuses to forward EDNS Client Subnet, the extension that leaks part of your IP to authoritative servers so that DNS-steered CDNs can geo-locate you. Cloudflare can afford that refusal because anycast CDNs don't need ECS — BGP already routed you to the nearest edge before any application logic ran. The resolver's privacy posture and the CDN's routing architecture are the same design decision. Which brings us to the point.

One network, every layer

Follow my domain through this post and count the hats one company wears. The authoritative servers answering for my zone: Cloudflare. The resolver you're encouraged to use for the lookup: Cloudflare. If I flipped my site's proxy toggle, the CDN and TLS terminator: Cloudflare. The WAF inspecting the request, the DDoS scrubbing, the edge compute: Cloudflare. It's 330+ cities, 100+ countries, one anycast network — and 95% of the internet-connected population is within 50 ms of it. Each layer that historically was a separate product from a separate vendor is the same machines answering on the same IPs.

I got to feel the endgame of this recently on a production system I run on GKE. Its entire public ingress is a Cloudflare Tunnel: no load balancer, no public IP, not a single open inbound port. Two small cloudflared pods dial out to Cloudflare's edge and hold the connection; the public hostnames are just proxied CNAMEs pointing at <tunnel-id>.cfargotunnel.com. From the internet's perspective my origin does not exist — Cloudflare's network is the front door, and the architecture decision record I wrote at the time gives the whole game away in one line: it "keeps DNS, TLS, CDN, and compute under one account." The convenience is real, which is exactly what makes it strategically interesting.

The compute layer completes the collapse. Here is a complete Cloudflare Worker — deployed live, you can click it:

export default {  async fetch(request) {    const { colo, city, country } = request.cf;    return Response.json({      answered_by: colo, // airport code of the Cloudflare datacenter you hit      city,      country,      note: "This same code runs in 330+ cities. You reached the closest.",    });  },};

Hit which-colo.raman-shrivastava-7.workers.dev and it tells you which city answered you — for me it says {"answered_by":"CDG","city":"Châtenay-en-France"}. Nobody chose a region, nobody configured replication; the code simply exists everywhere the CDN already was. Compute deployed like a cached object, steered by the same anycast that steers the phone book and the delivery truck.

The case against

An honest account has to sit with the cost of all this, because we've run the experiment. On November 18, 2025, a routine ClickHouse permissions change inside Cloudflare made a Bot Management configuration file double in size, a size-limit check failed, and a Rust .unwrap() on that error panicked the core proxy — across the entire fleet. X, ChatGPT, and a depressing fraction of the consumer internet returned 5xx together. It was Cloudflare's worst outage since 2019 (when one bad WAF regex went CPU-exponential globally), and it wasn't the last: a BGP mishap in February 2026 withdrew customers' routes for six hours. The postmortems are genuinely excellent — candid, technical, fast — and that is somehow the unsettling part. The blast radius isn't a bug they can fix; it's the product working as designed. One network, every layer, means one failure domain, every layer.

The numbers say we've accepted the trade. Per W3Techs, 23.4% of all websites sit behind Cloudflare — and among sites using any known reverse proxy, its share is 83.5%. The critics have a name for this: the new AOL — the open web re-centralizing into one company's walled garden. I think the label is evocative but wrong in an instructive way. AOL's moat was content you couldn't leave; Cloudflare's is infrastructure you're free to leave — change two NS records and you're out, which is a real difference in kind. The honest critique isn't lock-in. It's that when a fifth of the web makes the same free, individually-rational choice, exit rights don't help you on the day the shared proxy panics. Nobody's trapped, and everybody's down.

And the counterweight cuts the other way too: those 330 cities absorb DDoS attacks no independent site could survive, terminate TLS properly for millions of operators who'd botch it, and hand out for free what Akamai used to sell only to giants. Centralization is the price; the product is genuinely good. Both things are true, which is what makes it uncomfortable.

The file never went away

Strip the branding and the story is one idea applied recursively for forty years. One file at Stanford couldn't scale, so we sharded it into a hierarchy and cached the shards near the askers, with expiry dates — DNS. Pages couldn't cross oceans fast enough, so we cached them near the readers, with expiry dates — CDNs. Then code itself got cached at the edge like any other object — Workers. And one company noticed that finding things and delivering things were the same caching problem on the same network, built both layers on the same anycast IPs, and now answers somewhere north of a fifth of the web from the nearest of 330 cities.

We replaced one file at Stanford with a hierarchy of caches, then spent forty years pushing the caches closer to you. The file never really went away — it just learned to expire.

The resolver in this post is validated and runnable — full script as a gist. Point it at your own domain and watch the walk.