← System Design
Classic Case Studies

Design a Web Crawler

Watch: "Design a Web Crawler System Design Interview w/ a Ex-Meta Staff Engineer" — Hello Interview
In simple words

A tireless reader in a huge library who reads a page, writes down every reference it mentions on a to-read list, and repeats, while never visiting the same shelf twice and never pestering one librarian too often.

Problem Statement

Design a web crawler that downloads billions of web pages for a search engine: start from seed URLs, fetch pages, extract links, and keep discovering and re-fetching pages, while being polite to websites, avoiding duplicates and traps, and scaling across many machines.

Functional Requirements

Non-Functional Requirements

1B pages / month ~ 400 pages/s (peak ~800/s)
avg page 100 KB -> 100 TB/month raw HTML (compress ~5x -> ~20 TB)
URLs seen: tens of billions over time -> dedup set must be compact (Bloom filter / hashed ids)

Core Entities

API Design

Mostly internal components rather than a public API:

Frontier.add(urls[], priority)          Frontier.next(worker_id) -> url (host-polite)
Fetcher.fetch(url) -> {status, headers, body}
Store.put(url, content, hash)           Seen.check_and_add(url) -> bool

High-Level Design (HLD)

Web crawler loopURL frontierpriority + per-hostFetchers(robots, DNS cache)Content storeParser(extract links)Seen filter(Bloom + URL db)next politeURLHTMLnew linksunseen URLs
Fetch, store, extract links, drop the ones already seen, and feed the rest back into the frontier.
seeds -> [ URL Frontier ] --(url)--> Fetcher workers --(DNS cache, robots cache)
           ^  priority queues            |
           |  + per-host queues          v  HTML
           |                      [ content store: object storage ]
           |                             |
           |                      Parser / link extractor
           |                             |  links
           |                      URL normaliser -> URL filter (robots, type, traps)
           |                             |
           +------ new URLs ------ "seen?" check (Bloom filter + URL db)
                                  content dedup: hash / simhash of page

Low-Level Design (LLD)

URL frontier: priority first, then politenessPriority queue:highPriority queue:lowPrioritiser(weighted pick)Host queue:example.comHost queue:news.siteHeap ofnext-allowedtimeFetcher workerearliestdue host
Important URLs first, but each host has its own queue and a minimum delay between fetches.

The URL frontier: priority + politeness

Front queues (priority):  F1 (high) ... Fn (low)
   priority from PageRank-like importance, change frequency, freshness need
                 |  prioritiser picks from front queues (weighted)
                 v
Back queues (politeness): one FIFO per host (or host group)
   host -> back queue mapping table
   a heap of (next_allowed_time, back_queue) decides which host is due

worker: pop the earliest-due host from the heap -> take its next URL ->
        fetch -> put host back with next_allowed_time = now + crawl_delay

This two-level design gives important pages first while guaranteeing no host gets more than one in-flight request at a time. The frontier is partitioned by host hash across machines so each host's queue lives in one place.

Dedup and normalisation

Robustness

Re-crawl scheduling

Track how often each page's content hash changes and set its next due time accordingly: news homepages every few minutes, static docs every few weeks. Honour Last-Modified/ETag with conditional GETs to save bandwidth.

Follow-up Questions

How do you distribute crawling across 100 machines?
Partition by hash(host): each machine owns a set of hosts, their queues and politeness state. Discovered URLs for other hosts are forwarded to the owning machine. Consistent hashing eases adding machines.
How do you crawl JavaScript-rendered pages?
A separate, much more expensive rendering tier (headless browsers) for pages detected as needing it, with its own lower throughput budget.
Why a Bloom filter for seen URLs instead of a hash set?
With tens of billions of URLs, an exact in-memory set needs hundreds of GB; a Bloom filter at ~1% false positives needs roughly 10 bits per URL, and a false positive only means skipping a page.

Related