SYSTEM DESIGN · DESIGN PROBLEMS · SEARCH AND DISCOVERY · MEDIUM · 24 OF 41

Design a Web Crawler: URL Frontier, Politeness, Dedup and Recrawl

Design a crawler that fetches a billion pages a month without hammering any site. Requirements and scale, the control interfaces and URL table, the fetch-parse-enqueue loop, then the hard parts: a URL frontier with priority and per-host politeness queues, URL and content deduplication with Bloom filters and fingerprints, spider traps, recrawl scheduling, partitioning by host, and the trade-offs.

Reported atGoogleApple

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Clarify the goal first. We start from seed URLs, fetch HTML, store the raw page for an indexer, extract links and keep going, and we revisit pages that change. Non-functional needs matter more here. Be polite: obey robots dot txt and never hit one host with many parallel requests. Survive malformed pages and infinite link traps. Scale horizontally and resume after a crash. Out of scope: rendering JavaScript, images and video, and the search index itself.

Requirements
  • Functional
  • start from seed URLs
  • fetch HTML, store it
  • extract links, crawl them
  • recrawl pages that change
  • Non-functional
  • polite: obey robots.txt,
  • one request per host at a time
  • robust to traps and bad HTML
  • scalable, restartable

Ask scale

A billion pages a month is about four hundred pages a second, eight hundred at peak. At a hundred kilobytes a page that is forty megabytes a second of download, around three hundred megabits, and a hundred terabytes a month raw, about three hundred terabytes a year compressed. We will know of ten billion URLs, so remembering which ones we have seen takes eighty gigabytes of hashes, or about twelve as a Bloom filter. None of this is CPU heavy; waiting on slow sites is the real constraint.

Back of the envelope
1B pages/month / 2.5M s = 400 pages/s
peak x2 = 800 pages/s

avg HTML page 100 KB
  400 x 100 KB = 40 MB/s = 320 Mbps
  1B x 100 KB = 100 TB/month raw
  compressed 4x = 300 TB/year

seen URLs: 10B x 8-byte hash = 80 GB
  Bloom filter at 1% error = 12 GB

Define API

A crawler has no end users, so the API is about control. Operators add seeds, read stats and override the rate for a specific host, for example after a site owner complains. Internally the key interface is the frontier: a worker leases a URL and later acknowledges it with the result. Leases matter, because if a worker dies the URL goes back to the queue. Every fetched page emits an event that the indexer consumes.

Interfaces
POST /v1/seeds  { urls: [...], priority }
GET  /v1/stats  pages/s, errors, queue
PUT  /v1/hosts/{host}/limit { rps: 0.5 }

frontier.lease(worker) -> url, lease_id
frontier.ack(lease_id, result)

event page.fetched {
  url, status, content_hash, blob_key }

Data model

Two tables and a blob store. The URL table is keyed by a hash of the normalized URL and holds depth, priority, when we last fetched it, when to fetch it next, and the ETag and content hash from last time. The host table holds parsed robots rules and the earliest time we may hit that host again. Raw pages go to object storage, packed into segment files. Ten billion rows with only key lookups is a great fit for a wide-column store like Cassandra; we never need joins.

Records
urls (key = hash(normalized url))
  url, host, depth, priority
  last_fetch, next_fetch, status
  etag, last_modified, content_hash
hosts (key = host)
  robots_rules, crawl_delay, next_ok_at
pages: segment files in object store
  url_hash + time -> segment, offset

NoSQL: point reads by key, 10B rows

High-level design

Here is the loop. A fetcher leases a URL from the frontier, resolves the host through a local DNS cache, and downloads the page. The raw HTML is appended to object storage. A parser pulls out links and text. Each link is normalized and checked against the seen set; most are already known, and only genuinely new URLs go back into the frontier. Then the worker leases the next URL. Everything is a queue, so every stage scales on its own.

Deep dive: frontier

The frontier is the heart of the design, and it has two layers. Front queues sort URLs by priority, based on things like how many pages link to them and how often they change. Back queues hold one queue per host. A heap orders hosts by the earliest time we are allowed to contact them again. A worker pops a host whose time has come, takes one URL, and pushes the host back with its next allowed time. Important pages go first, and no host ever sees a burst.

Two-layer frontier
front: priority queues P1..P5
  priority from link popularity,
  site quality, change rate

