← System design

Search & Discovery

Design Web Crawler

A crawler is a loop you can write in twenty lines: take a URL, download it, pull out the links, queue the new ones. At 6 billion pages a month that loop turns into a scheduling problem. You have to decide which of forty billion known URLs deserve a fetch today, spread the work across hundreds of machines, and never send one small website more traffic than it can take.
Core: polite frontier + layered dedup6B pages per 28-day cycleBandwidth and CPU boundOne open connection per host

01

What the crawler must do

02

What the crawler must do

Functional
  • 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.
Non-Functional
  • 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.
Pin the scope first

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

03

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.

QuantityWorkingResult
Fetches per cycleassumption6B
Average fetch rate6B / (28 x 86,400 s)about 2,480 / s
Peak fetch rate2x for catch-up after an outageabout 5,000 / s
Inbound bandwidth2,480 / s x 120 KBabout 300 MB / s, roughly 2.4 Gbps
Stored per cycle6B x 25 KB after compressionabout 150 TB
Raw page retention3 cycles kept, erasure coded at 1.4xabout 630 TB
Known URLs6B crawled plus about 34B discovered, never fetchedabout 40B
URL records40B x about 110 B (URL, hash, scores, timestamps)about 4.4 TB
Seen-URL Bloom filter40B entries at 1% false positives, 9.6 bits eachabout 48 GB
Links extracted2,480 / s x 60about 150,000 / s into dedup
Concurrent fetches2,480 / s x 0.9 s mean fetch timeabout 2,200 in flight, more with politeness gaps
Parse CPU2,480 / s x 12 ms CPU per pageabout 30 cores busy, provision 80
What actually limits you

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.

The 150,000 per second number

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

04

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.

POST/v1/crawl-jobsRegister a seed set and its scope
// Operator call. Scope rules decide which discovered links stay in this job. { "name": "retail-catalogue-refresh", "seeds": ["https://shop.example.org/sitemap.xml"], "scope": { "hosts": ["*.example.org"], "max_depth": 12 }, "default_revisit_hours": 72, "render_js": false } // 201 Created { "job_id": "cj_7Q2", "seeds_accepted": 1 }
POST/internal/frontier/leaseA fetcher asks for its next batch
// Fetchers only receive URLs for host buckets they own. { "worker": "fetch-us-east-114", "max_urls": 400 } // 200 OK. Each URL comes with the earliest time it may be requested. { "lease_id": "ls_90af", "expires_in_s": 600, "urls": [ { "url": "https://blog.example.net/2026/09/notes", "not_before": 1790845212, "etag": "W/\"5d1\"" } ] }
POST/internal/frontier/ackReport outcomes for a leased batch
{ "lease_id": "ls_90af", "results": [ { "url_hash": "a91c03e7", "status": 304, "fetched_at": 1790845215 }, { "url_hash": "0be4f210", "status": 503, "retry_after_s": 120 } ] } // URLs not acked before the lease expires go back to the frontier.
GET/v1/urls/lookup?url=https%3A%2F%2Fblog.example.net%2F2026%2F09%2FnotesExplain one URL's crawl history
{ "canonical": "https://blog.example.net/2026/09/notes", "last_fetch": "2026-09-28T04:11:02Z", "last_status": 200, "next_due": "2026-10-04T00:00:00Z", "duplicate_of": null, "blocked_by": null // or "robots", "scope", "trap-budget" }

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

05

Architecture overview

Frontier, fetch, parse and dedup loopDiagram
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.

Frontier

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.

Fetcher fleet

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.

Parser fleet

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.

Dedup layer

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

06

Deep dive: the frontier and the dedup stack

The two problems that decide the design

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

Two-tier frontier with per-host back queuesDiagram
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

LayerQuestion it answersStructureCost per check
1. URL normalisationAre these two strings the same address?Rules: lowercase host, drop default port and fragment, sort query keys, strip tracking parametersMicroseconds of CPU
2. Seen-URL filterHave we ever queued this address?Bloom filter, about 48 GB, sharded by URL hashA few memory reads
3. Exact content hashIs this body byte-identical to one we hold?64-bit hash of normalised text in a KV storeOne lookup
4. Near-duplicate checkIs this the same article with a different header or ad slot?64-bit SimHash split into 4 bands for lookupFour 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.

Traps hide in the URL space

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

07

Decisions and their costs

Chosen
Each host owned by one fetcher process

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 correct
Rejected
Any fetcher, coordinated through a shared rate limiter

Every 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 part
Chosen
Bloom filter in front of the URL store

Answers 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 second
Alternative
Exact set of URL hashes

A 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 paramount
Chosen
Kafka between fetchers and parsers

Absorbs 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 cost
Rejected
Fetcher calls the parser inline

Fewer 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 workloads
Chosen
Revisit interval learned from observed change

Spends 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 budget
Rejected
Same interval for every page

Easy to reason about, but news pages go stale while years-old archive pages are fetched again and again for nothing.

Wastes most of the crawl

07

Failure modes

08

Failure modes

🕳️
An infinite URL space eats the budget

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.
🐌
Slow-drip servers tie up sockets

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.
🚫
A site blocks our IP range

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.
💥
A fetcher node dies mid-batch

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.
📈
The Bloom filter fills past its design point

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.
🧭
DNS becomes the bottleneck

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

09

How to handle the interview

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

7

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.

8

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

11

Growing the crawler in stages

Grow it in order

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.

Stage 1: a few pages per second
One process and a queue table

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.

Stage 2: about 200 pages per second
Async fetching on one big machine

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.

Stage 3: about 2,500 pages per second
Fetcher and parser fleets around a partitioned frontier

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.

Stage 4: freshness at scale
Learned revisit intervals and near-duplicate detection

Per-URL change history drives scheduling, conditional GETs cut bandwidth, and SimHash keeps mirrors out of the index.

Stage 5: global and specialised
Regional fetch pools and purpose-built crawlers

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.