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:

URL frontierURLs waiting, per hostFetcherrobots.txt, politenessPage storepages for the indexParserextracts linksFilter + seen testnew URLs → frontiernext polite URLsave the pagepage to parseevery link found

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:

8-byte fingerprints
4 GB
Bloom, 1 in 4 million
1.98 GB
Bloom, 1 in 100
0.6 GB

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

http
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

1. A site's robots.txt request returns 503 Service Unavailable. What should the crawler do under RFC 9309?
2. The crawler fetches 331 pages per second overall but must pause 3.3 s between requests to any one host. At least how many hosts must be in progress at once?
3. The seen test uses a Bloom filter. What happens on a false positive?
4. A recipe site's filters (cuisine, diet, time, sort) create millions of URL combinations. Which defense helps most?

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.