Medium

Design a Web Crawler

Design a crawler that downloads billions of pages for a search index while being a polite citizen of the web. The core components are a URL frontier that balances priority against per-host politeness, a dedup layer (Bloom filters and content fingerprints), robots.txt compliance, trap avoidance, and a recrawl strategy that keeps the index fresh.

1Requirements

Functional

  • Start from seed URLs, download pages, extract links, and enqueue newly discovered URLs.
  • Respect robots.txt and per-host crawl-delay directives.
  • Deduplicate both URLs (do not fetch twice) and content (detect mirror/duplicate pages).
  • Store page content and metadata for downstream indexing.
  • Re-crawl pages periodically at a frequency based on their change rate (freshness).
  • Restrict scope configurably: HTML only, obey max depth per site, per-domain quotas.

Non-functional

  • Scale: crawl 1B pages within about a week of a full pass.
  • Politeness: never overload a host; typically at most one in-flight request per host with delays between requests.
  • Robustness: tolerate malformed HTML, dead servers, slow responses, redirect loops, and spider traps without stalling.
  • Extensibility: adding new content types or processing stages should not require redesign.
  • Efficient storage: avoid storing duplicate content; fingerprints and URL sets must fit in practical memory.

2Back-of-envelope estimation

Crawl rate1B pages / 7 days ≈ 1,650 pages/sec sustained, design for 2x ≈ 3,300/sec
Download bandwidth1,650 pages/sec x 500 KB avg ≈ 825 MB/s ≈ 6.6 Gbps
Storage per pass1B pages x 500 KB ≈ 500 TB raw HTML per full crawl
URL-seen structureBloom filter for 10B URLs at 1% FP rate ≈ 10 bits/URL ≈ 12 GB
DNS lookups3,300 fetches/sec, each needing resolution; public resolvers throttle at this rate

3API design

POST /api/crawl/seeds

Submit seed URLs or sitemaps with priority and scope rules to bootstrap or expand a crawl.

GET /api/crawl/status?domain={domain}

Report crawl progress: pages fetched, error rates, queue depth, per-domain quota consumption.

PUT /api/crawl/policies/{domain}

Override politeness or scope for a domain: custom delay, max depth, exclusion patterns, blocklisting.

GET /api/pages/{urlHash}

Internal API for downstream consumers (indexer) to fetch stored content and fetch metadata for a URL.

4High-level design

The heart of the system is the URL frontier, which is much more than a FIFO queue. It has two stages: front queues partition URLs by priority (computed from PageRank-like importance, update frequency, depth), and back queues partition strictly by host, with each back queue mapped to exactly one host. A selector pops from back queues only when the host's politeness timer (its next allowed fetch time, kept in a min-heap) has expired. This structure enforces both prioritization and per-host rate limits in one place.

Fetcher workers pull a ready URL from the frontier, resolve DNS through a local caching resolver, check the cached robots.txt for that host (fetching and caching it if absent), and download the page with strict timeouts and size caps. Downloaded content is written to object storage, and the fetch result (status, headers, checksum, timestamp) is recorded in the URL metadata store.

A parsing and extraction stage, decoupled from fetching by a queue so slow parsing never blocks the network, validates the content, computes a content fingerprint for near-duplicate detection, extracts and normalizes outgoing links (resolve relative URLs, strip fragments, canonicalize), and applies URL filters (scheme, blocklists, depth, scope).

Each surviving link passes the URL-seen test: a Bloom filter (backed by an authoritative disk-based store to confirm positives) drops URLs already visited or already queued. New URLs are scored for priority and inserted into the frontier, closing the loop. The frontier itself is mostly on disk with only the head of each queue in memory, since billions of pending URLs cannot fit in RAM.

The whole pipeline shards horizontally: partition the URL space by hash of hostname across crawler nodes, so all URLs for one host land on one node, which makes politeness enforcement local (no cross-node coordination per fetch). A coordinator handles node membership and re-partitioning on failure via consistent hashing.

