Hotshard

Design a Web Crawler

You are handed a short list of seed URLs. Build the system that fetches those pages, pulls the links out of each one, follows every new link it has not already fetched, and keeps going — across billions of pages — until it has a stored, refreshable copy of a large slice of the web, ready for a downstream indexer to read. Do it fast enough to cover the web on a sensible cadence, and do it politely enough that you are never mistaken for an attack.

Predict ~1.5 h · Read ~50 min

The problem i

A web crawler is a loop. It takes a URL, fetches the page, saves the page, extracts the links on it, and adds the links it has not fetched before to a pile of URLs still to visit. Then it takes the next URL and does it again. On one machine, for a few hundred pages, this is almost nothing: keep a list of URLs to fetch, keep a set of URLs already fetched, and write each page to a folder on disk. Everything hard about this system shows up when you point it at the whole web, and it turns on two hard facts.

Here is the first hard fact. The crawl is a firehose, but its speed is not set by how fast your machines are. It is set by how gently you have to treat the sites you fetch. To cover a meaningful slice of the web on a sensible schedule you need to sustain thousands of pages a second. Common Crawl, a non-profit archive, pulled 2.6B pages in about fifteen days, which is roughly two thousand pages every second, and a real search engine runs a large multiple of that. But those two thousand pages a second are spread across millions of different websites, and you may not point all of that firehose at any one of them. A single popular site — a giant forum, a wiki, a news archive — can hold millions of URLs. If you fetch them one after another as fast as your fleet can go, you are no longer crawling that site. You are attacking it. So the real constraint is not "fetch fast." It is "fetch fast in aggregate, while fetching each individual host slowly and gently." Those two goals pull in opposite directions, and reconciling them is the force that shapes the whole design.

Here is the second hard fact. The crawler asks one question billions of times: "have I already fetched this URL?" Every page yields dozens of links, and most of them are links you have seen before. If the answer to "seen it?" is even a little slow, or if it takes too much memory, the crawl grinds to a halt or spends a fortune on machines that do nothing but remember URLs. And the memory is the real problem. A year of crawling easily surfaces tens of billions of distinct URLs. If you store each one as the raw text of the URL, you are holding hundreds of gigabytes of strings whose only job is to answer yes or no. Turning "have I seen this URL?" into a check that is both fast and cheap in memory is the second force that shapes this design.

Put the two facts together and you get the shape of the whole problem. A colossal aggregate fetch rate that must stay gentle on every individual host, and a have-I-seen check asked billions of times that must not cost a fortune in memory. Those two forces, not the fetching itself, are what this design is really about. Fetching a single page over HTTP is the easy part. Deciding which page to fetch next, and remembering what you have already done, is the whole game.

Two timings are worth pinning now, because decisions later turn on them. Looking a value up in an in-memory structure takes microseconds. A durable write to disk, the kind that has to survive a crash, takes about 10 milliseconds — thousands of times longer. And one number that will bite in the very first use case: a single DNS lookup, if it is not already cached, can take whole seconds, because it may bounce across several servers on the internet before it answers. Hold onto that gap; it decides how the fetch workers are built.

Functional requirements what it must do

  • Fetch a page from a URL, and keep doing it across billions of URLs.
  • Decide which URL to fetch next, so no single host is ever hammered.
  • Never fetch the same URL twice.
  • Store every fetched page durably, and avoid storing the same content twice.
  • Keep the stored pages current.

Non-functional requirements what it must be

  • Sustain thousands of pages a second in aggregate with no single node as the bottleneck.
  • Stay polite to every host, adaptively.
  • Lose a fetch worker without losing the crawl.

Out of scope left out on purpose

  • HTML parsing and link extraction
  • Ranking and indexing the crawled pages
  • The rate at which any one host may be fetched, as a counter
  • The HTTP fetch transport
  • Malware, spam, and adult-content filtering

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

POST /seedsAn operator hands the crawl a starting list of seed URLs. This bootstraps the crawl; it happens rarely and is not on any hot path. Answered with an empty accepted acknowledgement.
contract ▾
# An operator bootstraps the crawl with a list of seed URLs.
 POST /seeds {
    urls: [
      "https://en.wikipedia.org/wiki/Web_crawler",
      "https://news.ycombinator.com/"
    ]
  }
 202 Accepted                     # no body — the crawl runs asynchronously from here
INTERNAL next-urlThe loop's heartbeat, inside the system: a fetch worker asks for the next URL it is allowed to fetch, and gets back a URL whose host is due for another visit. This is the single operation the whole design is built to make correct and fast, and it is what the signature use case is about.
contract ▾
# The loop's heartbeat — a fetch worker leases the next URL whose host is due.
 (internal) next-url { workerId: "w_017" }
 { url: "https://en.wikipedia.org/wiki/Web_crawler",
    host: "en.wikipedia.org" }       # a host that is due for another visit
READ page-storeA downstream indexer reads the stored pages, iterating the store of fetched pages record by record. This is the crawl's output, consumed by the separate system that ranks and indexes.
contract ▾
# The downstream indexer iterates the stored pages, record by record.
 (internal) iterate page-store
 { url: "https://en.wikipedia.org/wiki/Web_crawler",
    fetchedAt: 1735689600,
    headers: { "content-type": "text/html" },
    body: "<!doctype html>…" }

part 1 The single-server version

Start with the smallest thing that works: one process on one machine, running the whole crawl loop.