back: one FIFO queue per host
  a heap orders hosts by next_ok_at
  worker pops the host whose time
  has come, takes one URL
  next_ok_at = now + crawl delay

result: important first, polite always

Deep dive: politeness

Politeness is a requirement, not a nicety. Fetch each host's robots dot txt, cache it for about a day, and skip disallowed paths. Keep one connection per host with a gap of a second or two, or whatever crawl delay the site asks for. If a site returns 429 or 503, double the gap and slow down. And send an honest user agent string with a contact page, so site owners can reach you instead of blocking you.

Manners
  • fetch robots.txt per host,
  • cache it for about a day
  • skip disallowed paths
  • one connection per host
  • default gap 1-2 s, or the
  • site's crawl-delay
  • 429 or 503: double the gap
  • identify: clear User-Agent
  • with a contact page

Deep dive: URL dedup

One page can be reached by dozens of URL spellings, so normalize first: lowercase the host, drop fragments and tracking parameters, sort query parameters and resolve relative paths. Then ask a Bloom filter. If it says no, the URL is definitely new. If it says maybe, confirm against the URL table. A Bloom filter never misses a URL it has seen; its only error is occasionally claiming to know a new one, and at one percent we accept skipping a few pages.

Have we seen this URL?
normalize first:
  lowercase host, drop #fragment
  drop tracking params (utm_*)
  sort query params, resolve ../

Bloom filter in memory (12 GB)
  "no" is certain -> new URL
  "maybe" -> check urls table

1% false positive: some pages skipped

Deep dive: content dedup

Even with perfect URL dedup, the same content lives at many addresses: mirrors, print views, session-id URLs. Hash the body to catch exact copies, and store only a pointer. For near copies, where only a date or an ad changed, compute a SimHash of the text. Similar pages produce fingerprints that differ in just a few bits, so a page within three bits of a known one is treated as a duplicate. This can save a large fraction of storage and indexing work.

Some sites, by accident or on purpose, generate infinite URLs: a calendar with a next link forever, a session id baked into every link, endless sort and page parameters. Defend with simple limits. Cap the depth from the seed, cap the URL length, give each host a page budget per day, and skip paths with repeating segments. A trap then costs you a few thousand pages instead of your whole crawl.

Same page, new URL
exact copy: hash the body (SHA-256)
  match -> store a pointer only

near copy (new date, new ad):
  SimHash of the text, 64 bits
  differ in 3 bits or fewer
    -> treat as a duplicate

mirrors and print views: near
  copies are a big share of pages
Spider traps
  • calendar: /2026/10/24/next...
  • session ids in every link
  • endless ?page=N or ?sort=
  • generated paths: /a/a/a/a
  • defences
  • max depth from seed (e.g. 15)
  • max URL length (e.g. 2,000)
  • page budget per host per day
  • skip repeated path segments

Deep dive: recrawl

Pages change at wildly different rates. A news front page changes hourly, an old blog post almost never. Track each URL's history: if it changed on recent visits, halve its revisit gap; if not, double it, within sensible bounds. When you do revisit, send a conditional GET with the stored ETag or last-modified date. An unchanged page answers 304 Not Modified, a tiny response that saves bandwidth for you and the site.

Freshness
track change rate per URL
  changed last 3 visits -> halve gap
  unchanged -> double gap
  bounds: 1 hour to 60 days

news front page: hourly
old blog post: monthly

conditional GET: ETag, Last-Modified
  304 = tiny response, no reparse

Deep dive: distribution

To scale out, partition everything by host. Hash the host name to pick an owner partition, using consistent hashing so adding machines moves few hosts. Because one worker owns each host's queue, politeness needs no global lock. Links to other hosts are forwarded to their owners. With async I/O one machine keeps hundreds of fetches open, so a handful of machines covers eight hundred pages a second. Keep a local DNS cache; public resolvers are slow and rate limited.

Scaling out
partition = hash(host) mod N
  (consistent hashing on rebalance)
  one owner per host -> politeness
  needs no global lock

new link for another host:
  forward to its owner partition

async I/O: 500+ open fetches/box
800 pages/s / 100 per box = 8 boxes
local DNS cache: resolvers are slow

Deep dive: storage

Storing a billion pages as a billion tiny objects is slow and expensive, since object stores charge per request and listing them is painful. Instead, append compressed pages to large segment files of around a gigabyte, with a small side index from URL hash to file and offset. When a segment closes, publish an event and the indexer reads it as one sequential stream. Older fetches move to a cheaper cold storage tier.