5Data model

url_metadata

url_hash BINARY(16) PK, url TEXT, host VARCHAR(255), status VARCHAR(20), last_fetched_at TIMESTAMP, fetch_count INT, last_change_at TIMESTAMP, content_fingerprint BINARY(8), priority FLOAT, next_fetch_at TIMESTAMP

Sharded by url_hash; next_fetch_at drives the recrawl scheduler

host_state

host VARCHAR(255) PK, robots_txt TEXT, robots_fetched_at TIMESTAMP, crawl_delay_ms INT, next_allowed_fetch_at TIMESTAMP, error_streak INT, pages_crawled BIGINT

One row per host; the politeness source of truth

page_store (object storage)

key = url_hash/fetch_ts, value = compressed HTML + response headers

Append-only, versioned per fetch for change detection and reprocessing

6Deep dives

The URL frontier: priority vs politeness

A naive BFS queue fails in two ways: it fetches junk as eagerly as important pages, and because links on a page mostly point within the same site, it hammers one host with rapid-fire requests, which is how crawlers get IP-banned or mistaken for a DoS attack. The Mercator-style two-stage frontier is the classic answer.

Front queues handle priority: a prioritizer assigns each URL to one of K queues (say 1 = highest), and the mover biases toward high-priority queues when refilling the back stage. Back queues handle politeness: each queue holds URLs for exactly one host, and a heap keyed by next_allowed_fetch_at (last fetch time plus the host's delay, from crawl-delay or an adaptive default like a multiple of observed response time) decides which host is ready. A worker pops the ready host's queue head; the host cannot be fetched again until its timer resets.

Sizing detail worth mentioning: keep roughly 3x more back queues than worker threads so workers rarely idle waiting for a polite host, and spill queue tails to disk since billions of pending URLs exceed RAM.

Dedup: Bloom filters for URLs, fingerprints for content

URL-seen testing happens billions of times, so it must be a memory-speed operation. Storing 10B URLs as strings needs on the order of a terabyte; a Bloom filter with ~10 bits per element and a 1% false-positive rate needs about 12 GB. The tradeoff is that false positives cause the crawler to skip roughly 1% of genuinely new URLs, which is usually acceptable for coverage; if not, treat the Bloom filter as a fast negative check and confirm positives against the disk-based url_metadata store, so the filter merely saves the vast majority of disk lookups. Note that standard Bloom filters do not support deletion, so a fresh filter is built per crawl generation.

Content dedup is a different problem: the same page often lives at many URLs (mirrors, tracking parameters, http/https variants). Exact duplicates are caught with a checksum (MD5/SHA) of the body, stored in url_metadata and checked before wasting parse and storage work. Near-duplicates (same article with different ads or navigation) need locality-sensitive fingerprints: SimHash produces a 64-bit fingerprint where similar documents differ in few bits, and Google reported using it for exactly this at web scale; pages within ~3 bits of Hamming distance are treated as duplicates.

Dedup also protects the frontier itself: canonicalize URLs before the seen-test (lowercase host, strip default ports, sort or strip known tracking query parameters, resolve relative paths), or trivially different spellings of one URL will slip past.

Politeness, robots.txt, and trap avoidance

Robots.txt is fetched once per host, cached with a TTL (typically a day), and consulted before every fetch; disallowed paths are dropped at the filter stage. Crawl-delay, where present, overrides the default politeness interval. Beyond compliance, adaptive politeness is good engineering: back off exponentially on 5xx and 429 responses, and slow down when a host's response time degrades since the crawler may be the cause.

Spider traps are structures that generate unbounded URL spaces: calendar pages with infinite next-month links, session IDs in URLs, and deliberately hostile generators. Defenses are layered: cap URL length, cap path depth, cap pages per domain per crawl cycle, detect cycles of near-identical content fingerprints within a site, and alert on domains whose queue grows without their unique-content count growing. There is no perfect automatic defense; real crawlers pair heuristics with manual blocklists.

A final operational point: crawler traffic must be identifiable (a clear User-Agent with contact info), because unidentifiable high-volume crawlers get blanket-blocked by CDNs and WAFs, which quietly destroys coverage.

Freshness and recrawl scheduling

A one-shot crawl decays immediately: news pages change hourly, reference pages change yearly. Recrawling everything at one frequency either wastes most of the fetch budget on static pages or serves stale news. The standard model estimates each page's change rate from history: compare the content fingerprint at each fetch, treat changes as a Poisson process, and estimate lambda from observed change/no-change outcomes.

Schedule next_fetch_at per URL from that estimate, weighted by page importance, so a high-value fast-changing page might recrawl hourly while a static tail page waits months. Sitemaps with lastmod, HTTP conditional requests (If-Modified-Since/ETag, where a 304 costs almost nothing), and RSS feeds give cheap change signals that stretch the fetch budget further.

In steady state the crawler is not a pipeline with an end but a continuous scheduler: the frontier is perpetually refilled by both newly discovered URLs and recrawl-due URLs, and the interesting knob is how the fixed fetch budget is split between discovery (coverage) and recrawl (freshness).

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Python 3.12 + asyncio + aiohttp + Redis (frontier and dedup) + SQLite for page metadata, gzip HTML to local disk; runs on a laptop or $6 VPS

  1. 01Model the frontier in Redis: one list frontier:{host} per host, a set of known hosts, and a sorted set host_ready scored by next_allowed_fetch_ms.
  2. 02Write the URL canonicalizer: lowercase scheme and host, strip fragments and default ports, drop tracking params (utm_*, fbclid), resolve relative paths.
  3. 03Add dedup: a Bloom filter (pybloom-live, 50M capacity, 1% FP) checked before enqueue, with an INSERT OR IGNORE into a SQLite urls table as the authoritative record.
  4. 04Implement the politeness scheduler: pop the lowest-scored ready host from host_ready, take one URL from its list, and re-score the host to now + delay after the fetch completes.
  5. 05Fetch with aiohttp using a 10s timeout, 2 MB size cap, and a descriptive User-Agent; on 429/5xx double the host's delay, on success decay it back toward 1s.
  6. 06Cache robots.txt per host in Redis for 24h using urllib.robotparser and drop disallowed URLs at enqueue time.
  7. 07Parse with BeautifulSoup, extract and canonicalize hrefs, cap path depth at 8 and pages per domain at 5000 to dodge spider traps.
  8. 08Store gzipped HTML keyed by sha256(url) and record status, checksum, and fetch time in SQLite; seed with 10 URLs and watch it hold roughly 1 req/sec/host.

Frontier with per-host politeness delay

python
import time

DEFAULT_DELAY_MS = 1000

async def get_next_url(redis):
    # host_ready: sorted set of host -> next_allowed_fetch_ms
    now_ms = int(time.time() * 1000)
    ready = await redis.zrangebyscore("host_ready", 0, now_ms, start=0, num=1)
    if not ready:
        return None  # no host is polite to fetch yet; caller sleeps briefly
    host = ready[0]
    url = await redis.lpop("frontier:" + host)
    if url is None:
        await redis.zrem("host_ready", host)  # queue drained
        return None
    # block this host until its delay elapses; adjusted again on response
    delay = int(await redis.hget("host_delay", host) or DEFAULT_DELAY_MS)
    await redis.zadd("host_ready", {host: now_ms + delay})
    return url

async def report_result(redis, host, status, elapsed_ms):
    delay = int(await redis.hget("host_delay", host) or DEFAULT_DELAY_MS)
    if status in (429, 503):
        delay = min(delay * 2, 60_000)      # back off hard
    else:
        delay = max(1000, int(delay * 0.9), elapsed_ms * 3)  # adaptive politeness
    await redis.hset("host_delay", host, delay)

Canonicalize then dedup with a Bloom filter

python
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
from pybloom_live import ScalableBloomFilter

seen = ScalableBloomFilter(initial_capacity=50_000_000, error_rate=0.01)
TRACKING = {"fbclid", "gclid", "ref"}

def canonicalize(url: str) -> str | None:
    s = urlsplit(url)
    if s.scheme not in ("http", "https"):
        return None
    host = s.hostname.lower() if s.hostname else None
    if not host or s.path.count("/") > 8:
        return None  # depth cap against calendar-style traps
    query = urlencode(sorted(
        (k, v) for k, v in parse_qsl(s.query)
        if not k.startswith("utm_") and k not in TRACKING
    ))
    return urlunsplit((s.scheme, host, s.path or "/", query, ""))

def enqueue_if_new(db, redis_pipe, url: str):
    canon = canonicalize(url)
    if canon is None or canon in seen:
        return  # Bloom filter: ~10 bits/URL vs storing full strings
    seen.add(canon)
    # authoritative store confirms; INSERT OR IGNORE handles FP double-checks
    db.execute("INSERT OR IGNORE INTO urls (url, status) VALUES (?, 'queued')", (canon,))
    host = urlsplit(canon).hostname
    redis_pipe.rpush("frontier:" + host, canon)
    redis_pipe.zadd("host_ready", {host: 0}, nx=True)

Async fetch worker loop

python
import asyncio, gzip, hashlib, time
import aiohttp

UA = "MiniCrawler/0.1 (+mailto:you@example.com)"

async def worker(redis, db, session: aiohttp.ClientSession):
    while True:
        url = await get_next_url(redis)
        if url is None:
            await asyncio.sleep(0.05)
            continue
        host = aiohttp.helpers.URL(url).host
        if not await robots_allows(redis, session, host, url):
            continue
        start = time.monotonic()
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=10),
                                   headers={"User-Agent": UA}) as resp:
                body = await resp.content.read(2_000_000)  # 2 MB cap
                elapsed = int((time.monotonic() - start) * 1000)
                await report_result(redis, host, resp.status, elapsed)
                if resp.status == 200 and "text/html" in resp.headers.get("Content-Type", ""):
                    key = hashlib.sha256(url.encode()).hexdigest()
                    open("pages/" + key + ".html.gz", "wb").write(gzip.compress(body))
                    await parse_and_enqueue(redis, db, url, body)
        except (aiohttp.ClientError, asyncio.TimeoutError):
            await report_result(redis, host, 503, 10_000)