Start with the vocabulary. You are given some seed URLs to begin from. The system fetches a page at each URL. Every page lives on a host — the website's domain, like en.wikipedia.org — and one host can own anywhere from one page to many millions. Doing all of this, over and over, is the crawl. Those words — seed URLs, a page, a host, the crawl — carry the whole problem.

The day-one design is a loop with three pieces of state. Keep a to-do list of URLs still to fetch, seeded with the starting URLs. Keep a set of URLs already fetched, so you do not fetch anything twice. And keep a folder of saved pages on disk. The loop is: take a URL off the to-do list, fetch the page over HTTP, save the page, extract the links, and for each link check the already-fetched set — if it is new, add it to the to-do list. Then take the next URL and repeat. For a few hundred pages on one site, this is genuinely fine, and nothing clever is missing.

SeedsCrawlerto-do list + seen-set + saved pagesone page at a time · durable · flat list
The whole system on day one.
Following a request
The crawl loop, in one box
  1. the seed URLs bootstrap the loop; the one Crawler pops a URL, fetches the page, saves it to a folder on disk, extracts the links, checks each against the raw seen-set, and enqueues the new ones — one page at a time~10 ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

It just does not survive being pointed at the web, and it fails in four separate ways — each of which forces a piece of the real design. The first failure is throughput: one process fetching one page at a time is idle almost the entire time, because fetching a page is mostly waiting — on DNS, on the network, on the far server. At one page at a time you manage a handful of pages a second, and the web has billions of pages; you would be crawling for centuries. The second failure is the to-do list itself: a plain first-in-first-out list has no idea which host a URL belongs to, so point it at a giant site and it fills with millions of URLs from that one host, clustered together, and the loop fetches them back-to-back as fast as it can — the exact behaviour that gets a crawler blocked or knocks the target offline.

The third failure is the already-fetched set: on one machine you hold every seen URL as a raw string in memory, which is fine for thousands and impossible for tens of billions — the raw strings alone would be hundreds of gigabytes, far more than one machine's memory. The fourth failure is that everything lives in one process: when it dies, the to-do list is gone, so the crawl has no idea what it still needs to do, and the saved pages may be gone too. Those four failures point the same way — the fetching has to spread across a fleet with many fetches in flight, the to-do list has to gain structure so it can be gentle per host, the already-fetched set has to become cheap enough in memory to hold tens of billions of entries, and the saved pages have to be kept durably, apart from the fleeting bookkeeping. The rest of this design does exactly that, one forced decision at a time.

part 2 The version that survives scale

This section walks the design by use case, in the order you actually have to decide things. First, get the shape right — a fleet of fetch workers pulling from a shared queue — and see the queue's fatal flaw still sitting there. Then face that flaw head-on and give the queue the structure it needs to stay polite to every host, which is the heart of the whole design. Then make "have I seen this URL?" a check cheap enough in memory to hold billions of entries. Then keep the crawl correct and current: catch the same content behind different URLs, schedule recrawls, and store the pages durably. And finally, confront what happens when the crawler, run without that politeness, becomes the load that takes sites down. The finished diagram waits at the end.

Two ways through this section

The five decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 Seeds in, pages out

On day one the entire crawl loop ran inside a single process. That does not scale, so the first job is to split it into separate roles that can each grow on their own. And even after the split, one shared queue in the middle still carries the same weakness the single box had.

deep dive 1 Seeds in, pages out
The question

The single-server version ran the whole loop in one process. The first move is to pull it apart into a shape that can grow. Three roles are obviously different. One role is fetching pages — the part that spends its life waiting on the network. One role is holding the list of URLs still to fetch. And one role is storing the pages that come back. Splitting them lets each scale on its own. A crawl needs to fetch thousands of pages a second, but each fetch is mostly waiting — so what fetches the pages, where do the URLs waiting to be fetched live, and where do the fetched pages go?

The HTTP fetch is an inlet, not a connection design, and it is worth saying why once and moving on. A worker "fetches a page over HTTP." Underneath that sentence is a whole world — holding TCP connections open, reusing them, negotiating TLS, tuning socket timeouts and retries. That transport layer is a real engineering problem, and it is one another board owns. Here it is an inlet: the worker asks for a page and gets bytes back. Re-deriving the connection layer would rebuild another board's signature, so the design names the fetch and moves on.

That leaves the real shape decision, which is about where state is allowed to live. The fetch workers must hold nothing durable, because they sit on the firehose and die routinely: when a worker dies, the handful of URLs it had in flight are simply never marked done, so they stay pending in the frontier and get handed to another worker later. Nothing is lost, because the frontier owns the work, not the worker. Push all the pending-work state into the shared frontier and all the fetched pages into the store, and the fleet becomes a pure fetcher you scale flat — which is why the two hard problems that follow, the politeness of the queue and the memory of the seen-set, land on the frontier and the URL-seen store and nowhere else.

A stateless Fetcher fleet pulling from a single shared URL frontier, writing to a Page store. Put fetching in a horizontally scaled Fetcher fleet, hand it URLs from a single shared URL frontier, and write pages to a Page store. The fleet is many stateless worker processes, each holding a large number of fetches in flight at once, so that while one fetch waits on the network, hundreds of others are in progress on the same worker. A worker takes a URL, fetches the page, hands the page to the store, extracts the links, and checks each link against the record of URLs already fetched — enqueuing the new ones. The workers hold no long-term state of their own; everything durable lives in the pieces around them. The list of URLs still to fetch becomes its own component, the URL frontier, and right now — established here as the baseline the next use case attacks — it is the simplest possible thing: a single shared queue that every worker pulls from, first URL in is the first URL out.

