Index/Web Crawler

SponsorGitHub
Design14 min

Web Crawler

Ten billion pages, ten thousand fetches a second, without hammering any single domain.

Primary concepts and the hard part

Concepts
frontier queue designBloom filterspoliteness and per-domain rate limitingconsistent hashingDNS cachingcontent deduplicationtrap detectionprioritized schedulingbackpressure
The hard part they’re probing
Politeness. Crawling fast is easy; crawling fast without hammering any single domain is the real constraint, and it forces a queue design where URLs are grouped by host rather than processed FIFO. The second probe is dedupe at a scale where you cannot store the set of seen URLs in memory naively.

Requirements

Functionalwhat it must do
Start from seed URLs, discover and fetch pages, extract links, repeat
Respect robots.txt and crawl-delay
Avoid re-crawling identical content
Recrawl pages on a schedule proportional to how often they change
Store fetched content for downstream indexing
Non-functionalhow well it must do it
Politeness: never more than ~1 concurrent request per host, with delay
Throughput: billions of pages, thousands of fetches/sec
Robust: must survive malformed HTML, infinite redirects, crawler traps
Extensible: new content types and extractors
Out of scopedeliberately left out
The search index itself (that's design 06)
JavaScript rendering (mention the cost)
Ranking
Scale
Pages to crawl:     10B
Target rate:        10,000 pages/sec  → 10B / 10k = ~11 days for a full pass
Avg page:           ~100 KB HTML → 1 GB/sec ingest → ~85 TB/day raw
URLs seen (dedupe): 100B+ URLs → storing them as strings ≈ 10+ TB
                    → Bloom filter at 10 bits/URL ≈ 125 GB, fits in RAM across a cluster
Conclusion
The URL-seen set is too large to store literally in memory. That fact alone justifies a Bloom filter, and this is the canonical place to introduce one.

API / Model

Internal system, so the "API" is the queue contracts:

Queue contracts
frontier.push(url, priority, discovered_at)
frontier.pop(worker_id)
politeness-aware
url
content.put(url, html, fetched_at, checksum)
primary keyforeign key → referenced columnHover a table or column to trace its keys

High-level architecture

CacheObject storageFocusClick a node for details

The crawler is a loop around the URL Frontier: URLs leave it, pages are fetched and parsed, and new links go back in. Politeness lives inside the frontier, and duplicates are caught twice, once by content and once by URL.

  1. Seed URLs enter the front queues, which order work by priority (Q1 high for news and homepages, Q2 medium, Q3 low) through a weighted selector.
  2. URLs then move to the back queues, one per host. A heap keyed by next_allowed_fetch_time lets a worker pop a URL only when its host is ready.
  3. Consistent hashing by domain sends all of a host's URLs to the same node in the fetcher fleet, which reads the robots.txt cache for rules and crawl-delay, and the DNS cache for addresses.
  4. The fetcher makes the HTTP request with a timeout, a size cap and a redirect limit, and sends ETag / If-Modified-Since so an unchanged page costs only a 304.
  5. Content dedupe checks a checksum for exact duplicates and a simhash for near duplicates, and the page is written to the page store as compressed HTML.
  6. The parser / link extractor pulls out links and normalizes each URL: lowercase host, strip fragment, sort query params, resolve relative paths.
  7. The Bloom filter checks every normalized URL. A URL that is definitely not seen goes back into the front queues, and one that is probably seen is discarded.

Two jobs feed the frontier from the side. Trap detection enforces a maximum depth and a per-domain cap and catches calendar and session-id patterns, so runaway URL spaces don't flood the queues. The recrawl scheduler estimates how often each page changes and puts it back into the front queues at its next_crawl_at.


Trade-offs and deep dives

01
The frontier is the whole design

A naive FIFO queue will happily hand ten workers ten URLs from the same domain simultaneously, which is a denial-of-service attack against that site and gets you blocked. The two-level structure fixes this: front queues encode priority, back queues encode politeness (one per host), and a heap ordered by next_allowed_fetch_time means a worker can only ever pop a URL whose host is ready. Politeness becomes a structural property rather than a check you might forget.

02
Consistent hashing by domain

Route all URLs for a host to the same worker node. Now politeness state (last fetch time, crawl delay, robots rules) is local — no distributed coordination per fetch. Consistent hashing means adding or removing a node moves only ~1/N of domains rather than reshuffling everything.

03
Bloom filter: why it's exactly right here

You need "have I seen this URL?" over 100B URLs. Storing them costs 10+ TB. A Bloom filter at ~10 bits per element costs ~125 GB and answers in O(1) from RAM. Its guarantee is asymmetric: no false negatives, ~1% false positives. Translated to this problem: you will never re-crawl a page you've already crawled (which would be a correctness/politeness problem), but you will occasionally skip a page you haven't (which costs you ~1% coverage). That's the right direction for the error to point, and saying so demonstrates you understand why the structure fits rather than just naming it.

The limitation to mention: you can't delete from a Bloom filter, so a URL can never be "un-seen". Handle recrawls through the scheduler, not by removing from the filter.

04
URL normalization prevents an explosion

Example.com/Page?b=2&a=1#section and example.com/page?a=1&b=2 are the same resource. Without normalization (lowercase host, drop fragment, sort query params, resolve relative paths, strip known tracking params), you'll enqueue millions of duplicates and your Bloom filter fills with noise.

05
Content dedupe is separate from URL dedupe

Different URLs frequently serve identical content (mirrors, print views, session IDs in the path). Exact checksums catch identical bytes; simhash catches near-duplicates where only boilerplate differs. Detecting near-duplicates is what keeps the downstream index from being 40% junk.

06
Crawler traps

Infinite calendars (/calendar?date=2099-12-31 linking to the next day forever), session IDs generating unlimited unique URLs, and deliberately deep link structures. Defenses: cap URL depth and length, cap pages per domain, detect repeating path patterns, and monitor the ratio of new-URLs-discovered to useful-content-found per domain. A domain generating a million URLs and no new content is a trap.

07
DNS is a hidden bottleneck

Resolution is a synchronous network call that can take 50-200ms, and at 10,000 fetches/sec a naive crawler spends most of its time in DNS. Cache aggressively (respecting TTLs), run your own resolvers, and resolve asynchronously ahead of fetch time.

08
Conditional requests are free pages

Send If-Modified-Since/If-None-Match on recrawls. A 304 Not Modified costs almost no bandwidth and confirms freshness. For a recrawl-heavy workload this is a large efficiency win.

09
Recrawl scheduling

Not all pages change at the same rate. Estimate change frequency from observed history (a news homepage changes hourly; a 2009 forum post never will) and set next_crawl_at accordingly. Budget crawl capacity between discovery (new pages) and freshness (recrawls) — they compete for the same fetchers, and how you split is a product decision.

10
Politeness beyond rate

Respect robots.txt and its Crawl-delay. Send a descriptive User-Agent with a contact URL. Back off automatically on 429/503 responses and rising error rates from a host. Being a well-behaved crawler is not just etiquette — sites block badly-behaved ones, which costs you coverage.

11
JavaScript rendering

Many modern pages are empty without executing JS. Rendering requires a headless browser, which is roughly 10-100x the CPU and memory of a plain fetch. The practical answer is a two-tier approach: plain fetch for everything, and route only pages detected as JS-dependent (empty body, known SPA frameworks) to a smaller rendering fleet. Flag the cost explicitly rather than hand-waving it.

12
Backpressure

If parsers fall behind fetchers, the frontier grows unboundedly. Monitor queue depth and throttle fetchers when it exceeds a threshold. The frontier is also persistent — it must survive restarts, so it's backed by durable storage, not just memory.


Possible follow-up questions

6 questions·try answering before you reveal