sehaj/learn
System design/15 Classic designs

15.06 · design · 22 min

Design: a web crawler

A crawler that fetches a billion pages a month without hammering anyone: a URL frontier with a queue per host (with a lab), robots.txt, URL normalisation (tested) and a seen-URL filter, splitting hosts across machines, and the traps that keep crawlers busy forever.

The prompt

A crawler's core loop fits in five lines: take a URL, fetch it, store it, extract links, add the new ones. Every interesting part of the design is about what that loop does to other people's servers, and to itself, at a billion pages.

Design it yourself firstmidseniorqueuespolitenessdeduplicationpartitioning

Design a crawler for a search engine. Starting from a list of seed URLs, it should fetch 1 billion HTML pages a month, store them for an indexer, and keep finding new pages through links. It must be polite to every website, never fetch the same page twice in one pass, and recrawl pages that change often.

22:00

1. Requirements

Functional: fetch pages from seeds outward; store each page and its metadata; extract and follow links; recrawl pages based on how often they change.

Non-functional:

  • Polite: one request at a time per host, with a pause between requests, and obey robots.txt.
  • Scalable: 1 billion pages a month, more by adding machines.
  • Robust: slow servers, malformed HTML and infinite link structures can't stall it.
  • Restartable: a crashed node resumes without losing its frontier.

2. Estimates

ValueWhat it decides
Fetch rate1 billion ÷ 2.6 million seconds ≈ 400 pages a secondAsynchronous I/O, thousands of connections
Bandwidth400 × 100 KB ≈ 40 MB/s, about 320 Mbit/sA handful of machines on a good network
Storage100 TB a month before compressionObject storage, compressed
Seen URLs10 billion over timeA Bloom filter at 1% is about 12 GB; exact fingerprints, 80 GB, sharded
Hosts in flightWith a 2 s pause per host, 400 pages a second needs 800+ hosts active at onceThe frontier must juggle many hosts

The last row is the one that shapes the design.

3. High-level design

One crawler node
next polite URLnew?new URLsURL frontierpriority + per-host queuesFetchersasync, DNS + robots cachesWebsitesPage storeobject storageParserlinks + fingerprintSeen filterURLs + contentIndexer
Many such nodes run in parallel, each owning a share of the hosts.

5 steps. Use Next and Back to follow the request.

Text version
  1. A fetcher asks the frontier for the next URL whose host may be contacted now.
  2. It checks the host's cached robots.txt and its DNS cache, then fetches the page with a timeout and a size limit.
  3. The parser fingerprints the content (skipping exact duplicates), stores the page, and extracts links.
  4. Each link is normalised and checked against the seen-URL filter; new ones go back into the frontier.
  5. The indexer reads stored pages on its own schedule, so a slow indexer never stops the crawl.
  • URL frontier → Fetchers: next polite URL
  • Fetchers ↔ Websites
  • Fetchers → Parser
  • Parser → Page store
  • Page store → Indexer
  • Parser → Seen filter: new?
  • Seen filter → URL frontier: new URLs

4. Deep dives

Deep dive

The frontier: fast and polite

BadOne first-in, first-out queue

Pages link mostly to their own site, so the queue fills with runs of URLs from one host, and eight threads hit that host at once.

GoodOne queue, and a thread waits for its URL's host

Polite, but a thread holding a URL for a busy host sits idle while thousands of other hosts could be fetched.

GreatA queue per host, and a heap of when each host may be contacted next

Front queues order URLs by priority; back queues each hold one host's URLs. A free thread takes the host whose "next allowed" time is earliest, fetches, and puts the host back with a new time. Threads stay busy on whichever hosts are ready.

Try it

LabThree frontiers, same URLs

40% of URLs are from one big site, in runs of up to six.

One FIFO queue, no politeness594 pages (252 from the big site) · threads idle 0% · 510 rude requests, up to 8 at once
One FIFO queue, thread waits for its host54 pages (22 from the big site) · threads idle 91% · polite
A queue per host, soonest-ready host first360 pages (21 from the big site) · threads idle 40% · polite

0 to 60 seconds, one lane per thread. Green: the big site. Grey: small sites. Red: a request that broke the politeness rule. Blank: a thread waiting.

Try this

With one big site (40% of URLs) and a 2-second pause, a single queue with no politeness fetched 594 pages in a minute, but 510 requests broke the rule, with up to 8 at once on one host. Making each thread wait for its host made it polite and cut it to 54 pages, with threads idle 91% of the time. A queue per host fetched 360, all polite: the big site got about the same number of requests either way (22 and 21); the difference is that the other threads kept working on the small sites. With 70% of URLs from one site, even per-host queues left threads idle 70% of the time: politeness caps each host, so throughput comes from crawling many hosts at once.

robots.txt. Before fetching from a host, the crawler fetches /robots.txt and obeys it. RFC 9309 sets the rules: cache it for at most 24 hours; if it returns a 4xx, the crawler may fetch anything; if the server errors (5xx) or can't be reached, assume everything is disallowed; parse at least the first 500 KiB.

Deep dive

Not fetching the same thing twice

BadCompare URLs as strings

HTTP://Shop.example:80/a#top and http://shop.example/a are the same page; tracking parameters make millions of URLs for each page.

GoodNormalise, then keep an exact set of seen URLs