The fetchers scale by simply adding more stateless workers — the fetch rate is a matter of how many concurrent fetches the fleet can hold open, so more workers means more pages a second. The frontier hands out URLs; the store keeps pages. But drawn honestly as the baseline the next use case demolishes, the frontier is a single shared first-in-first-out queue, and it carries the fatal flaw from the single-server version straight into the fleet: a flat queue cannot be polite, because it has no idea which host a URL belongs to. The very next use case faces exactly that.

Under the hood — how it works

The Fetcher fleet is sized by throughput: the target pages a second divided by how many pages a second one worker can sustain. One worker's rate is set by how many fetches it holds in flight — Google's first crawler ran about three hundred open connections per process — so a worker's page rate is its concurrency divided by the average fetch time. Divide the fleet's target by that per-worker rate and you get the worker count. The fleet's capacity is near zero: a worker holds only its in-flight fetches and its DNS cache, nothing durable. That is the point of making it stateless.

DNS is the one part of the fetch that will quietly wreck you. Before a worker can fetch a host, it has to turn that host name into an IP address, and a DNS lookup that is not already cached can take whole seconds, because it may bounce across several name servers on the internet before it answers. If a worker blocks on that lookup, one slow resolution stalls a fetch thread for seconds, and a target of hundreds of pages a second falls apart. The fix, and it is the fix Google's very first crawler already used, is two things: cache DNS answers aggressively, and use an async DNS resolver so that a worker fires off many lookups at once and never sits blocked on any single one. Each worker keeps its own DNS cache. It is a well-known bottleneck with a well-known fix, fenced inside the fleet rather than drawn as its own box.

⚡ From production

Common Crawl's recent archive captured 2.6B pages from 47.5M hosts in about a fifteen-day window, which works out to roughly two thousand pages a second sustained — and that is a non-profit archive, not a commercial search engine. Google's first system already targeted more than a hundred pages a second across four processes, each with its own DNS cache, so a real fleet-scale crawler runs many times Common Crawl's rate.

Common Crawl — December 2024 Crawl Archive Now Available
SeedsURL frontiersingle shared FIFOFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeraw string setPage store · WARCappend pages
The board after this deep dive — “The crawl loop, in one box” traced, hop by hop. The numbered steps below walk the same path.
The crawl loop, in one box
  1. the seed URLs enter the URL frontier — the pile of URLs still to fetch~1 ms
  2. a worker takes the next URL off the frontier (single shared FIFO, for now — first in, first out)~1 ms
  3. the worker fetches the page over HTTP and appends it to the Page store~3 ms
  4. the worker extracts the links and checks each against the URL-seen store (a raw string set, for now)~1 ms
  5. the links it has not fetched before are enqueued back into the frontier~1 ms
use case 2 Two thousand pages a second, and you cannot hammer one host

That flat shared queue can hold the design together no longer. Keeping the whole fetch fleet working flat out sounds like a pure throughput problem — until you remember the one rule that forbids hammering any single website, which quietly turns it into a much harder one.

deep dive 2 Two thousand pages a second, and you cannot hammer one host
The question

The pipeline has a shape now, but the URL frontier is still the single shared queue from the last use case, and that queue carries the flaw that has been sitting in this design since the single-server version. The fleet has to stay busy at a couple of thousand pages a second. But a single host might own millions of the URLs in the queue, and you may only fetch that host gently, one page every so often. With one shared first-in-first-out queue, which URL does a free worker fetch next?

Follow the obvious answer first, because it is what most people reach for: make the shared queue bigger, or shard it across a few machines so it can hold more URLs and hand them out faster. The attraction is real — it is the design you already have. But making the queue bigger does not touch what actually breaks. Picture a giant forum with three million URLs. As the crawl discovers them, they pour into the shared queue and cluster together near the front. Now every free worker pulls the next URL, and the next, all from that one forum, back-to-back as fast as the fleet can go. Two things break at once: that one forum is hit by the full weight of the fleet — the denial-of-service you must never be — and every other host is stuck behind three million forum URLs, so the rest of the web is starved. A bigger queue holds more URLs and starves them harder; a sharded queue spreads the URLs but still has no idea that those three million belong to one host. The problem is not the queue's size. The problem is that a flat queue has no notion of which host a URL belongs to, and politeness is entirely about the host.

So how polite is "gentle," and where does that number come from? Not from a config file. The intuitive answer is that a site declares a Crawl-delay in its robots.txt and you obey it — true for some crawlers, Bing and Yandex among them, but false for the biggest one. Google's own documentation says plainly that Googlebot does not support the crawl-delay field at all. Instead the per-host pace is adaptive: read off how the host is actually behaving right now. If it answers quickly and cleanly, the crawler raises the rate and uses more connections; if it slows or returns errors, the crawler lowers the rate and backs off. The next-contact interval the heap uses is set by this live signal, not a number someone typed once. It is worth being precise about what this is not: it is not a counter ticking off requests per second against a limit — that counting machinery is a different, solved problem. Here the pace is a scheduling signal, and the frontier's job is to decide whose turn is next, and when.

A host-partitioned frontier — front queues for priority, back queues holding one host each, scheduled by a next-contact heap. Give the queue structure, because the structure is the design. Split the frontier into two layers with a scheduler between the fleet and the queues — the Mercator design. The first layer is a set of front queues: new URLs arrive here and are routed by priority, so the crawl is ordered by what matters more. The second layer is a set of back queues, and this is where politeness lives, held by a single invariant: each back queue holds URLs from exactly one host. All of one wiki goes in one back queue; all of that giant forum goes in another; a tiny blog gets its own. Because one back queue is one host, "be gentle to this host" becomes "do not pull from this back queue too often" — a property of a single queue, which is something you can actually schedule.

