Design a Web Crawler
Design a crawler that fetches a billion pages a month: the URL frontier, politeness, deduplication with hashes and Bloom filters, DNS and traps.
On this page
A search engine can only find pages it has seen. A web crawler is the program that sees them: it starts from a few known URLs, downloads each page, pulls out the links, and adds the new ones to its to-do list. Run that loop a billion times a month and you have the raw material for a search index.
Interviewers like it because the loop is easy and everything around it is not: be polite to other people's servers, avoid fetching a page twice, survive sites that generate endless URLs, and keep hundreds of downloads in flight. The steps follow A Framework for Any Design.
Clarify the problem
Design a web crawler.
What is it for: feeding a search index, archiving pages, or something narrower like price monitoring? And do we fetch only HTML, or images and PDFs too?
It feeds a general search index. HTML only.
How many pages, and how fresh must they be?
About a billion pages a month. Popular pages should be refreshed more often than obscure ones.
Then my priorities are: never overload a website, never waste fetches on duplicates, and spend the fetch budget on the pages that matter most. I'll store the raw HTML and hand it to the indexing team; ranking and search are theirs. I'll respect robots rules and skip pages behind logins.
- Functional: start from seed URLs; fetch HTML pages; extract links and add new ones; store each page's content; recrawl pages, important ones more often.
- Non-functional: polite (one request at a time per site, with a gap, robots rules obeyed); about 1 billion pages a month; each URL scheduled once per cycle (a rare lease retry may fetch it twice); survives slow and hostile sites; a crashed machine loses no frontier state.
- Out of scope: indexing and ranking, JavaScript rendering, images and video, pages that need a login.
Estimate the scale
A month is 30 × 86,400 = 2,592,000 seconds, call it 2.6 million. The crawler sets its own pace, so there is no traffic peak to plan for, only a target rate plus headroom for retries and slow periods.
- Fetch rate: 109 pages ÷ 2.6 × 106 s ≈ 385 a second. Call it 400, and plan for 800 so a bad day can be caught up.
- Bandwidth: assume an average HTML page of 100 KB. 400 × 100 KB = 40 MB/s, about 320 Mbit/s. One data center link handles it.
- Connections in flight: assume a fetch takes about 1 second end to end, because many sites are slow. By Little's law (in-flight = rate × time), 400 a second × 1 s = 400 downloads open at any moment, 800 with headroom.
- The politeness ceiling: at most one request a second per site, so a site with 1 million pages takes 106 ÷ 86,400 ≈ 11.6 days. Large sites, not bandwidth, limit how fresh you can be.
How much storage does one month of raw HTML take, before compression?
- 1 billion pages fetched a month
- About 100 KB of HTML per page
Show the working
- 109 × 100 × 103 B = 1014 B = 100 TB
- HTML compresses well; assume 4x, so about 25 TB stored per monthly snapshot
Estimate 100 TB (anything from 30 TB to 300 TB is a good answer)
This goes to object storage, not a database.
The crawler knows about 10 billion distinct URLs. If you store an 8-byte hash of each one to answer "have I seen this URL?", how much memory is that?
- 10 billion URLs
- 8 bytes per URL hash
- No other overhead
Show the working
- 10 × 109 × 8 B = 8 × 1010 B = 80 GB
Estimate 80 GB (anything from 30 GB to 300 GB is a good answer)
Easy when split over ten machines. A Bloom filter needs about 12 GB at a 1% false-positive rate; the deep dive weighs the two.
A crawler has no public users. Write the messages between its parts, and what goes out to the indexing team.
Compare with a model answer
Three: a way to add URLs (seeds from operators and links from the parser, same call), a way for a fetcher to ask for the next URL it may fetch now, and an output record per fetched page. The interesting part is that "next URL" is not "next in line": the frontier decides, based on politeness and priority.
Define the API
frontier.add(urls: [ { url, priority, discovered_from } ])
-> accepted count; URLs already seen are dropped silently
frontier.lease(fetcher_id) -> { url, host, lease_id, lease_until }
-> or "nothing ready, retry in N ms" when every host is cooling down
frontier.complete(lease_id, result)
result: fetched(status, content_hash) | not_modified | failed(reason, retry_after?)
-> an expired lease is handed out again, so a dead fetcher loses no URL
event PageFetched { url, fetched_at, status, content_hash, blob_key }
-> published to a stream that the indexer consumesThe decision worth arguing is lease, not pop. If a fetcher popped a URL and crashed, the URL would be lost for good, since it is already marked seen and will never be added again. A leased URL goes back out after lease_until. The cost: a slow (not dead) fetcher may download a page another fetcher also downloads. That wastes a fetch but breaks nothing.
What records does the crawler keep about URLs, about hosts and about page content? Which questions does it ask of each, and how often?
Compare with a model answer
A URL record keyed by a hash of the normalised URL (seen? when to fetch next?), a host record keyed by host name (robots rules, when the next request is allowed), and the page bytes in object storage. The hot question is "have I seen this URL?", asked about 50 times per page fetched, which is why it gets its own structure.
Model the data
-- one row per known URL, partitioned by host hash
urls: url_hash BIGINT PRIMARY KEY -- 8-byte hash of the normalised URL
url TEXT -- up to ~2 KB
host TEXT
priority SMALLINT
next_fetch_at TIMESTAMP
last_status SMALLINT
content_hash BIGINT NULL -- fingerprint of the last body
-- one row per host
hosts: host TEXT PRIMARY KEY
robots_rules TEXT -- parsed robots.txt, refreshed daily
crawl_delay_ms INT -- 1,000 unless the site asks for more
next_allowed_at TIMESTAMP
ip_cache TEXT, ip_expires TIMESTAMP
-- page bodies: object storage, key = url_hash + fetch date, gzip-compressedThe URL table gets lookups and inserts by hash and a scan for "what is due", with no joins: a wide-column or key-value store (such as Cassandra) partitioned by host fits, so the machine that schedules a host also owns its URLs. Page bodies are large, written once and read in bulk: that is blob storage.
Normalise URLs before hashing, or one page looks like many: lowercase the scheme and host, drop the default port and the #fragment, sort and prune query parameters. HTTP://Example.com:80/a?b=2&a=1#top and http://example.com/a?a=1&b=2 are one page.
Sketch the loop: where URLs wait, who downloads them, where bodies go, and how new links get back into the queue without duplicates.
Compare with a model answer
A frontier hands URLs to a fleet of fetchers. Fetchers resolve DNS, download, store the body and pass it to parsers. Parsers extract links, drop the ones already seen, and add the rest to the frontier.
High-level design
The crawl is a breadth-first search over the web graph: the frontier is the queue and the seen-URL set is the visited set. Here the queue is ordered by priority and politeness, and the visited set spans machines.
request and replyasynchronous message
Follow one URL through the loop.
- in flight
- 1
1The frontier leases
bakery.example/menuto a fetcher for 60 seconds: its host's cool-down has passed and it is high priority.- in flight
- 1
2The host's robots rules, cached this morning, allow
/menu.- in flight
- 1
3The IP is in the DNS cache, so no lookup. A miss would cost 10 to 200 ms.
- in flight
- 1
- bytes
- 92 KB
4It downloads the page: 200 OK, 92 KB, in about 1 second, without blocking a thread.
- in flight
- 1
- bytes
- 92 KB
5The body is compressed and stored with its fingerprint, so unchanged pages can be skipped later.
- in flight
- 0
- links found
- 0
6A message goes to the parsers. The fetcher completes its lease and moves on without waiting.
- links found
- 60
7A parser pulls 60 links from the HTML, normalises each and hashes it.
- links found
- 60
- new
- 12
8It sends all 60 hashes to the seen set in one batch, as insert-if-absent. 48 were already there: most links point to pages already found.
- links found
- 60
- new
- 12
9The 12 new URLs go to their hosts' queues in the frontier. The shard keeps them as seen only once the frontier has them on disk.
- host delay
- 4 s
10Later the same host returns
503twice. The frontier doubles its delay to 2 seconds, then 4: a struggling site gets less traffic from us, not more.
Deep dives
The URL frontier: politeness and priority
A plain first-in, first-out queue fails twice. Pages link mostly within their own site, so the queue fills with runs from one host and the fetchers hit it all at once. And it treats a news home page like a ten-year-old forum thread.
Split the frontier into two layers. The front layer is a handful of priority queues: high for pages that change often or rank well, low for the long tail. The back layer is one queue per host, and each host is in at most one fetcher's hands at a time. A min-heap holds each host keyed by the time it may next be fetched. When a host's queue runs dry, it is refilled from the front layer, picking high priority more often than low.
host, ready_at = heap.peek()if ready_at > now: wait until ready_atheap.pop(); url = queue[host].pop()lease url to fetcheron complete: heap.push(host, now + delay[host])- heap (host @ ready time)0a @ 9.81b @ 10.32c @ 11.0
- now
- 10.0 s
1Three hosts are waiting, each keyed by the time it may next be fetched. The clock says 10.0 s.
- heap (host @ ready time)0b @ 10.31c @ 11.0
- now
- 10.0 s
- busy
- a
2Host
ahas been ready since 9.8 s, so its next URL is leased andaleaves the heap: no second fetcher can touch it. - heap (host @ ready time)0b @ 10.31c @ 11.0
- now
- 10.0 s
- busy
- a
3Another fetcher asks. The top is
bat 10.3 s, still in the future, so it waits 0.3 s. - heap (host @ ready time)0c @ 11.0
- now
- 10.3 s
- busy
- a, b
4At 10.3 s
bis leased. - heap (host @ ready time)0c @ 11.01a @ 11.9
- now
- 10.9 s
- busy
- b
5The fetch from
aends at 10.9 s. With a 1 s delay,agoes back in at 11.9 s. The gap counts from the end of a fetch, so slow sites are fetched less often. - heap (host @ ready time)0c @ 11.01a @ 11.9
- hosts
- 10 million
- log2
- ≈ 23
6Every pop and push is O(log h) for h hosts. With 10 million hosts that is about 23 comparisons, so the heap is never the bottleneck.
To scale out, split the frontier by host with consistent hashing over, say, 10 machines. Each host lives on exactly one machine, so politeness needs no coordination. Queues live on disk with a small in-memory head.
For priority, score each page by how often it changed on past fetches and how many known pages link to it.
Deduplicating URLs
Each page adds about 50 links to check, so at 400 pages a second the seen set answers 20,000 lookups a second, nearly all "already seen".
Two details make it correct. The check is insert-if-absent, one atomic step on the shard, so two parsers finding the same link cannot both add it. And a URL counts as seen only once the frontier has stored it, so a crash never leaves a URL marked seen but never queued.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Full URLs in a database | Exact, and you can list and inspect what you have seen. | 10 billion URLs at about 100 bytes is 1 TB. Every check is a network read. | Small crawls, or as the cold record behind a faster check. |
| 8-byte hashes, sharded in memory Usual default | 80 GB across 10 machines is 8 GB each. Effectively exact: with 10 billion 64-bit hashes you expect about 3 accidental matches in total. | Needs that memory and a lookup per batch over the network. | The seen set must not silently drop pages. My default here. |
| Bloom filter | About 12 GB for 10 billion URLs at a 1% false-positive rate. Never says "new" for a URL it has seen. | 1% of truly new URLs are wrongly reported as seen and never crawled. You cannot delete entries. | Memory is the hard limit and losing a small share of pages is fine. |
A Bloom filter is a big bit array plus k hash functions. Adding a URL sets k bits; checking it looks at the same k bits. If any is 0, the URL is certainly new. If all are 1, it is probably seen, because other URLs may have set those bits. For a 1% false-positive rate you need about 9.6 bits per item and 7 hash functions: 1010 × 9.6 bits ≈ 12 GB.
The catch to say out loud: a false positive is sticky. The same new URL hits the same bits every time it is discovered, so it is skipped every time, for as long as that filter lives. If you use a Bloom filter, rebuild it each crawl cycle with fresh hash seeds so the unlucky URLs change. At this scale I would pick the hash set. At 100 billion URLs (800 GB against 120 GB) the trade flips.
Deduplicating content
Different URLs often serve the same page: mirrors, ?ref= variants, printer-friendly copies. Store a 64-bit fingerprint of each body (8 bytes × 1 billion pages = 8 GB) and skip parsing when it is already known. That catches exact copies only. Pages that differ by a timestamp or an ad need a similarity hash, where similar pages get fingerprints that differ in a few bits. Mention it; design it only if asked.
DNS and fetch throughput
A fetch is mostly waiting. With non-blocking I/O, ten fetcher machines holding 80 open connections each cover the 800 we planned for. The CPU goes to parsing, which is why parsers are a separate fleet.
DNS is the classic hidden bottleneck: many standard resolver calls block a thread, and an uncached lookup can take 10 to 200 ms. Run a caching resolver next to the fetchers, keep each host's IP until its DNS time to live runs out, and resolve a host just before its turn in the heap. If one lookup serves 20 fetches on average, 400 fetches a second need only 400 ÷ 20 = 20 lookups a second.
Robots rules and traps
Before the first fetch from a host, download its /robots.txt and cache the parsed rules for about a day. Obey Disallow paths and any longer delay the file asks for. If the file returns a server error, treat the site as off limits for now; a 404 means no rules.
Some sites generate URLs without end: a calendar with a "next month" link forever, session IDs in every link, filters that combine into millions of pages. Defend in layers: cap URL length (say 2,000 characters) and path depth, cap pages per host per cycle, strip session parameters, and flag hosts where most fetched pages have a fingerprint you have already seen.
Bottlenecks and failures
At 10x (10 billion pages a month, about 4,000 fetches a second), fetchers and parsers scale by adding machines, and bandwidth becomes 400 MB/s, about 3.2 Gbit/s. The seen set heads toward 100 billion URLs, where a Bloom filter starts to earn its loss rate. The real limit is politeness: large sites cannot be fetched faster, so their freshness comes from smarter priority and conditional requests (If-Modified-Since), where a 304 Not Modified costs almost nothing.
| When this dies | What happens | What you do |
|---|---|---|
| A fetcher | Its leased URLs are stuck until their leases expire. | After 60 seconds the frontier leases them to others. At most a minute of that machine's work is redone. |
| A frontier machine | Its hosts are not crawled; nothing else stops. | Queues are on disk and replicated; a standby takes over that host range and replays from the last checkpoint. |
| A seen-set shard | Links for its hash range cannot be checked. | A replica takes over, or the shard is rebuilt from the URL table; parsers buffer and retry meanwhile. Never treat "unknown" as "new". |
| The DNS resolver | Cached hosts keep working; new hosts wait. | Run two resolvers; the frontier skips hosts whose lookup fails and tries them later. |
| A website | Timeouts and 5xx errors. | Back off that host exponentially, and after several days of failure drop it to low priority. |
What a strong answer sounds like
Everyone draws the loop. The difference is whether politeness and deduplication shape the design.
| Level | What the answer does |
|---|---|
| Junior | Draws a queue, fetchers and a database; uses a set for visited URLs; adds a rate limit when asked about overloading sites. |
| Mid | Estimates fetch rate and storage, uses per-host queues for politeness, hashes URLs for the seen set, and respects robots rules. |
| Senior | Builds the frontier from priority plus per-host scheduling, partitions it by host so politeness needs no coordination, weighs an exact hash set against a Bloom filter with the sticky false-positive cost, leases instead of pops, and names politeness as the limit on freshness. |
Why not just use a Bloom filter? It's much smaller.
It is: about 12 GB against 80 GB for 10 billion URLs. But at 1% false positives, one in a hundred genuinely new URLs is reported as seen, and because the same URL hits the same bits every time, it is never crawled this cycle. 80 GB split over ten machines is cheap, so I would rather be exact. If we grew to 100 billion URLs I would switch, and rebuild the filter each cycle so the lost URLs are not always the same ones.
Follow-up questions
How do you decide when to recrawl a page?
Track how often its fingerprint changed over past fetches and schedule around that interval, shorter for important pages. Conditional requests make an unchanged page cost a 304, not a download.
How would you crawl pages that need JavaScript to render?
Send only the pages that need it to a separate pool of headless browsers. Rendering costs far more CPU than parsing HTML, so keep it as a per-site exception.
A site asks you to stop crawling it. What changes?
Add the host to a blocklist the frontier checks before leasing, and purge its queue. Each host lives on one frontier machine, so this takes effect at the next lease.
How would you crawl from several regions?
Give each host to the region nearest its servers, so fetches are short and each host still has one owner. Only new URLs cross regions, to the seen set.
How do you know the crawler is healthy?
Watch fetches per second, the duplicate-content share, error rates per host, and the age of the oldest due URL in the high-priority queue, which tells you whether important pages are going stale.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".
Before drawing
Numbers and interfaces
Design and depth
Failure
What to practise next
Next, Design Video Streaming moves huge files instead of small pages. For the queue ideas here, see Message Queues and Streams.