Design a web crawler
Start from a few seed pages, download them, find their links, and keep going until you've fetched a billion pages, without downloading the same page twice, without hammering any one website, without getting stuck in infinite link mazes, and without losing progress when machines fail. A crawler is a giant, carefully scheduled queue, which makes it an excellent exercise in Level 4 thinking. It follows the interview framework phase by phase.
1. The prompt
"Design a web crawler that fetches a billion pages a week for a search engine (or for training data). The output is the raw HTML of each page, stored for later processing."
There are no users waiting on a response, so latency barely matters. What matters is throughput under a politeness constraint, deduplication at the scale of billions, and a pipeline that keeps going when individual machines, websites and DNS servers misbehave.
2. Frame the problem
How many pages, how fast?
About 1 billion pages a week.
~1,650 pages a second: bandwidth, concurrency and storage follow from this.
What do we keep?
Raw HTML and metadata; an indexing system reads them later.
Object storage for content, a metadata store for status and links.
How polite must we be?
Obey robots.txt, and at most about one request a second per site (configurable).
Politeness, not compute, limits throughput: we need many sites in parallel.
One crawl, or continuous?
Continuous: pages are recrawled, more often if they change often.
A scheduler that learns each page's change rate.
Do pages need JavaScript rendering?
Plain HTML for now; rendering later for pages that need it.
Rendering costs 10–100× more per page.
Which pages first?
Important ones (well linked, popular sites) before obscure ones.
The frontier needs priorities, not just FIFO.
3. What it must do
- The crawler fetches pages, starting from seed URLs and following extracted links.
- It stores each page's content and metadata for downstream indexing.
- It recrawls pages periodically, more often for pages that change often.
Not now:
- Indexing and ranking: another system reads the stored pages.
- Heavy JavaScript rendering: mentioned as an extension.
- Crawling behind logins.
4. How well it must do it
| Target | Why this number |
|---|---|
| Polite: obey robots.txt; never more than ~1 request a second to one site rules | Impolite crawlers get blocked, and can hurt small sites. |
| Throughput: 1 billion pages in 7 days load shape | The index is only as good as its coverage and freshness. |
| Efficient: no URL fetched twice per crawl; duplicate content skipped load shape | Every wasted fetch costs bandwidth and politeness budget. |
| Robust: survives worker crashes, bad HTML, slow servers and crawler traps, losing no progress surviving failures · data safety | A week-long job will see every kind of failure. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Fetch rate | 10⁹ pages ÷ (7 × 86,400 s) | ~1,650 pages/s; plan for ~3,000/s |
| Bandwidth | 3,000/s × ~100 KB (compressed HTML) | ~300 MB/s ≈ 2.4 Gbit/s → so a few dozen fetcher machines |
| Storage | 10⁹ × 100 KB | ~100 TB per crawl → so object storage |
| Concurrency | Little's Law: 3,000/s × ~1 s per fetch | ~3,000 fetches in flight → so asynchronous I/O, not a thread per request |
| Politeness | 3,000/s at 1 request/s per host | → so at least 3,000 hosts must be crawlable at every moment (try the calculator) |
| URLs seen | ~10 billion discovered URLs | An exact set would be hundreds of GB → so a Bloom filter |
6. The nouns
- URL task: a URL in the frontier.
- Host: with its robots rules and rate limit.
- Page record.
- Content: the raw HTML.
- Seen set: URLs already discovered.
url task normalised_url, host, priority, depth, discovered_at
host host, robots_rules + expiry, crawl_delay, next_allowed_at, error_count, ip (cached)
page url, fetched_at, status, content_hash, storage_key, out_links, change_rate, next_crawl_at
content object storage: crawl/{date}/{hash} → compressed HTML
7. The contract
No public API. Internally, components pass messages, and operators get a small admin API.
frontier.add(url, priority) // from parsers and the recrawl scheduler
frontier.next(node) → url task // a fetcher asks its node for a host that's ready
page.stored {url, content_hash, storage_key, links[]} → parser, metadata store
admin: POST /v1/seeds {urls} · POST /v1/hosts/{host}/block · GET /v1/crawl/stats
Data flow: (1) seed URLs enter the frontier → (2) a fetcher takes a URL whose host is allowed now → (3) robots.txt and DNS from cache → (4) download, store raw HTML → (5) parse links, normalise, check the seen set → (6) new URLs go back to the frontier; repeat.
8. First working version
One queue of URLs, a pool of fetcher workers, a parser, and a set of seen URLs.
8.1 The crawl loop
A worker takes the next URL from the queue, downloads it, stores the HTML, extracts links, and adds every link it hasn't seen to the back of the queue.
8.2 Storing pages
Raw HTML goes to object storage under a key derived from its content hash; a metadata row records the URL, status and storage key.
8.3 Recrawling
Version one re-adds every page to the queue a week after it was fetched.
Parked for later (section 9 fixes each)
- Links mostly point within the same site, so the FIFO queue hammers one host.
- An in-memory set of 10 billion URLs doesn't fit.
- One page with dozens of URL spellings; mirrors with identical content.
- robots.txt, DNS lookups and slow servers.
- Infinite calendars and session-id URLs.
- A crashed worker loses its queue.
- Recrawling everything weekly wastes budget on pages that never change.
9. Make it hold up
9.1 The frontier: priority and politeness together polite · throughput
| Approach | Why | |
|---|---|---|
| ✗ Bad | One big FIFO queue of URLs; fetchers take the next one | Pages link mostly within their own site, so the queue fills with one host's URLs; fetchers hit that host thousands of times a second and get blocked. Important pages wait behind junk. |
| ~ Good | A queue per host, and a rate limiter per host | Polite, but there's no notion of importance, and millions of hosts means millions of queues to schedule. |
| ✓ Great | Two stages (the classic Mercator design): front queues by priority (importance, freshness), feeding back queues, one per host, each with a "next allowed time"; a heap of hosts ordered by next allowed time tells fetchers which host is ready | Important pages go first; each host is fetched at most at its allowed rate; fetchers are never idle while some host is ready. |
Partition the frontier by hash of the host across crawler nodes, so each host's queue, rate limit and robots.txt live on exactly one node.
9.2 Don't fetch twice efficient
- Normalise URLs first: lowercase the host, remove default ports and fragments, sort or strip tracking parameters, resolve
../. Otherwise one page has dozens of URLs. - URL-seen check: a Bloom filter answers "definitely not seen" or "probably seen" in a few bits per URL. A false positive means skipping a URL that wasn't actually crawled: acceptable at a 1% rate for most crawls. For exactness, back it with a key-value store. Size it in section 13.
- Content dedup: mirrors and copies are common. Store a hash of the normalised content and skip exact duplicates; near-duplicate detection uses fingerprints such as SimHash, where similar pages get similar fingerprints.
9.3 Robots.txt, DNS and slow servers polite · robust
- Fetch
/robots.txtper host before crawling it, cache it (for example for a day), and obey its rules and any crawl delay (RFC 9309). Identify the crawler with a clear user agent and contact URL. - DNS is a hidden bottleneck: thousands of lookups a second. Run a local caching resolver and cache results per host.
- Set connect and read timeouts, cap page size (say 10 MB), and back off hosts that error repeatedly (a per-host circuit breaker, Module 24).
9.4 Crawler traps robust · efficient
Some sites generate endless URLs: calendars with a "next month" link forever, session ids in every URL, faceted search with millions of filter combinations. Defences:
- a maximum depth per host;
- a cap on URLs per host per crawl;
- a cap on URL length;
- detecting repetitive path patterns;
- noticing when many "different" pages share the same content hash.
9.5 Not losing progress robust
The frontier is durable (a log such as Kafka, or a disk-backed queue per node), so a crashed node's work resumes. A fetch task is acknowledged only after the page is stored (at-least-once; the content hash makes repeats harmless). If a crawler node dies, its host partitions move to another node, which rebuilds those queues from the durable frontier.
9.6 Recrawling efficient · throughput
The web changes constantly, but not uniformly: a news homepage changes every minute, an old blog post never.
- Learn each page's rate: track how often it actually changed between visits, and schedule its next crawl accordingly.
- Cheap checks: use conditional requests (
If-Modified-Since,ETag), so an unchanged page costs only a tiny response. - Sitemaps: give their
lastmodhints some weight.
Every recrawl spends politeness budget that could discover a new page, so the split between freshness and coverage is an explicit choice.
9.7 The whole design after the deep dives
One URL through the final crawler: front queue, host queue, fetch, store, parse, and back to the frontier.
10. Interviewer follow-ups
Crawl 10 billion pages a week instead.
Scale out by host partition: more crawler nodes and fetchers. The real limits become politeness (enough distinct hosts ready at once), DNS and bandwidth. Prioritise harder, and recrawl less of what doesn't change.
Pages that need JavaScript to show content.
Detect them (HTML nearly empty, a known framework) and send only those to a pool of headless browsers, which is 10–100× more expensive per page. Cache rendered results and recrawl them less often.
A site asks you to stop crawling it.
Honour robots.txt changes at the next refresh, and offer a manual block list for immediate effect. Keep a contact page linked from the user agent.
How do you choose which pages are important?
Link-based importance (PageRank-like scores from the link graph computed by a batch job), site reputation, freshness needs, and signals from the search engine (which pages people click). The scores set front-queue priority.
The Bloom filter fills up.
False positives rise as it fills. Size it for the expected count with headroom, use a scalable Bloom filter (add a new layer when full), or rebuild it per crawl generation.
A fetcher node is in a region far from most sites.
Place fetchers in several regions and route hosts to the nearest one by IP geolocation. Fetch time drops and so does the in-flight concurrency needed.
Downstream wants new pages within an hour.
Add a fast lane: sitemaps, RSS and change notifications (where sites support them) feed a high-priority front queue for new and changed pages.
11. When things break
| Failure | Effect | How we detect it | What limits the damage |
|---|---|---|---|
| Crawler node crash | Its hosts pause briefly | Node heartbeat | Partitions reassigned; queues rebuilt from the durable frontier |
| DNS resolver overloaded | Fetches stall | Lookup latency | Local caching resolvers; per-host IP cache |
| Crawler trap | Budget wasted on one site | URLs per host, repeated content hashes | Depth and per-host caps; pattern detection |
| Site starts returning errors | Its pages not refreshed | Error rate per host | Per-host backoff and breaker; retry later |
| Object storage slow | Fetchers back up | Write latency | Buffer and retry; tasks are acked only after storage |
12. Wrap-up and the clock
Trade-offs we made:
- A Mercator-style two-stage frontier (priority plus politeness).
- Partitioning by host.
- A Bloom filter with a known false-positive cost.
- At-least-once fetching with content hashes.
- Adaptive recrawling.
- Plain HTML first, rendering only where needed.
What we'd watch:
- Pages fetched per second.
- Ready hosts.
- Frontier size.
- Duplicate rate.
- Errors by host.
- DNS latency.
- Freshness of top pages.
At 10×:
- More nodes per host partition.
- Multi-region fetchers.
- Stricter priorities.
A 45-minute interview
| 0–4 | Frame the problem (scale, politeness, recrawl) |
| 4–9 | Must do, how well, numbers |
| 9–13 | Nouns, interfaces, data flow |
| 13–20 | First working version, parked list |
| 20–40 | Deep dives: frontier (9.1), dedup (9.2), traps (9.4), durability (9.5) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–16 | Nouns, interfaces, data flow |
| 16–24 | First working version |
| 24–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: JavaScript, importance, 10×; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Frontier queue, fetchers, parser, URL dedup, storage in object storage, robots.txt. |
| Senior | Priority plus per-host politeness queues, partitioning by host, Bloom filters with stated false-positive costs, content dedup, timeouts and backoff, durable frontier. |
| Staff+ | Traps, adaptive recrawl scheduling, DNS and bandwidth as bottlenecks, JavaScript rendering cost, legal and ethical considerations (robots, rate limits, opt-outs). |
Common mistakes:
- A single FIFO queue.
- An exact in-memory seen set at 10 billion URLs.
- Forgetting URL normalisation.
- Ignoring robots.txt and DNS.
- No trap defences.
- Recrawling everything equally.
13. Try it, check yourself, read more
Size the URL-seen Bloom filter
Formulas: bits m = −n·ln(p) / (ln 2)², hash functions k = (m / n)·ln 2. An exact set is estimated at ~60 bytes per URL (a hash plus overhead).
How fast can politeness let you go?
What keeps a crawler from hammering one website?
What does a Bloom filter false positive mean in a crawler?
How should the frontier be partitioned across machines?
At 1 request a second per host, what limits crawling 3,000 pages a second?
| Resource | Why |
|---|---|
| RFC 9309: Robots Exclusion Protocol | The standard for robots.txt. |
| Web crawling and indexes (Introduction to Information Retrieval, free online) | The frontier design with front and back queues, explained clearly. |
| Design a web crawler (Hello Interview) | Another walkthrough. |