And the scheduling is the last piece: a min-heap of next-contact times. The frontier keeps, for each back queue, the earliest moment its host is allowed to be contacted again, and it keeps those moments in a heap so the soonest one is always on top. A free worker does not pick a URL directly — it asks the heap for the back queue whose host is due soonest, waits if that moment has not arrived, takes one URL from that back queue, and fetches it; then it sets that host's next-contact time a polite interval into the future and pushes it back on the heap. Run roughly three times as many back queues as fetch threads, so there are always eligible hosts and the fleet stays busy. The aha is that the hardest constraint — fast overall, gentle per host — is not a tension you tune away. It is a data structure: one host per back queue, and a heap that tracks which host is due next.

Under the hood — how it works

The frontier also carries the robots contract. Before fetching any host, the crawler fetches that host's robots.txt and obeys it — which paths are disallowed, and any host-specific rules. RFC 9309 says a crawler should support at least a 500 KiB robots.txt file and should not reuse a cached copy for more than 24 hours. So each host's back queue is really governed by two things: the robots rules cached for that host, and the adaptive next-contact time the heap schedules on. Both are per-host, both live with the frontier, and both are why the frontier is the box that owns politeness.

The queues are disk-backed; only the heads are in memory. The frontier holds the whole pending-URL set, which is enormous — a single fifteen-day crawl surfaces over a billion new URLs, and they queue up faster than they drain. So the front and back queues live on disk, and only the head of each queue and the scheduling heap are kept in memory. The heap is small — one entry per back queue, and there are only a few times as many back queues as fetch threads — so scheduling stays fast even though the queued URLs number in the billions. The frontier's failure story is the most serious on the board, because it is the durable work-list of the entire crawl: a lost worker costs only its in-flight URLs, but if the frontier itself is lost, the crawl loses its memory of what it still has to do. So the frontier must be persisted and replicated — it is the one component whose loss actually stalls the crawl.

⚡ From production

The two-layer front/back-queue frontier, with its one-host-per-back-queue invariant and its next-contact heap, is the Mercator design described in Introduction to Information Retrieval. The adaptive-rate behaviour is Google's own: its documentation states that Googlebot's crawl rate rises when a site responds quickly and falls when the site slows or returns errors, and separately that Googlebot does not support crawl-delay while Bing and Yandex do.

Google — How Google Interprets the robots.txt Specification
SeedsURL frontierhost-partitionedfront/back queuesnext-contact heapFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeraw string setPage store · WARCappend pages
The board after this deep dive — “The crawl loop, in one box” traced, hop by hop. The numbered steps below walk the same path.
The crawl loop, in one box
  1. the seed URLs enter the URL frontier — the pile of URLs still to fetch~1 ms
  2. the frontier hands the worker the next due host's URL — the heap pops the host due soonest, and one URL comes off its back queue~1 ms
  3. the worker fetches the page over HTTP and appends it to the Page store~3 ms
  4. the worker extracts the links and checks each against the URL-seen store (a raw string set, for now)~1 ms
  5. the links it has not fetched before are enqueued back into the frontier~1 ms
use case 3 Have I already fetched this URL?

A fetched page comes back stuffed with links, dozens of them, and each one has to be checked before it can join the queue. The check never changes: have we grabbed this URL before? The trouble is the crawler runs it billions of times, and the old raw-string way of remembering buckles long before that.

deep dive 3 Have I already fetched this URL?
The question

The frontier decides which URL to fetch next. But every fetched page yields dozens of links, and before a link can be added to the frontier the crawler has to answer one question, billions of times over: have I already fetched this URL? Get that check wrong and the crawl either loops forever over pages it has seen, or spends a fortune on machines that do nothing but remember URLs. Over a year of crawling, the record of URLs already fetched grows to tens of billions of entries. How do you hold that record so the check is both fast and cheap enough in memory to be affordable?

Follow the obvious answer first, because it is the one the single-server version used: store every URL you have fetched as a string, in a big distributed hash set, and check membership directly. It is technically correct — a hash set answers "seen it?" in about constant time, and it never makes a mistake. The trouble is the memory. Size it from the bytes up. A real crawler dataset measured the average URL at about 77 bytes. Ten billion URLs at 77 bytes each is about 770 gigabytes of raw strings — and that is before the overhead a hash table adds on top to index them. You are holding most of a terabyte of memory whose entire job is to answer a yes-or-no question. It works, and it is honest to say so, but it is enormously wasteful for what it does.

So the real question is not "hash set or something else" as a binary. It is: how much can you shrink that memory, and what do you have to give up to shrink it? A Bloom filter answers it by storing no URLs at all — only bits — which is why it beats storing the strings by roughly forty times, and why the published bits-per-element formula is independent of how long the URLs are. The asymmetry that makes it safe is mechanical: adding a URL sets several bits to one, and those bits are never cleared, so a genuine member always tests as present — the filter can miss, but it can never wrongly re-admit. That is exactly the property a crawler wants: never waste a fetch, occasionally miss one.

