Design a web crawler
The interviewer asks
“Design a web crawler that downloads billions of web pages for a search engine. It should be polite to websites, avoid duplicate work and keep the index fresh.”
What a strong answer looks like
A strong answer treats the crawler as a loop: a URL frontier hands out URLs, fetchers download pages, a parser extracts links, and a de-duplication layer decides what goes back into the frontier. The hard parts are the frontier design (priority plus per-host politeness queues), URL and content de-duplication at billions of items (Bloom filters, fingerprints like SimHash), DNS and robots.txt caching, and recrawl scheduling for freshness. Partition everything by host so one worker owns a host's politeness state. Store raw pages in object storage and push them to the indexing pipeline through Kafka. Show the numbers: ~1B pages/month means ~400 pages/s average, ~100 KB per page, so ~100 TB/month of raw HTML before compression.
Concepts to mention
1 Requirements
Functional
- Start from a set of seed URLs and discover new pages by following links
- Download HTML pages and store the raw content for the indexer
- Respect robots.txt rules and per-site crawl-delay
- Skip URLs already crawled and pages whose content is a duplicate
- Recrawl pages on a schedule based on how often they change
- Prioritise important pages (high PageRank, news sites) over low-value ones
Non-functional
- Scale: 1B pages per month (~400 pages/s average, design for 2,000 pages/s peak)
- Politeness: at most 1 request per host every 1-2 s by default, never overload a site
- Robustness: survive malformed HTML, spider traps, slow servers and worker crashes
- Extensible: new content types (PDF, images) can be added as plug-ins
- Storage: ~100 TB/month raw HTML (~25 TB compressed) kept for 6+ months
- Freshness: important news pages recrawled within minutes to hours, long-tail pages within weeks
Out of scope (say it out loud)
- Building the search index and ranking itself
- Rendering JavaScript-heavy pages (mention as an extension)
- Crawling content behind logins or paywalls
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/seeds | Admin adds seed URLs with a priority |
gRPCFrontier.Lease(worker_id, n) | A fetcher leases up to n URLs from hosts it owns that are ready to be crawled |
gRPCFrontier.Report(url, status, next_crawl_at) | Fetcher reports the result so the frontier can reschedule or back off |
gRPCFrontier.Add(urls[]) | Parser submits newly discovered, normalised URLs after dedup |
GET/v1/pages/{url_hash} | Indexer or debugging tool reads the latest stored copy of a page |
4 Data model
url_stateCassandra / Bigtableurl_hash (PK)urlhostlast_crawled_atnext_crawl_athttp_statuscontent_hashchange_ratepriorityPartition by host hash so one frontier shard owns all URLs of a host. Wide-column store handles billions of rows and heavy writes.
host_stateRedis (with snapshot to Cassandra)host (PK)robots_rulesrobots_fetched_atcrawl_delay_msnext_allowed_aterror_countipHot, small (~100M hosts × ~1 KB). Read on every fetch, so it lives in memory on the frontier shard.
seen_urlsBloom filter in memory + url_statebits per host-shardFast 'probably seen' check. A Bloom filter never gives false negatives, so a URL it calls unseen is truly new; a false positive just skips a page.
pagesS3 / GCS (WARC files)warc file with many records: urlfetch_timeheaderscompressed bodyBatch many pages into ~1 GB WARC files; small objects per page would be slow and costly.
content_fingerprintsBigtable / RocksDBsimhash (64-bit)url_hashNear-duplicate detection: split the 64-bit SimHash into bands and index each band to find pages within 3 bits Hamming distance.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Crawl one URL
- 1Fetcher calls Frontier.Lease; the frontier picks hosts whose next_allowed_at has passed and returns their highest-priority URLs
- 2Frontier checks cached robots.txt for the host (refetched every ~24 h); disallowed URLs are dropped
- 3Fetcher resolves the host through the local DNS cache (TTL-respecting, avoids 10-50 ms DNS lookups per page)
- 4Fetcher sends HTTP GET with a clear User-Agent, a 10 s timeout, a 10 MB size cap and If-Modified-Since / ETag headers
- 5On 304 Not Modified the frontier only updates next_crawl_at; on 200 the body is appended to a WARC file in S3
- 6Fetcher publishes a 'page fetched' event (url, S3 offset, headers) to Kafka and reports status to the frontier
- 7Frontier sets the host's next_allowed_at = now + crawl_delay (or longer after errors)
Process a fetched page
- 1Parser consumes the event, reads the page from S3 and extracts links and text
- 2Links are normalised: lowercase host, remove fragments and default ports, resolve relative paths, drop tracking params
- 3Each URL is checked in the host-shard Bloom filter; only 'not seen' URLs continue (most of the ~50 links per page stop here)
- 4Parser computes the SimHash of the text; if a near-duplicate exists the page is marked duplicate and not sent for indexing
- 5Unseen URLs are routed to the frontier shard that owns their host, with a priority from domain rank and link depth
- 6Unique pages are published to the indexing topic
7 Deep dives
The URL frontier: priority and politeness together
The frontier must answer two questions: which URL is most important, and which host may I hit now. The classic design (Mercator) uses two layers. Front queues are priority buckets (for example 10 levels, set from domain rank, depth and change rate). A biased selector pulls more often from high-priority buckets. Back queues are per host: each back queue holds URLs for one host only, and a min-heap keyed by next_allowed_at says which host is ready. A fetcher thread pops the heap, takes one URL from that host's queue, fetches it, and pushes the host back with next_allowed_at = now + delay. This gives one connection per host at a time and a global priority order. At scale we shard the frontier by hash(host), so a single shard owns all politeness state for a host and no cross-machine locks are needed. Each shard keeps the heads of queues in memory and spills the long tails to disk (RocksDB or Cassandra), because 10B pending URLs will not fit in RAM. Large hosts such as Wikipedia get a higher allowed rate, configured per host, but are still owned by one shard.
De-duplication: URLs and content
There are two kinds of duplicates. URL duplicates are the same address written differently: fix them with normalisation, then check a set of seen URLs. With 10B URLs, an exact hash set of 8-byte hashes is ~80 GB, while a Bloom filter at 1% false positives needs ~12 GB and can be sharded by host. A false positive means we skip a new URL, which is acceptable at 1%; a Bloom filter never says 'not seen' for a seen URL. For extra safety, important URLs can be confirmed against url_state. Content duplicates are different URLs with the same or almost the same page: mirrors, session IDs in URLs, printer-friendly versions. Exact duplicates are caught by a hash of the normalised text. Near-duplicates use SimHash: a 64-bit fingerprint where similar documents differ in few bits. To find pages within 3 bits, split the fingerprint into 4 blocks of 16 bits; by the pigeonhole principle any match within 3 bits shares at least one exact block, so we index each block and compare only candidates. Duplicates are not indexed and their outlinks are trusted less.
Freshness and recrawl scheduling
You cannot recrawl 10B pages every day, so the budget must follow change rate and importance. For each URL we store the last few crawl results: did the content hash change? A simple estimator is change_rate = changes ÷ observed time; next interval ≈ 1 ÷ change_rate, clamped between, say, 1 hour and 60 days, then shortened for important pages. A news homepage that changes every 10 minutes gets crawled every few minutes; a 2012 blog post gets crawled every few weeks. Conditional GETs (If-Modified-Since, ETag) make most recrawls cheap: a 304 response has no body. Sitemaps with lastmod and RSS feeds are strong hints, and protocols like IndexNow let sites push changes. In the frontier this is just a priority: a URL whose next_crawl_at has passed is re-inserted into a front queue with priority from importance × staleness. Splitting capacity into a 'discovery' budget (new URLs) and a 'refresh' budget (known URLs), for example 30/70, stops new-link floods from starving freshness.
Spider traps and robustness
The open web is hostile. Calendars generate infinite URLs (/calendar?month=1…∞), session IDs make each link unique, and some servers reply byte by byte to tie up crawlers. Defences: cap URL length (~2,000 chars) and path depth; cap pages per host per crawl cycle (for example 100k for unknown hosts, grown if the site proves valuable); detect repeating path segments (/a/b/a/b/a/b); strip known session and tracking params; and treat many near-duplicate pages from one host as a trap signal. On the network side: connect and total timeouts, a maximum body size, and a maximum redirect chain of ~5. Parsers run in a sandboxed process with memory limits so one malformed page cannot crash a worker. Every lease has an expiry: if a fetcher dies, its leased URLs return to the frontier after the lease times out, so work is retried, not lost. Because fetches are idempotent GETs, a retry is safe.
8 Trade-offs
BFS vs priority-driven crawl
Pure BFS is simple but wastes budget on low-value pages and hammers hosts with many links. DFS falls into traps. A priority frontier (domain rank, depth, freshness) costs more complexity but spends bandwidth where search quality gains most. Mention BFS as the starting idea, then improve it.
Bloom filter vs exact seen-set
A Bloom filter is ~7x smaller than an exact hash set and fits in RAM per shard, at the cost of ~1% of new URLs being skipped. An exact set in RocksDB/Cassandra is precise but costs a disk lookup for ~20k links/s. Common answer: Bloom filter first, url_state as the source of truth for scheduling.
Partition by host vs by URL hash
Partitioning by URL hash spreads load evenly but spreads one host's URLs over many machines, so politeness needs a distributed lock or shared rate limiter. Partitioning by host keeps politeness local and DNS/robots caches warm, at the cost of skew (big sites). Choose host partitioning and handle skew with consistent hashing and virtual nodes.
Raw HTML vs rendered pages
Plain HTTP fetch costs ~10-50 ms of CPU per page. Rendering JavaScript in headless Chrome costs 1-5 s of CPU and lots of memory. Render only for hosts where the plain HTML is clearly empty, in a separate queue, so the main pipeline stays cheap.
9 Bottlenecks
DNS becomes the limit
At 2,000 fetches/s, uncached DNS lookups add tens of ms each and can overload public resolvers. Run a local caching resolver per worker pool, keep the IP in host_state, and resolve ahead of time when a host enters the ready heap.
Link fan-in to the frontier
Parsers produce ~20k-100k links/s. Sending each one over RPC is wasteful. Batch links per destination shard (for example every 100 ms), dedup within the batch, and use the Bloom filter on the parser side to drop most links before they cross the network.
Small objects in storage
Writing one S3 object per page means billions of PUTs (cost and request-rate limits). Append pages to WARC files and roll a file at ~1 GB; keep an index of (url_hash → file, offset) for random reads.
10 Failure handling
A fetcher crashes mid-crawl
Leased URLs carry a lease expiry (for example 5 min). The frontier re-queues them after expiry. Pages already in S3 but not acked may be fetched again; this is harmless because downstream dedup by url_hash + content hash makes processing idempotent.
A frontier shard dies
Its in-memory queues are lost, but url_state in Cassandra holds next_crawl_at for every URL. A replacement shard (or neighbours via consistent hashing) rebuilds queues by scanning its host range. Snapshot the Bloom filter to S3 every few minutes so it can be reloaded quickly.
A website starts failing or slowing down
Track error rate and latency per host. On 5xx or 429, back off exponentially (double crawl_delay up to hours) and honour Retry-After. This protects the site and stops our fetchers wasting connections.
11 What impresses
- Explaining the two-layer Mercator frontier (priority front queues, per-host back queues with a ready-time heap)
- Showing the Bloom filter size maths and saying clearly what a false positive costs
- Using SimHash with banding and the pigeonhole argument for near-duplicate search
- Splitting crawl capacity into discovery vs refresh budgets
- Mentioning conditional GETs, sitemaps and IndexNow to cut recrawl cost
- Lease-based work distribution so crashes cause retries, not lost URLs
12 Mistakes
- Using one global FIFO queue and forgetting politeness, so the crawler DDoSes small sites
- Ignoring robots.txt or fetching it on every request
- Storing every seen URL in a SQL table and doing a lookup per link at 20k/s
- Not handling spider traps, so one calendar site eats the whole crawl
- Storing each page as its own small S3 object
- Forgetting recrawl and freshness entirely
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.