Storing a billion pages
1B tiny objects/month: slow to list,
  costly per request

instead: append pages to segment files
  about 1 GB each, gzip per record
  side index: url_hash -> file, offset

segment closed -> event to indexer
old fetches: lifecycle to cold tier

Trade-offs

Failure handling leans on leases and idempotent writes. If a worker crashes, its lease expires and the URL returns to the queue. Frontier queues are persisted as a log, so a restarted node replays them. Fetching a page twice after a retry is harmless, because storage writes are keyed by URL and time. Dead sites get retried with backoff and then marked. And give the parser strict time and size limits, because some HTML is truly hostile.

Monitor pages per second, the mix of status codes, frontier size per priority, the duplicate rate and bytes per second. A rising share of 429s is your early warning that you are being impolite. At ten times the volume the design holds: add partitions and fetchers, and storage becomes the main cost. Crawling JavaScript-heavy sites needs a separate tier of headless browsers, which is many times more expensive per page, so send only the URLs that need it.

Failure modes
  • worker crash: lease expires,
  • URL returns to the queue
  • frontier node down: queues
  • are logged, replay on restart
  • duplicate fetch after retry:
  • harmless, writes are by key
  • site down: retry with backoff,
  • mark dead after 3 tries
  • bad HTML: parser time limits
Monitor and grow
  • watch: pages/s, status code mix
  • (2xx, 3xx, 4xx, 5xx, 429)
  • frontier size per priority
  • per-host gap violations
  • duplicate rate, bytes/s
  • 10x: more partitions, same
  • design; storage grows first
  • JavaScript sites: a separate
  • headless-browser tier, costly

Interview summary

To summarize in the interview. Four to eight hundred pages a second and about three hundred terabytes a year. A loop of lease, fetch, store, parse, dedup and enqueue. A two-layer frontier with priority queues in front and per-host queues behind. Robots rules, crawl delay and backoff. URL normalization with a Bloom filter, SimHash for near duplicates, limits against traps, adaptive recrawl with conditional requests, and partitioning by host.

Say it like this
  • 400-800 pages/s, 300 TB/year
  • loop: lease, fetch, store,
  • parse, dedup, enqueue
  • frontier: priority front queues
  • + per-host back queues
  • robots.txt, crawl delay, backoff
  • normalize + Bloom filter
  • SimHash for near duplicates
  • trap limits, adaptive recrawl
  • partition by host hash

Interview questions

How does the URL frontier enforce politeness?

It keeps a separate queue per host and a heap of hosts ordered by the next time each may be contacted. A worker only takes a URL from a host whose time has come, then pushes that host back with a new time based on its crawl delay.

How do you avoid crawling the same URL twice?

Normalize each URL to a canonical form, then check a Bloom filter in memory; a negative answer means the URL is definitely new, while a positive answer is confirmed against the URL table. The filter has no false negatives, so its only cost is occasionally skipping a new page.

How do you detect duplicate content at different URLs?

Hash the page body to catch exact copies, and compute a locality-sensitive fingerprint such as SimHash for near duplicates, treating pages whose fingerprints differ by only a few bits as the same document.

What is a spider trap and how do you defend against it?

A spider trap is a site that generates an unbounded number of URLs, such as an infinite calendar or session ids in every link. You defend with a maximum crawl depth, a maximum URL length, a per-host page budget and detection of repeated path segments.

How do you decide when to recrawl a page?

Track how often each page changes and adapt its revisit interval, shortening it after observed changes and lengthening it otherwise, within fixed bounds. Use conditional GET requests with ETag or Last-Modified so unchanged pages return a cheap 304.

How do you distribute the crawler across many machines?

Partition URLs by a hash of their host, ideally with consistent hashing, so each host is owned by exactly one worker group. That keeps politeness local with no global coordination, and links for other hosts are forwarded to their owning partition.

What database would you use for the URL table and why?

A wide-column or key-value store such as Cassandra or Bigtable, because there are billions of rows accessed by key with high write throughput and no need for joins or multi-row transactions.

What happens when a fetcher crashes mid-crawl?

The URLs it leased are not acknowledged, so their leases expire and they return to the frontier for another worker. Writes are keyed by URL and fetch time, so any duplicate fetch is harmless.