A Bloom filter for the URL-seen store — store no URLs, just bits. You do not actually need to store the URLs. You only need to answer "have I seen this one?" A Bloom filter does exactly that and nothing more: a bit array plus a few hash functions, where adding a URL sets a handful of bits and checking a URL tests those same bits. It never stores the URL itself, so it is dramatically smaller. Size it from the bits up. The published formula says a Bloom filter needs about 14.4 bits per element to hold a tenth-of-a-percent false-positive rate. Ten billion URLs at 14.4 bits each is about eighteen gigabytes — against 770 gigabytes for the raw strings. That is roughly forty times smaller, and it is the whole reason to use one.

The forty-times win comes with exactly one cost, and it is a cost a crawl can afford. A Bloom filter can return a false positive — say "yes, seen it" about a URL that was never fetched. But it never returns a false negative: it will never say "no, not seen" about a URL it actually has. So the only thing a false positive can ever cause is the crawler skipping a URL it had not really fetched. It can never cause the crawler to fetch the same URL twice. Read that trade carefully: the mistake the filter can make is occasionally missing a page, never wastefully re-fetching one, and the rate of that mistake is a knob you tune by spending a few more bits per element. Trading a tiny, tunable chance of missing a page for a forty-times memory reduction is a trade a web-scale crawl takes every time.

Under the hood — how it works

The URL-seen store's throughput job is one membership test per extracted link before that link is enqueued — a handful of hash computations, effectively constant time, run at a high rate but cheaply. Its capacity is the selection number itself: about eighteen gigabytes as a Bloom filter versus about 770 gigabytes as raw strings for the same ten billion URLs, growing by roughly a billion per crawl cycle. Capacity is what forces the representation here — not a bigger machine, a different structure.

Its failure story is gentle: a false positive wrongly skips a never-fetched URL, tunable by the false-positive rate, and there is never a false negative, so there is never a wasteful duplicate fetch. If the store's state is lost, the crawler forgets what it has fetched and will re-fetch some pages until it is rebuilt — and it can be rebuilt, because the Page store holds a durable record of what was actually fetched. The URL-seen store is an accelerator, not a system of record.

⚡ From production

Common Crawl's recent archive alone contained 1.1B previously-uncrawled URLs, so the seen-set grows by roughly that much every cycle, not once. The Bloom sizing is not a guess: the standard formula gives about 9.6 bits per element for a one-percent false-positive rate and about 14.4 bits to tighten that to a tenth of a percent, independent of how long the URLs are — which is exactly why it beats storing the strings.

Bloom filter — space and false-positive formula
SeedsURL frontierhost-partitionedfront/back queuesnext-contact heapFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeBloom filter~40× smallernever re-fetchesPage store · WARCappend pages
The board after this deep dive — “The crawl loop, in one box” traced, hop by hop. The numbered steps below walk the same path.
The crawl loop, in one box
  1. the seed URLs enter the URL frontier — the pile of URLs still to fetch~1 ms
  2. the frontier hands the worker the next due host's URL — the heap pops the host due soonest, and one URL comes off its back queue~1 ms
  3. the worker fetches the page over HTTP and appends it to the Page store~3 ms
  4. the worker checks each extracted link against the URL-seen store — a Bloom filter membership test in constant time, at about a fortieth of the memory raw strings would cost~1 ms
  5. the links it has not fetched before are enqueued back into the frontier~1 ms
use case 4 The same content behind different URLs, and pages that go stale

With a polite frontier and a cheap seen-check, the board finally runs clean. But a clean run is not the same as a correct, up-to-date archive, and the naive loop quietly falls short on both counts — the same page can hide behind many different URLs, and a page fetched once will not stay fresh forever.

deep dive 4 The same content behind different URLs, and pages that go stale
The question

The board reads well now: the frontier is polite, and the seen-check is cheap. Time to keep the crawl correct and current, which the naive loop ignores in two ways. Two different URLs routinely serve the same content — a syndicated article, a mirror, the same page with a tracking parameter tacked on — and the URL-seen store cannot catch that, because the URLs really are different. And separately, a page fetched last week has probably changed by now, so the crawl has to revisit it — and the obvious revisit policy is wrong. Both problems land on the durable output of the whole system, the Page store.

Start with the duplicate content, because it is the sharper myth. The instinct is to say different URLs mean different pages, so the URL-seen store already handles duplicates. That is false, and it is a different problem from the one the URL-seen store solves. The URL-seen store answers "did I fetch this exact URL string?" It has nothing to say about two different URLs that return the same bytes. And that case is everywhere: a news story syndicated to a dozen sites, a documentation page mirrored across domains, the identical article reachable with and without a tracking parameter. Each of those is a distinct URL, so the URL-seen store correctly says "new," and the crawler correctly fetches it — and then the store would hold the same content many times over. Catching that needs a content-level check, which is what simhash is for.

Then freshness, where the intuitive policy loses. Recrawling fast-changing pages more often feels right and measures worse, because a page that changes every hour is stale no matter how often you revisit it — recrawl budget poured into it is wasted, while the steady pages you neglected go out of date. Spreading the budget uniformly keeps more of the archive fresh on average. Three distinct fixes — content dedup, durable storage, uniform recrawl — all land on the one durable output of the crawl, which is why this use case is also where the Page store becomes a real store.

A WARC store, a simhash near-dup gate on its write path, and a uniform recrawl feed. Catch duplicate content with a simhash gate on the write path, store pages in a compressed WARC container, and drive recrawls at a uniform rate. A simhash is a fingerprint computed from a page's content such that near-identical pages get near-identical fingerprints — unlike an ordinary hash, where a one-character change scrambles the whole value. The store computes a sixty-four-bit simhash of each incoming page and compares it against the fingerprints it has already stored; if a new page's fingerprint differs from an existing one in at most a few bit positions — the validated threshold is k=3, at most three differing bits — the two are near-duplicates, and the store skips or links the new copy instead of keeping it whole. This is a gate on the write path of the Page store: content comes in, the fingerprint is checked, and near-duplicates are caught before they cost storage. Google researchers validated this at a repository of eight billion pages, the scale that matters here.

