Design a Web Crawler
Difficulty: Tier 2 Asked at: Google, Amazon, Systems Ltd, Arbisoft Time budget: 45 min
A web crawler downloads the internet, one page at a time, forever. The interesting part isn’t fetching a
page — it’s doing it at the scale of billions of pages: not re-fetching what you’ve seen, being polite
to servers, prioritizing what matters, and never getting stuck. It’s a beautiful showcase for queues,
deduplication (Bloom filters!), and politeness — a producer/consumer system at planetary scale.
Prerequisites: Message Queues, Bloom Filters, Object Storage
1. Requirements
Functional:
- Given seed URLs, fetch pages, extract links, and recursively crawl them.
- Store the fetched content (for indexing / a search engine downstream).
- Respect
robots.txt and be polite (don’t hammer one server).
- Re-crawl pages periodically to keep content fresh.
Non-functional:
- Massive scale — billions of pages.
- Politeness — never overwhelm a single domain; obey crawl-delay.
- Robustness — handle malformed HTML, timeouts, traps (infinite URL spaces), duplicate content.
- Extensible — pluggable for images, PDFs, different parsers.
- Freshness — important pages re-crawled more often.
Out of scope: the search index itself (that’s google-search), ranking.
2. Estimation
- 1 billion pages to crawl, each ~100 KB → 100 TB of raw content (compressed, less). Object storage.
- Crawl 1B pages/month → 1B / 2.6M sec ≈ ~400 pages/sec. To crawl faster (say a month), scale
workers horizontally.
- URL frontier: must track billions of URLs — the seen-set and the to-crawl queue are the big data
structures.
- Bandwidth: 400 pages/sec × 100 KB ≈ 40 MB/sec download.
3. Components
flowchart LR
Seed[Seed URLs] --> Frontier[[URL Frontier<br/>priority + politeness queues]]
Frontier --> Fetcher[Fetcher workers]
Fetcher --> DNS[DNS resolver cache]
Fetcher --> Robots[robots.txt cache]
Fetcher --> Parser[Parser: extract links + text]
Parser --> Dedup{Seen before?<br/>Bloom filter}
Dedup -- no --> Frontier
Dedup -- content dup? --> Drop[Drop]
Parser --> Store[(Content store<br/>object storage)]
The heart is the URL frontier — the smart queue that decides what to crawl next and when, honoring
priority and politeness.
4. Deep dives
4a. The URL frontier (priority + politeness)
🚨 This is the core of the question. Two competing needs:
- Priority: important/fresh pages first (PageRank-ish, or update frequency). → a set of priority
queues; a URL’s priority routes it to a queue.
- Politeness: never hit one domain too fast. → a set of per-host queues, each drained by a single
worker with a crawl-delay, so requests to one domain are serialized and spaced.
A common design: a front set of priority queues feeds a back set of per-host queues; a worker is assigned a
host queue and paces its requests. This decouples “what’s important” from “who’s allowed to fetch now.”
4b. Deduplication — have we seen this URL / content?
Two dedup problems:
- URL dedup: billions of URLs; a hash set would be huge. Use a Bloom filter — probabilistic, tiny memory, “definitely new” or “probably seen.” 🚨 A
false positive means we skip a page we haven’t crawled — acceptable at this scale (with a backing store
for exact checks on important URLs).
- Content dedup: different URLs, same content (mirrors, session IDs in URLs). Hash the page content
(e.g. a checksum or SimHash for near-duplicates) and skip duplicates. This also defends against
crawler traps.
4c. Politeness & robots.txt
Fetch and cache each domain’s robots.txt; obey disallowed paths and crawl-delay. Cache DNS resolutions
(DNS lookups are slow and would dominate latency otherwise). Rate-limit per host in the frontier. Set a
descriptive User-Agent. 🚨 A crawler that ignores politeness gets your IP banned and can DoS small sites
— name this.
4d. Traps & robustness
- Crawler traps: infinite URL spaces (calendars, faceted search generating endless links). Defend with
a max depth/URL-count per domain, content-dedup, and URL-pattern heuristics.
- Malformed pages / timeouts: bound fetch time, handle parse errors gracefully, retry with backoff, and
give up after N tries. (Retries & Jitter)
4e. Freshness / re-crawling
Pages change at different rates (a news homepage vs an archived doc). Track each page’s change history and
re-crawl frequently-changing, important pages more often — an adaptive re-crawl schedule. Re-insert URLs
into the frontier with a due-time.
4f. Distributed coordination
Partition the frontier and workers by domain hash so all URLs for a host go to the same worker/shard —
this makes politeness easy (one place enforces per-host pacing) and keeps DNS/robots caches warm. Workers
are stateless and horizontally scalable; the frontier and seen-set are the shared state.
5. Bottlenecks & scaling further
- Fetch throughput → add fetcher workers; they’re stateless and network-bound.
- DNS → a caching DNS layer (DNS is a classic hidden bottleneck).
- Frontier size → the seen-set is billions of URLs → Bloom filter in memory + durable backing store.
- Politeness vs speed → per-host queues limit per-domain rate; parallelism comes from crawling many
domains at once, not one domain fast.
- Storage → object storage scales; compress content.
6. Trade-off summary
| Decision |
Chosen |
Alternative |
Why |
| URL dedup |
Bloom filter |
Exact hash set |
Billions of URLs won’t fit; false positives tolerable |
| Frontier |
Priority + per-host queues |
Single FIFO queue |
Needs both importance and politeness |
| Partitioning |
By domain hash |
By URL hash |
Keeps politeness/DNS/robots per-host in one place |
| Content dup |
Content hash / SimHash |
Ignore |
Mirrors and traps waste crawl budget |
| Storage |
Object storage, compressed |
Database |
Cheap, scalable for 100 TB of blobs |
7. Follow-up questions
Why a Bloom filter for the seen-set and what's the risk?
Because tracking billions of already-seen URLs in an exact hash set would need hundreds of GB of RAM,
whereas a Bloom filter represents the same set in a small fraction of that memory by allowing a controlled
false-positive rate. Its answers are "definitely not seen" (safe to crawl) or "probably seen." The risk is
a false positive: it says "seen" for a URL we actually haven't crawled, so we skip a real page. At web scale
that's acceptable — we'll still crawl billions of pages, and for high-value URLs we can double-check against
a durable store. There are no false negatives, so we never re-crawl something the filter says is new when it
isn't, meaning we never accidentally lose the dedup guarantee in the dangerous direction.
How do you stay polite without crawling slowly overall?
Politeness is per-host, throughput is aggregate. You cap the request rate to any single domain (per-host
queue with a crawl-delay, one worker draining it), but you crawl thousands of *different* domains
simultaneously. So no individual server is overwhelmed, yet total throughput is huge because it's the sum
across many hosts. The trick is partitioning by domain so per-host pacing is enforced in one place while
parallelism comes from breadth across domains, not depth into one.
How do you avoid crawler traps and infinite loops?
Several layers: content-hash dedup catches pages that are identical despite different URLs (common in
traps); a max crawl depth and a per-domain URL budget bound how far you'll descend; URL-pattern heuristics
detect obviously-generated infinite spaces (endless calendar dates, session IDs); and the seen-set stops
re-crawling the same URL. Together these cap the damage a malicious or accidental infinite URL space can do
to your crawl budget.
How do you keep the crawl fresh?
Track how often each page actually changes and re-crawl adaptively: pages that change frequently and matter
(news, popular sites) get re-queued with short intervals; static or unimportant pages get long intervals.
URLs carry a due-time and re-enter the frontier when due. This spends limited crawl budget where change is
happening instead of re-fetching everything uniformly.
8. What junior / mid / senior answers look like
- Junior: a BFS loop — queue of URLs, fetch, extract links, enqueue. Correct in spirit; misses scale,
politeness, and dedup at billions.
- Mid: adds a proper frontier, Bloom-filter dedup, robots.txt/politeness, object storage, stateless
workers.
- Senior: the priority-plus-politeness frontier design, content dedup / SimHash, trap defenses, adaptive
re-crawl, domain-hash partitioning for locality, DNS caching as a hidden bottleneck — and explains why
parallelism comes from breadth across domains, not speed into one.
Further reading