Bottlenecks & failure modes

  • Per-host politeness caps parallelism: if the frontier concentrates on few hosts, workers idle; keep breadth in the frontier and more back queues than threads.
  • DNS resolution at thousands of lookups/sec overwhelms external resolvers; run local caching resolvers and prefetch resolutions.
  • The URL-seen check is on every extracted link (tens of thousands/sec); a naive DB lookup per link dies, hence the Bloom filter front.
  • Frontier state (billions of URLs) exceeds memory; hybrid memory/disk queues with only heads in RAM.
  • Spider traps and crawler-hostile sites silently eat the fetch budget; per-domain quotas and anomaly monitoring are essential.

Key takeaways

  • The frontier is the crawler: a two-stage structure enforcing priority in front queues and per-host politeness in back queues.
  • Politeness is a hard requirement, not a nicety: impolite crawlers are indistinguishable from DoS attacks and get blocked.
  • Bloom filters make the billion-scale URL-seen test a ~12 GB in-memory problem, accepting a small false-positive rate.
  • Dedup twice: URL dedup before fetching (canonicalize first), content dedup after fetching (checksums plus SimHash for near-duplicates).
  • Shard by hostname so politeness state stays node-local, and treat recrawl as a continuous scheduling problem driven by estimated change rates.

Brush up on the underlying topics