Now freshness. Pages change, so the crawl feeds already-fetched pages back through the frontier to refetch them, on some schedule. The intuitive schedule is obvious and wrong: recrawl fast-changing pages more often than slow-changing ones — a proportional policy. It feels right, and it is worse. Cho & Garcia-Molina's foundational study tracked 720k pages across 270 sites for four months and found that a uniform recrawl rate — revisit every page at the same cadence — produces better average freshness than the proportional one. The intuition fails because a page that changes constantly is nearly always stale no matter how often you revisit it, so pouring recrawl budget into it is wasted, while the steady pages you neglected drift out of date. So the recrawl feed runs at a uniform rate, re-enqueuing already-crawled URLs into the frontier's front queues, competing for the same fetch capacity as fresh crawling.

Under the hood — how it works

The Page store's throughput is one write per successfully fetched, non-duplicate page — a couple of thousand a second at Common Crawl's cadence, a large multiple at fleet scale, each write first cleared by the simhash gate. Its capacity is the both-sizings habit landing from the bytes up: Common Crawl stored 394 TiB of raw content as 80.9 TiB of compressed WARC for its 2.6B-page archive, which is about thirty kilobytes stored per page after compression — close to the twenty-two-kilobyte raw-HTML median, because the WARC container overhead and the compression roughly cancel. The rule of thumb is pages times about thirty kilobytes, so one monthly archive is on the order of eighty terabytes.

Its failure story is the serious, durable one: this is the crawl's system of record, so losing a shard loses real, fetched pages, which means it must be replicated and backed up. The durability of the whole system lives here — and it earns that expense, because the frontier and the URL-seen store can both be rebuilt from the Page store. It is the one place the crawl's actual work is safe. Its format is a WARC-style container, a standard web-archive format that packs many fetched pages, with their HTTP response headers, into large compressed files in an object store; the downstream indexer reads it back record by record.

⚡ From production

Common Crawl's recent archive stored 394 TiB of content in 80.9 TiB of compressed WARC files, about a 4.9× compression ratio, across 2.6B pages — about thirty kilobytes stored per page. Google researchers validated sixty-four-bit simhash fingerprints with a k=3 near-duplicate threshold at an eight-billion-page repository, and Cho & Garcia-Molina's freshness study is the source of the counterintuitive finding that uniform recrawl beats proportional.

Common Crawl — December 2024 Crawl Archive Now Available
SeedsURL frontierhost-partitionedfront/back queuesnext-contact heapFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeBloom filter~40× smallernever re-fetchesPage store · WARCcompressed WARC~30 KB/page · replicatedsimhash near-dup gate
The board after this deep dive — “The crawl loop, in one box” traced, hop by hop. The numbered steps below walk the same path.
The crawl loop, in one box
  1. the seed URLs enter the URL frontier — the pile of URLs still to fetch~1 ms
  2. the frontier hands the worker the next due host's URL — the heap pops the host due soonest, and one URL comes off its back queue~1 ms
  3. the worker writes the fetched page to the WARC store — unless the sixty-four-bit simhash gate finds it is a near-duplicate of content already stored~3 ms
  4. the worker checks each extracted link against the URL-seen store — a Bloom filter membership test in constant time, at about a fortieth of the memory raw strings would cost~1 ms
  5. the links it has not fetched before are enqueued back into the frontier~1 ms
  6. separately, the recrawl feed re-enters already-crawled URLs into the frontier's front queues at a uniform rate, keeping the archive current~1 ms
use case 5 When the crawler is the load

For a crawl that behaves itself, the design is finished — every box does its job. This last use case breaks it from an unexpected direction: nothing on the board fails. The damage lands on the websites out there, once every crawler at once forgets its manners.

deep dive 5 When the crawler is the load
The question

Step back and look at what is built. It is correct and complete for a well-behaved crawl: the frontier is polite, the seen-check is cheap, content duplicates are caught, and the archive stays fresh. Now the premise arrives already broken, the way a real incident does — and the twist is that the thing that breaks is not any component on this board. It is what happens to the sites being crawled when crawlers, in aggregate, stop being polite. The frontier is fine. The URL-seen store is fine. The Page store is fine. Real websites fall over anyway. The Wikimedia Foundation, which runs Wikipedia and its sibling projects, reported that automated crawlers had become a serious operational problem: bots were generating at least 65% of the most resource-expensive traffic while being only about 35% of pageviews, and the bandwidth for downloading multimedia grew 50% in little more than a year. Nothing in Wikimedia's own infrastructure had a bug.

The instinct is to read the incident as "the sites should just block the bots" or "add more capacity at the origin." Both miss the mechanism, and the mechanism is the whole lesson. Bots consume two-thirds of the expensive traffic while being a third of pageviews because of a cache-miss asymmetry: humans cluster on popular pages that sit in a content delivery network's edge cache, cheap and close to the user, while a crawler bulk-reads the long tail of unpopular pages that miss the edge and get forwarded to the origin core datacenter, the expensive place to serve from. The crawler's load hurts more than its pageview share suggests not because it makes more requests, but because it makes the costly ones humans never trigger.

