System designDeep dive11 min
Web crawling
A crawler is a loop: take a URL, fetch it, extract its links, queue the new ones. At scale that loop can overload small sites, fetch the same page under a thousand URLs, and wander forever in generated pages. A good crawler is polite per host, remembers every URL it has seen in a few bytes, skips duplicate content, avoids traps, and recrawls each page about as often as it changes.
1 · The problem
A crawler is a simple loop that becomes harmful at scale
A recipe search engine crawls 40,000 cooking sites, from large publishers to one-person baking blogs on shared hosting. It knows about 200 million URLs and wants every page refreshed within a week. The core is the same loop every crawler runs:
The loop feeds itself: links that pass the filter and seen test go back into the frontier. Everything else on this page is about controlling what goes back into it and how fast it comes out.
Run naively, the loop goes wrong in predictable ways. Relative links mean a page's links mostly point to the same host, so a simple first-in, first-out queue sends bursts of requests to one server. The same page is reachable under many URLs. Some sites generate pages without end. And once everything has been fetched, it starts going stale:
Failure | What it costs | Section |
|---|---|---|
Bursts to one host | Small sites slow down or fail; owners block you | Politeness |
Fetching a URL twice | Wasted fetches; loops between pages | URL seen test |
Same content, many URLs | Duplicate work and duplicate results | Duplicates |
Endless generated pages | The crawl never finishes a site | Traps |
Old copies | Search shows recipes that changed or vanished | Recrawling |
2 · Politeness
Be polite per host: obey robots.txt, space out requests, and back off when a site struggles
Before fetching anything from a host, a crawler reads its robots.txt, a file of allow and disallow rules per crawler, standardized as RFC 9309. The rules that matter in practice:
- The most specific rule wins: the matching rule with the longest path; on a tie, allow beats disallow.
- A 4xx response (no file) means everything may be crawled.
- A 5xx response or a network error means nothing may be crawled until it is fixed.
- Cache it for at most 24 hours, and parse at least the first 500 KiB.
- It isn't access control: anyone can ignore it, so it can't protect private pages.
Then the crawler limits its own pace for each host. A crawl that only kept one request at a time per server still drew complaints, because pages arrived back to back with no pause. The Mercator crawler waited 10 times as long as the last download took before contacting the same host again; the Heritrix crawler has the same setting as a multiple of the last fetch time, with a minimum and maximum:
One request at a time, with a pause that grows when the site is slow. Server errors and 429 responses lower the rate further, so the crawler adapts to what the site can handle.
Server errors are a signal, not just a failure. Google lowers its crawl rate for a site that returns 5xx errors or 429 (too many requests), in proportion to how many URLs fail, and raises it again gradually once the site responds normally. The Crawl-delay line some robots.txt files contain isn't part of the standard, and Google ignores it.
3 · The frontier
The frontier is a queue per host, ordered by when each host may be contacted next
Politeness reshapes the frontier. Mercator's design splits it in two. A front end holds URLs in a few queues by priority. A back end keeps many first-in, first-out queues, each holding URLs of a single host, and a heap of those queues ordered by the time each host may next be contacted. A fetching thread takes the queue at the top of the heap, waits if its time hasn't come, fetches one URL, and puts the queue back with its next allowed time.
The arithmetic shows why there must be far more host queues than threads:
Quantity | Value | How |
|---|---|---|
Pages per week | 200 million | Every known URL once |
Pages per second | ≈ 331 | 200,000,000 ÷ 604,800 s |
Per host | One page every 3.3 s | 0.3 s fetch + 3 s pause |
Hosts in progress at once | ≈ 1,100 | 331 × 3.3 |
Most one host gets in a week | ≈ 183,000 pages | 604,800 ÷ 3.3 |
The total rate is easy; one host is the limit. A publisher with 500,000 recipe pages needs 500,000 × 3.3 s ≈ 19 days at that pace, so its pages can't all be refreshed weekly, and the crawler has to choose which ones to fetch. Mercator ran three times as many host queues as threads to keep every thread busy, and kept most of each queue on disk, because a large frontier outgrows any machine's memory.
Priorities decide what gets fetched first. A site's crawl demand is driven by popularity, staleness, and how many of the site's known URLs are worth fetching at all. Sites can help with a sitemap, a file listing their URLs, at most 50,000 URLs or 50 MB per file; Google uses a page's last-modified date from it only when the dates prove accurate, and ignores the priority and change-frequency fields.
4 · Seen URLs
Normalize every URL, then remember it in as few bytes as possible
A link is checked against everything already seen before it enters the frontier. First it is normalized, so that equivalent spellings compare equal. The URL standard (RFC 3986) lists safe rules: lowercase the scheme and host, drop a default port such as :80, turn an empty path into /, decode needlessly percent-encoded characters, and resolve . and .. segments. The #fragment part is dropped too, because it is never sent to the server. A site's own redirects and rel="canonical" links point to its preferred URL for duplicates.
Then comes the seen test, which must be fast for hundreds of millions of URLs. Mercator stored an 8-byte fingerprint of each URL instead of the URL itself. A Bloom filter (Bloom filters) is smaller still: Heritrix's default holds 125 million URLs in about 495 MB with a false-positive chance of 1 in 4 million. The recipe engine has discovered about 500 million distinct URLs over time, of which 200 million are worth fetching, and the seen set must hold all of them:
Fingerprints: 500 million × 8 bytes. Bloom filter bits per URL ≈ 1.44 × log₂(1/p): 1.44 × 22 ≈ 31.7 bits for 1 in 4 million, and about 9.6 bits for 1 in 100.
A Bloom filter's error is one-sided, and for a crawler that decides which way it fails. It never says "new" for a URL already seen, so nothing is fetched twice because of it. It sometimes says "seen" for a URL that is new, and that page is silently never crawled. At 1 in 100, that is 1 page in every 100 new URLs; at 1 in 4 million, a few hundred out of a billion.
5 · Duplicates & traps
Duplicate content and endless URL spaces waste the budget unless you cap them
Different URLs often return the same page: print versions, tracking parameters, mirrored sites. Mercator ran a content-seen test after each download, comparing a fingerprint of the content with those already stored, and stopped processing exact copies. Many more pages are near-duplicates, the same recipe with a different ad or timestamp. Simhash, a 64-bit fingerprint in which similar pages differ in only a few bits, catches them: treating pages within 3 differing bits as duplicates works at the scale of 8 billion pages.
A near-duplicate's outgoing links are probably near-duplicates too, so the crawler can skip them as well. That matters most for crawler traps: sites whose URLs never run out. A recipe site's filters (cuisine, diet, time, sort order) combine into more URLs than the site has recipes, and calendars or scripts that link to "next" forever do the same. Filter-based navigation can generate infinite URL spaces and lead crawlers to fetch huge numbers of useless pages.
Defense | What it stops |
|---|---|
Normalize parameters; drop known tracking ones | Many spellings of one page |
Cap pages and link depth per host | Calendars and endless "next" links |
Budget pages per domain by links from other domains | Spam farms that generate millions of hosts |
Skip near-duplicates and their links | Filter and sort variants of the same list |
The third row comes from a research crawler that fetched 6.3 billion pages from a single server in 41 days. Spam sites can create unlimited pages and subdomains, but not unlimited links from other domains, so it gave each domain a page budget in proportion to those links.
6 · Recrawling
Recrawl each page about as often as it changes, and let unchanged pages cost almost nothing
A crawl is never finished: the recipe engine's index is only as fresh as its last visit to each page. Pages change at very different rates. A site's home page and its newest recipes change daily; a recipe from 2014 may never change again. Staleness and popularity drive how often a page is recrawled, and Mercator's prioritizer used each URL's download history, such as whether it had changed since the last fetch.
A recrawl of an unchanged page can cost the site almost nothing. The crawler sends back the page's last ETag or Last-Modified value, and if nothing changed the server answers 304 Not Modified with no body:
A conditional recrawl
GET /recipes/rye-bread HTTP/1.1
Host: bakingblog.example
If-None-Match: "v7-3f9a"
If-Modified-Since: Tue, 29 Sep 2026 08:12:00 GMT
HTTP/1.1 304 Not Modified
ETag: "v7-3f9a"Google's crawlers support both headers; ETag is the less error-prone of the two. Few sites make use of it: only about 0.017% of Google's fetches could be served from cache, down from 0.026% ten years earlier. So the crawler should also learn from what it sees: a page that came back unchanged several times in a row can wait longer before the next visit.
7 · Scaling out
Partition the crawl by host, and a checklist
When one machine isn't enough, split the crawl by host. In Mercator's distributed version each process owned a set of host names, and its frontier, seen set and DNS cache held only those hosts; a link to another process's host was sent there in batches. Because about 80% of links are relative, most links never left the process that found them, and per-host politeness stays in one place (Consistent hashing keeps the assignment stable as machines join).
DNS deserves its own attention. In Mercator, resolving host names took 70% of each thread's time, because the system resolver handled one uncached lookup at a time; a resolver that sent many queries in parallel to a local name server cut that to 14%. The checklist for the recipe engine:
Decision | Choice for the recipe engine |
|---|---|
robots.txt | RFC 9309 rules; cached up to 24 hours; 5xx means stop |
Politeness | One request at a time per host; pause 10 × the last fetch time |
Back-off | Slow down on 429 and 5xx; recover gradually |
Frontier | Priority queues in front; one queue per host behind; mostly on disk |
Seen test | Normalized URLs in a Bloom filter sized for the expected count |
Duplicates | Content fingerprints; simhash for near-duplicates |
Traps | Per-host caps; budgets by links from other domains |
Freshness | Recrawl by observed change rate; conditional requests |
Scale-out | Partition by host; batch cross-partition links; parallel DNS |
Check yourself
4 questions
Takeaways
Remember this
- Politeness is per host: follow robots.txt, keep one request at a time with a pause, and back off on 429 and 5xx.
- Build the frontier as priority queues feeding one queue per host, ordered by each host's next allowed time.
- Normalize URLs and keep the seen set in fingerprints or a Bloom filter, accepting that a false positive skips a page.
- Fingerprint content to drop exact and near-duplicates, and cap or budget hosts to escape traps.
- Recrawl by observed change rate with conditional requests, and scale out by partitioning hosts across machines.