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 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.
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
| Value | What it decides | |
|---|---|---|
| Fetch rate | 1 billion ÷ 2.6 million seconds ≈ 400 pages a second | Asynchronous I/O, thousands of connections |
| Bandwidth | 400 × 100 KB ≈ 40 MB/s, about 320 Mbit/s | A handful of machines on a good network |
| Storage | 100 TB a month before compression | Object storage, compressed |
| Seen URLs | 10 billion over time | A Bloom filter at 1% is about 12 GB; exact fingerprints, 80 GB, sharded |
| Hosts in flight | With a 2 s pause per host, 400 pages a second needs 800+ hosts active at once | The frontier must juggle many hosts |
The last row is the one that shapes the design.
3. High-level design
5 steps. Use Next and Back to follow the request.
Text version
- A fetcher asks the frontier for the next URL whose host may be contacted now.
- It checks the host's cached robots.txt and its DNS cache, then fetches the page with a timeout and a size limit.
- The parser fingerprints the content (skipping exact duplicates), stores the page, and extracts links.
- Each link is normalised and checked against the seen-URL filter; new ones go back into the frontier.
- 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
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.
Polite, but a thread holding a URL for a busy host sits idle while thousands of other hosts could be fetched.
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
40% of URLs are from one big site, in runs of up to six.
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
HTTP://Shop.example:80/a#top and http://shop.example/a are the same page; tracking parameters make millions
of URLs for each page.
Correct, but 10 billion URLs is 80 GB of 8-byte fingerprints, which must be sharded or kept on disk.
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.
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
Politeness then needs a shared, global "last contacted" per host: a hot coordination point for every fetch.
One place to enforce politeness, but it becomes the bottleneck and a single point of failure.
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
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
2001Mercator
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
2022RFC 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 ↗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?
Recap
Interview questions
Design a web crawler.