So the engineering answer is not at the sites; it is in the crawler's own scheduling. You can build a flawless frontier, a perfectly cheap URL-seen store, and a durable, deduped page store — everything in the four use cases before this — and the crawler can still be a disaster, because the thing that fails is not any component. It is the crawler's relationship with the hosts it reads. The frontier's adaptive per-host pace, which looked like good-citizen politeness, is really the load-bearing contract: read each host's live health and let the crawl slow itself down — widen the interval when a host slows, back off hard on errors, honor robots — before the origin has to defend itself.

Treat adaptive back-off and robots compliance as the load-bearing contract. Why do bots consume two-thirds of the expensive traffic while being a third of pageviews? Because of a cache-miss asymmetry. A human reader clusters on popular pages — the same few thousand articles everyone reads — and those pages sit in a content delivery network's edge cache, close to the user, cheap to serve. A crawler does the opposite: it bulk-reads the long tail, every obscure article, every rarely-visited page, precisely the pages that are not in the edge cache. So a crawler's requests miss the cache and get forwarded to the origin core datacenter, the expensive place to serve from. The crawler's load is disproportionately expensive not because it makes more requests, but because it makes the costly requests — the ones humans never trigger. So the frontier's adaptive back-off and robots compliance are the load-bearing contract, not a nicety.

Back in the frontier, the per-host pace was adaptive: speed up when a host responds well, slow down when it slows or errors. That looked like good-citizen politeness. The Wikimedia report shows it is actually the contract that keeps a crawler from becoming the incident. A crawler that ignores adaptive per-host pacing — that fetches every host as fast as its fleet allows — does not just risk getting blocked; at aggregate scale it becomes a de-facto denial-of-service on the origins it reads, and the cache-miss asymmetry is what makes that load bite so hard. So the frontier's job under load is to degrade gracefully against the origin's health: widen the next-contact interval when a host slows, back off hard when it returns errors, and honor the robots contract about what not to fetch at all. The reframe is bigger than this board: a crawler is a guest on millions of servers it does not own, and its politeness is not manners — it is the mechanism that keeps it from taking those servers down.

Under the hood — how it works

Graceful degradation here is a policy on the frontier's scheduling, not a component, which is why it is a badge and not a box. Widening the next-contact interval trades throughput for not overwhelming a slowing host; backing off hard on errors trades a temporary halt for not compounding an origin's distress; honoring the robots contract keeps the crawler off paths it was told not to fetch. All three throttle the crawler against each origin's health, before the origin has to defend itself. The one thing none of them does is add capacity, because no capacity you add changes the crawler's relationship with the servers it reads.

The frontier already faces a smaller version of this every day: a single enormous host can dominate the crawl the way crawlers in aggregate dominate a site, and the fix is the same shape — schedule against the host's live behaviour with the next-contact heap and the adaptive interval, rather than blasting it at a fixed rate. The Wikimedia incident is that same skew at web scale, and the same answer — adapt to the load in front of you instead of pretending one pace fits every host — is what keeps the crawler from being the outage.

⚡ From production

Every number here is Wikimedia's own, from its recent engineering report: automated crawlers generate at least sixty-five percent of its most resource-expensive traffic while being about thirty-five percent of pageviews, and multimedia bandwidth grew by half in little more than a year. The report's own explanation is the cache-miss asymmetry — bots bulk-read the long tail of unpopular pages, which miss the edge cache and get forwarded to the core datacenter, while humans cluster on already-cached popular pages. That is what makes adaptive politeness a load-bearing contract rather than a courtesy.

Wikimedia Foundation — How crawlers impact the operations of the Wikimedia projects
SeedsURL frontierhost-partitionedfront/back queuesnext-contact heapadaptive back-offFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeBloom filter~40× smallernever re-fetchesPage store · WARCcompressed WARC~30 KB/page · replicatedsimhash near-dup gate
The board after this deep dive — “The crawl loop, in one box” traced, hop by hop. The numbered steps below walk the same path.
The crawl loop, in one box
  1. the seed URLs enter the URL frontier — the pile of URLs still to fetch~1 ms
  2. the frontier hands the worker the next due host's URL — and under aggregate load it widens the interval and backs off on errors, degrading against each origin's health~1 ms
  3. the worker writes the fetched page to the WARC store — unless the sixty-four-bit simhash gate finds it is a near-duplicate of content already stored~3 ms
  4. the worker checks each extracted link against the URL-seen store — a Bloom filter membership test in constant time, at about a fortieth of the memory raw strings would cost~1 ms
  5. the links it has not fetched before are enqueued back into the frontier~1 ms
  6. separately, the recrawl feed re-enters already-crawled URLs into the frontier's front queues at a uniform rate, keeping the archive current~1 ms
Where this design sits on consistency (and CAP)

This design makes opposite durability choices for its two kinds of state, on purpose, because they are worth completely different things. The crawl pipeline — the frontier of pending URLs, the fetch fleet, and the URL-seen store — chooses availability and throughput over strong consistency. A fetch that drops is simply retried later; a Bloom filter's rare false positive is tolerated because it only ever skips a page, never re-fetches one; and a lost fetch worker costs only its handful of in-flight URLs, which stay pending in the frontier. None of this coordinates to be exactly right, because the crawl is eventually complete, not instantaneously correct.

The Page store makes the other choice. The fetched pages are the crawl's actual product — the thing a downstream indexer reads and the business keeps — so a lost shard is a real, unrecoverable harm, not one the next fetch repairs. So the Page store is the durability-leaning dataset: it lives in a replicated, backed-up object store, and it is the one place the crawl's work is safe. That is exactly why the frontier and the URL-seen store can afford to be cheap and rebuildable — both can be reconstructed from the durable record of what was actually fetched. Durability is bought where it matters and declined where it does not.