Correct, but 10 billion URLs is 80 GB of 8-byte fingerprints, which must be sharded or kept on disk.

GreatNormalise, check a Bloom filter, and fingerprint the content

A Bloom filter (05.08 Bloom filters) answers "definitely new" for most URLs in memory; a 1% false positive skips a page that will be found again later. A hash of each page's content catches the same page under different URLs (mirrors, session IDs), like content addressing in 13.04 File sync: chunking and content addressing.

ts
const TRACKING = /^(utm_[a-z]+|fbclid|gclid)$/i;

export function normalise(href: string, base?: string): string | null {
  let u: URL;
  try {
    u = new URL(href, base); // resolves relative links against the page
  } catch {
    return null;
  }
  if (u.protocol !== "http:" && u.protocol !== "https:") return null; // mailto:, javascript:, ...
  u.hash = ""; // fragments never reach the server
  u.hostname = u.hostname.toLowerCase().replace(/\.$/, "");
  const params = [...u.searchParams].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b));
  u.search = new URLSearchParams(params).toString();
  if (u.pathname === "") u.pathname = "/";
  return u.toString(); // URL already lowercases the scheme and drops default ports
}

Tested: https://Shop.Example:443/a/b/page.html?utm_source=x&b=2&a=1#reviews became https://shop.example/a/b/page.html?a=1&b=2; ../c?id=7 on that page resolved to https://shop.example/a/c?id=7; HTTP://shop.example:80 became http://shop.example/; mailto: and javascript: links were dropped; a gclid parameter was removed; and a trailing dot on the host was removed.

Deep dive

Splitting the crawl across machines

BadAny node takes any URL

Politeness then needs a shared, global "last contacted" per host: a hot coordination point for every fetch.

GoodA central frontier service

One place to enforce politeness, but it becomes the bottleneck and a single point of failure.

GreatPartition by host: hash(host) picks the node

Each node owns a share of hosts and runs its own frontier, robots cache and DNS cache. Links to hosts it doesn't own are sent in batches to the owning node. Politeness stays local, and nodes are added by moving host ranges (05.03 Consistent hashing).

Traps. Calendars with a "next month" link forever, session IDs in URLs, and generated pages make infinite URL spaces. Cap URL length and path depth, cap pages per host per crawl, and lower the priority of hosts whose pages are mostly near-duplicates.

Freshness. Recrawl by observed change rate: a news front page hourly, an old article monthly. Conditional requests (If-Modified-Since, If-None-Match) let unchanged pages answer 304 with no body.

5. Wrap-up

What breaks first is rarely the fetchers: it's DNS (each node needs its own caching resolver), the seen-URL set as it grows, and complaints, which is why politeness, a clear user agent and a contact page for site owners are part of the design.

What's expected at each level

Junior

gets a working design

  • Draws the fetch, parse, store, enqueue loop with a queue
  • Mentions robots.txt

Mid-level

explains the trade-offs

  • Designs per-host queues for politeness
  • Normalises URLs and sizes the seen-URL set
  • Stores pages in object storage, decoupled from indexing

Senior

drives it and goes deep

  • Partitions by host so politeness stays local
  • Handles traps, duplicates by content, DNS and recrawl policy
  • Knows throughput comes from parallelism across hosts, not per host

Seen in production

Google

1998

The original crawler

A single URLserver sent lists of URLs to a few crawlers, each keeping about 300 connections open at once with asynchronous I/O. At peak, four crawlers fetched over 100 pages a second, about 600 KB a second of data. DNS lookup was a major performance stress, so each crawler kept its own DNS cache. The paper notes that a crawler contacting more than half a million servers generates a fair amount of email and phone calls.

What we foundA few crawler processes with hundreds of connections each, and DNS as a major bottleneck.

The Anatomy of a Large-Scale Hypertextual Web Search Engine (Brin and Page, 1998)research.google ↗

Compaq

2001

Mercator

One thread per host at a time ("weak politeness") still drew complaints, because it allowed requests with no pause. Mercator's frontier keeps per-host FIFO back queues and a heap ordered by when each host may next be contacted, uses about three times as many back queues as threads, and waits ten times as long as the last download took before contacting a host again. A December 2000 crawl processed 891 million URLs in 17 days; 35 million were excluded by robots.txt.

What we foundPer-host back queues and a heap of next-contact times: polite and busy at once.

High-Performance Web Crawling (Najork and Heydon, Compaq SRC Research Report 173)cs.cornell.edu ↗

IETF

2022

RFC 9309, Robots Exclusion Protocol

Crawlers should not use a cached robots.txt for more than 24 hours unless it's unreachable. If the file is unavailable (a 4xx status), crawlers may access any resource; if it's unreachable because of server or network errors (such as a 5xx), they must assume complete disallow. Crawlers should follow at least five redirects for it, and the parsing limit must be at least 500 KiB.

What we foundrobots.txt became a standard in 2022, including how to cache it and what errors mean.

Robots Exclusion Protocol (RFC 9309)rfc-editor.org ↗
Predict first

A crawler node has 200 threads and one shared FIFO queue. To be polite, each thread waits until its URL's host may be contacted. Throughput is a fraction of what the network allows. What's the fix?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowDesign

Design a web crawler.

Report a mistakeEdit this page