Search & Discovery
Design Web Crawler
01
What the crawler must do
What the crawler must do
- Start from a seed list and keep discovering new URLs by following links found in fetched HTML.
- Fetch each page, store the raw response and hand the parsed text and outlinks to the indexing team.
- Obey robots.txt and any crawl-delay a site declares, and stop fetching a host that asks us to.
- Revisit known pages on a schedule that follows how often they actually change: news front pages hourly, an old forum thread every few months.
- Skip URLs we have already seen and pages whose content is a copy of something we already hold.
- Let operators add seeds, block domains and look up why a given URL was or was not crawled.
- Sustain about 2,500 fetches per second on average, with headroom for twice that.
- Never hold more than one open connection per host by default, and never exceed the host's stated delay.
- Survive the loss of any single machine without losing the frontier or recrawling large ranges.
- Fail soft against hostile or broken sites: endless URL spaces, slow-drip servers, 2 GB files labelled as HTML.
- Scale horizontally: doubling the crawl rate should mean roughly doubling the fetcher and parser fleets, nothing else.
Ask what the crawl is for before drawing anything. A search engine crawl, a price monitor and an archive behave very differently. A search crawl wants broad coverage and fresh popular pages. A price monitor wants a few million product URLs refreshed every hour. An archive wants every version kept forever. Also ask whether JavaScript must be executed, because a headless browser costs roughly fifty times more CPU per page than plain HTML parsing, and that single answer can change the fleet size by an order of magnitude.
02
Sizing the crawl
Sizing the crawl
Assume the index holds 6 billion pages and we want each one refreshed on average once every 28 days, with popular pages far more often and dead ones far less. The average HTML response is 120 KB before compression. Every page yields around 60 outlinks, most of which we have already seen.
| Quantity | Working | Result |
|---|---|---|
| Fetches per cycle | assumption | 6B |
| Average fetch rate | 6B / (28 x 86,400 s) | about 2,480 / s |
| Peak fetch rate | 2x for catch-up after an outage | about 5,000 / s |
| Inbound bandwidth | 2,480 / s x 120 KB | about 300 MB / s, roughly 2.4 Gbps |
| Stored per cycle | 6B x 25 KB after compression | about 150 TB |
| Raw page retention | 3 cycles kept, erasure coded at 1.4x | about 630 TB |
| Known URLs | 6B crawled plus about 34B discovered, never fetched | about 40B |
| URL records | 40B x about 110 B (URL, hash, scores, timestamps) | about 4.4 TB |
| Seen-URL Bloom filter | 40B entries at 1% false positives, 9.6 bits each | about 48 GB |
| Links extracted | 2,480 / s x 60 | about 150,000 / s into dedup |
| Concurrent fetches | 2,480 / s x 0.9 s mean fetch time | about 2,200 in flight, more with politeness gaps |
| Parse CPU | 2,480 / s x 12 ms CPU per page | about 30 cores busy, provision 80 |
Bandwidth and storage are large but boring: a few racks and an object store. The binding constraint is politeness. Two hundred million hosts each accept roughly one request every few seconds, so throughput comes from keeping many thousands of hosts busy at once, not from fetching any one host faster.
Link extraction produces sixty times more candidate URLs than fetches. Most of them die in the first dedup check, which is why that check must be an in-memory structure, not a database read.
03
Control and worker APIs
Control and worker APIs
A crawler has few external users, so the API is mostly internal: operators control jobs and scope, and fetchers lease work from the frontier. Fetcher calls are batched, because a per-URL RPC at 2,500 per second would waste more time on overhead than on downloading.
Conditional headers matter here. Sending the stored ETag or Last-Modified lets unchanged pages answer with a 304 and no body, which on a mature crawl can cut bandwidth by a third or more.
04
Architecture overview
Architecture overview
flowchart LR
SC["Recrawl scheduler"] --> FR[("Frontier: priority queues + host queues")]
FR --> FE["Fetcher fleet, host-pinned"]
FE --> DNS["Caching DNS resolver"]
FE --> RB[("robots.txt cache")]
FE --> WEB(("The web"))
FE --> OS[("Object store: raw pages")]
FE -. "page fetched" .-> K[["Kafka: fetched"]]
K --> PA["Parser fleet"]
PA --> CD["Content dedup: hash + SimHash"]
PA --> LX["Link extractor + normaliser"]
LX --> UD["Seen-URL filter"]
UD --> FR
CD --> IX[["To indexing"]]
PA --> MD[("URL metadata store")]The design is a cycle, and every component sits on that cycle exactly once. The frontier decides what is fetched next. Fetchers download and nothing else. Parsers turn bytes into text and links. A dedup layer stops known URLs from re-entering the frontier and stops copied content from reaching the index. A scheduler feeds known pages back into the frontier when they are due again.
Fetching and parsing are split by a Kafka topic because they fail and scale differently. Fetchers spend their lives waiting on the network and need lots of sockets and little CPU. Parsers are the opposite. With a log between them, a parser deploy or a burst of very large pages builds up lag in Kafka instead of stalling downloads, and you can replay a day of fetched pages through a fixed parser without touching the web again.
Raw bodies go to object storage keyed by URL hash and fetch time. The Kafka message carries only the pointer and response headers, which keeps the topic small: around 2,500 messages per second of 1 KB each instead of 300 MB per second of HTML.
Two tiers. Front queues are ordered by priority, so important and overdue URLs leave first. Back queues hold one FIFO per host, plus a heap of next allowed fetch times, which is how politeness is enforced without locks.
Async HTTP clients with thousands of sockets per node. Each node owns a slice of host buckets, so every request to a given host leaves from one process that knows exactly when it last called that host.
Stateless consumers. Decode charset, strip boilerplate, extract links, compute fingerprints, write metadata. Pages that need rendering go to a separate, much smaller headless-browser pool.
A sharded Bloom filter for URLs seen, an exact hash table for identical bodies, and SimHash buckets for near copies. Each check is cheaper than the one behind it.
05
Deep dive: the frontier and the dedup stack
Deep dive: the frontier and the dedup stack
Everything interesting in a crawler is either which URL do we fetch next, and from where, or have we already got this. The first is the frontier. The second is a stack of filters. Get both right and the rest is horizontal scaling of simple workers.
Inside the frontier
flowchart TD
IN["New and due URLs"] --> PR["Prioritiser: score from links, freshness, job"]
PR --> F1["Front queue: urgent"]
PR --> F2["Front queue: normal"]
PR --> F3["Front queue: background"]
F1 --> RT["Router: weighted pick, map host to back queue"]
F2 --> RT
F3 --> RT
RT --> B1["Back queue: host A"]
RT --> B2["Back queue: host B"]
RT --> B3["Back queue: host C"]
B1 --> HP{"Heap of next allowed times"}
B2 --> HP
B3 --> HP
HP --> FT["Fetcher thread takes earliest ready host"]This is the structure the Mercator paper made popular, and it is still the cleanest answer. The front half handles importance. Each incoming URL gets a score from its inbound link count, how stale our copy is, how often the page has changed in the past and which job asked for it. A weighted random pick across a few priority bands means background URLs still drain slowly instead of starving.
The back half handles politeness. Every host maps to exactly one back queue on one fetcher node. A min-heap keyed by the earliest time each host may be contacted again tells a fetcher thread which queue to service. After a fetch, the host's next time is set to now plus the larger of its robots crawl-delay and a multiple of how long the last response took, so slow servers automatically get gentler treatment.
Keep the back queues short, a few hundred URLs per host, and refill them from the front when they drain. A long back queue for one giant host is wasted memory, because that host can only take one request every second or two anyway.
Where the frontier lives
Forty billion URLs do not fit in memory, but the part that matters right now does. Store the full URL table in a partitioned on-disk store such as RocksDB or Cassandra, keyed by host then URL hash. Each frontier partition keeps only the next few hours of due work in memory and pages more in as queues drain. Partition by host hash, the same key the fetchers use, so every component that touches a host agrees on where that host lives.
When a fetcher node dies, its host buckets move to other nodes through consistent hashing. The new owner reads the hosts' last-fetch times from the URL store before sending anything, so a reassignment never causes a burst against a site that was just contacted.
The dedup stack
| Layer | Question it answers | Structure | Cost per check |
|---|---|---|---|
| 1. URL normalisation | Are these two strings the same address? | Rules: lowercase host, drop default port and fragment, sort query keys, strip tracking parameters | Microseconds of CPU |
| 2. Seen-URL filter | Have we ever queued this address? | Bloom filter, about 48 GB, sharded by URL hash | A few memory reads |
| 3. Exact content hash | Is this body byte-identical to one we hold? | 64-bit hash of normalised text in a KV store | One lookup |
| 4. Near-duplicate check | Is this the same article with a different header or ad slot? | 64-bit SimHash split into 4 bands for lookup | Four lookups plus a bit compare |
Layer 2 handles the 150,000 links a second. A Bloom filter never says "new" for a URL it has seen, so it never causes a duplicate fetch. It sometimes says "seen" for a URL it has not, which means we occasionally skip a genuinely new page. At a 1% rate that is acceptable, because important pages are linked from many places and will be offered again. For URLs from sitemaps or high-value seeds, bypass the filter and check the URL store directly.
Layer 4 is what protects the index from mirrors, syndicated news and printer-friendly copies. SimHash turns a document's weighted word features into 64 bits where similar documents differ in only a few bit positions. Split the fingerprint into four 16-bit bands. Any two documents within three bits of each other must match exactly on at least one band, so you only compare against documents that share a band. When a near copy is found, keep the version with the stronger link signal as canonical and record the other as duplicate_of.
Deciding when to come back
A fixed revisit interval wastes most of the budget. Track, per URL, how many of the last N fetches found changed content, and estimate a change rate. Schedule the next visit so the expected staleness is roughly equal across pages of similar importance. A page that changed on 9 of the last 10 visits halves its interval. One that has not changed in six visits doubles it, up to a cap. A 304 response counts as "unchanged" at almost no cost, so conditional requests make this feedback cheap to collect.
A calendar widget with a "next month" link, a session ID in every URL or faceted search with a dozen filters can generate unlimited distinct URLs that all pass the Bloom filter. The filter cannot help, because each URL really is new. Defend with a per-host page budget per cycle, a path depth limit, a cap on query parameters, and a detector that flags hosts where newly found URLs produce near-identical SimHashes.
06
Decisions and their costs
Decisions and their costs
Politeness becomes a local timestamp check with no network round trip. The cost is uneven load: a node that happens to own a few very large hosts runs slower, so you rebalance buckets by observed work, not only by hash.
Simple and correctEvery fetch would need a call to Redis or similar to claim a slot for that host. At 2,500 fetches per second that is manageable, but it adds a failure point and a race on every request, and bugs here get your IP ranges banned.
Extra moving partAnswers most link checks from memory in under a microsecond. Losing 1% of new URLs to false positives is a deliberate, measured price. Rebuild it each cycle from the URL store so the fill ratio stays in its designed range.
Right for 150K checks per secondA 64-bit hash per URL is 320 GB for 40B URLs, still feasible sharded across RAM or SSD, and it never drops a new URL. Pick it if completeness matters more than memory, for example in a compliance archive.
Valid when coverage is paramountAbsorbs bursts, decouples deploys and lets you reprocess stored pages after a parser bug. You pay with a few seconds of end-to-end delay and another system to operate.
Worth the operations costFewer components, but a slow parser now holds a socket open, a crashing parser loses the page, and there is no way to replay.
Couples two very different workloadsSpends fetches where content actually moves, so freshness per fetch goes up sharply. It needs history per URL and a cold-start default for new ones.
Best use of a fixed budgetEasy to reason about, but news pages go stale while years-old archive pages are fetched again and again for nothing.
Wastes most of the crawl07
Failure modes
Failure modes
A shop's faceted search links every filter combination, and within hours one host has 30 million queued URLs. All are new, so dedup lets them through, and other sites in the same bucket fall behind schedule.
Enforce a per-host URL budget per cycle scaled by the host's importance, cap path depth and query parameter count, and auto-quarantine hosts whose new pages are mostly near duplicates.Some servers send one byte every few seconds, by accident or on purpose. Each holds a connection open indefinitely, and a fetcher with a few hundred of them stops making progress.
Set a total deadline per fetch as well as connect and read timeouts, cap body size at a few MB for HTML, and abort responses whose Content-Type does not match what was expected.After a bucket reassignment the new owner did not load last-fetch times, sent a burst, and the site returned 403s to every address in the range.
Load host state before taking ownership, back off exponentially on 429 and 503, honour Retry-After, publish a crawler info page and a contact address in the User-Agent, and alert when a host's error rate spikes.It held a lease on 400 URLs and had fetched 250. Nothing was acked.
Leases expire and the frontier re-offers the whole batch. Writes to the object store and metadata are keyed by URL hash and fetch time, so a repeated fetch overwrites rather than duplicates. Ack in small groups to keep the redo window short.Discovery ran ahead of forecast and the filter now holds twice the entries it was sized for. False positives climb from 1% to around 8%, and new pages quietly stop being discovered.
Track the fill ratio as a metric, size each filter for the next cycle's projected URL count, and swap in a freshly built filter from the URL store at cycle boundaries.Resolver latency of 200 ms on each new host adds up when the crawl touches millions of hosts a day, and public resolvers start rate-limiting us.
Run local caching resolvers on every fetcher rack, prefetch DNS for hosts about to reach the front of the heap, and respect record TTLs instead of re-resolving per request.08
How to handle the interview
How to handle the interview
Lead with politeness. Interviewers wait to see whether you notice that one host cannot take 2,500 requests a second. Bring up robots.txt, crawl-delay and per-host queues before you draw the fleet.
Name the frontier's two jobs. Priority and politeness pull in opposite directions. Showing the front queues for importance, back queues for hosts split is the single strongest signal in this question.
Do the dedup arithmetic. State how many links per second you check and why a database lookup per link is too slow. Then size the Bloom filter in gigabytes out loud.
Separate URL dedup from content dedup. Different URLs can serve the same page, and the same URL can change. Mention exact hashing and SimHash as two different tools.
Raise crawler traps yourself. Calendars, session IDs and faceted search are classic. A budget per host is a better answer than smarter URL rules alone.
Explain how you recover. Leases with expiry, idempotent writes keyed by URL hash, and a durable frontier. Say what gets fetched twice after a crash and why that is fine.
Talk about freshness, not just coverage. A crawl that never revisits is a snapshot. Change-rate scheduling and conditional GETs show that you know the steady state, not just the first pass.
Ask about JavaScript. Whether pages must be rendered changes cost per page by more than an order of magnitude. Route only the pages that need it to a browser pool.
09
Growing the crawler in stages
Growing the crawler in stages
Each stage below is forced by a measurable limit. Build the next one when you hit that limit, not because the final picture has it.
A script pops URLs from a Postgres table, fetches with a polite delay per host and inserts new links with a unique index. Good for a research crawl of a few million pages.
Switch to an event-loop HTTP client with thousands of concurrent requests, add per-host queues in memory and a local DNS cache. Raw pages move to object storage.
Hosts are hashed to fetcher nodes, Kafka sits between fetch and parse, and a Bloom filter replaces the unique index. The single queue table is retired.
Per-URL change history drives scheduling, conditional GETs cut bandwidth, and SimHash keeps mirrors out of the index.
Fetchers run close to the sites they crawl, a headless pool renders script-heavy pages, and separate high-frequency crawlers handle news and product feeds with their own budgets.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