There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being wrong, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would cost. Here a skipped or slightly stale page costs almost nothing while a lost archive of fetched pages costs everything, so the design takes availability and throughput for the crawl pipeline and pays for durability only on the Page store.

CAP theorem — the partition trade-off, per operation
The finished board

That is the whole machine, one crawl loop with a durable spine, and every box on it was placed by one of the deep dives above. Seeds enter the frontier, whose front queues order by priority and whose back queues hold one host each, scheduled by a min-heap of next-contact times so the crawl stays fast in aggregate and gentle per host. A stateless fetch fleet takes the next due host's URL and fetches the page; each page goes to a replicated WARC store with a simhash gate on its write path that keeps the same content from being stored behind different URLs; and every extracted link is checked against a Bloom filter that answers "have I fetched this?" at about a fortieth of the memory raw strings would cost. A recrawl feed keeps the archive current, and under aggregate load the frontier's per-host scheduling backs off against each origin's health — the contract that keeps the crawler from becoming the load that takes sites down. If you carry away one sentence, make it the one the whole design turned on: when a flat queue cannot express the constraint, the answer is structure, not size.

SeedsURL frontierhost-partitionedfront/back queuesnext-contact heapadaptive back-offFetcher fleetstatelessasync DNS · ~300 connsURL-seen storeBloom filter~40× smallernever re-fetchesPage store · WARCcompressed WARC~30 KB/page · replicatedsimhash near-dup gate
The finished design — every box placed by one of the deep dives above.
What's on the diagram
Seeds
Seeds. The operator's starting list of URLs, handed to the crawl once to bootstrap it. It is not on any hot path — the seeds enter the frontier, and from there the crawl feeds itself, following the links it discovers.
URL frontier
URL frontier. The signature: the durable work-list of the whole crawl. Front queues order URLs by priority; back queues hold URLs from exactly one host each, so politeness is a property of a single queue; and a min-heap of next-contact times hands a free worker the host that is due soonest — fast in aggregate, gentle per host. It carries the robots contract and an adaptive per-host pace read off each host's live response, and under aggregate load that back-off is the load-bearing contract that keeps the crawler from taking a site down. The queues are disk-backed with only the heads and the heap in memory, and it is the one box whose loss stalls the crawl, so it is persisted and replicated.
Fetcher fleet
Fetcher fleet. Stateless HTTP fetch workers, each holding hundreds of fetches in flight and its own async DNS resolver and cache, so one slow lookup never stalls a thread. A worker takes the next due host's URL, fetches the page, hands it to the store, and checks each extracted link against the URL-seen store. It holds nothing durable, so a dead worker's in-flight URLs simply stay pending in the frontier and get re-handed out. The HTTP transport underneath is treated as an inlet — another board's problem, not this one's.
URL-seen store
URL-seen store. A Bloom filter answering "have I fetched this URL?" in constant time — about eighteen gigabytes for ten billion URLs against roughly seven hundred and seventy as raw strings, some forty times smaller. It never returns a false negative, so it never causes a duplicate fetch; a rare false positive only ever skips a never-fetched URL, tunable by spending a few more bits. It is an accelerator, not a system of record, and rebuilds from the Page store.
Page store · WARC
Page store · WARC. The crawl's durable system of record — a compressed WARC-style container in an object store, about thirty kilobytes per page, replicated and backed up. A sixty-four-bit simhash gate on its write path catches the same content behind different URLs, which the URL-seen store cannot. The frontier and the URL-seen store both rebuild from it, and a uniform recrawl feed re-enters already-crawled URLs from here into the frontier to keep the archive current.
What this design never covered — raise it yourself
HTML parsing and link extraction

A strong follow-up is: how do you actually get the links out of a page? The honest answer names it as a parsing problem this board treated as a step, not a systems-scale decision — it does not change which boxes exist or how they are sized. Name it as the cut it is.

Ranking and indexing the crawled pages

The deepest version of "what is the crawl for" is the search index built on top of the page store — inverted indexes, something like PageRank. The strong answer names it as a separate downstream system that reads the page store, not part of getting pages fetched and stored.

The per-host rate as a counter

The frontier's politeness was framed as a scheduling decision — whose turn is next, and when. The deeper mechanism, actually counting and capping requests per second to an endpoint with a token or leaky bucket, is its own well-defined problem with its own board. Name it as the piece this design treated as solved, rather than re-deriving counter machinery.

The HTTP fetch transport

This design took the fetch as an inlet. The follow-up is how a worker holds connections open, reuses them, and negotiates TLS at hundreds of concurrent fetches — which is the persistent-connection and transport-tuning problem another board owns. Name it and reach for that, rather than re-deriving a connection layer here.

Crawl traps and infinite URL spaces

A sharp interviewer may ask what stops the crawler drowning in a calendar that generates a new URL for every day forever, or a faceted-search page with unbounded filter combinations. The honest answer names it as a real practical hazard — the frontier needs limits on per-host depth and URL patterns so one host cannot generate infinite work — and admits this board treated it as a known hardening detail rather than a sourced design number.

Go deeper — the primitives this design leaned on
  • Bloom filterthe probabilistic membership structure that answers "have I seen this?" in constant time at a fraction of the memory, trading a rare false positive for a large memory win; it is exactly the URL-seen store

Feedback on this problem →