Designing a Web Crawler and Search Engine

Difficulty: Advanced Prerequisites:Message Queues, Consistent Hashing, and Bloom Filters


TL;DR

Two coupled systems in one question: a crawler that discovers and downloads billions of pages, and a search service that indexes them and answers queries in milliseconds. They are joined only by storage — the crawler writes pages, the indexer reads them — which is what lets them scale independently.

The shape of the answer: a two-level URL frontier for politeness, a Bloom filter for dedup, an offline indexing pipeline, and a sharded inverted index fronted by a query aggregator.

The single most important framing: crawling is a write-heavy batch problem, serving is a read-heavy latency problem. Treat them as one system and you will design both badly.


Understanding the Problem

Two coupled systems: a crawler that discovers and downloads billions of web pages, and a search service that indexes that content and answers user queries in milliseconds. The hard parts: crawling politely without hammering any single site, avoiding re-crawling unchanged content, building a massive inverted index, and serving ranked results at sub-200ms latency.

Crawling is throughput-bound and can be arbitrarily slow per page as long as aggregate rate holds. Serving is tail-latency-bound, where a single slow shard ruins the query. Because they share nothing but the page store, the crawler can fall hours behind without the search path noticing.


Naive First Cut

flowchart LR
    SEED["Seed URLs"]:::client
    CRAWLER["Single Crawler"]:::service
    DB[("One DB<br/>pages + index")]:::data
    USER["Search User"]:::client

    SEED --> CRAWLER
    CRAWLER --> DB
    USER --> DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Why this breaks:

The rest of the doc splits this into two independently scalable halves.


Prior Art We’re Drawing From


Functional Requirements

Core (top 3)

  1. Crawl the web — discover, download, and store web pages at scale (1B+ pages)
  2. Build an inverted index — map every word to the pages that contain it
  3. Serve search queries — return the top 10 most relevant results for a query in <200ms

Below the Line


Non-Functional Requirements

Below the Line


Scale Estimation (Back-of-Envelope)

Crawl side:

Serve side:

The 750TB figure is the one to state early: it immediately rules out “store pages in Postgres” and justifies object storage plus a separate index.


Core Entities


API

POST /v1/crawl/seed
  Body: { urls: ["https://example.com", ...] }
  Response: { queued: 150 }

GET /v1/search?q=distributed+systems&page=1
  Response: { results: [{ url, title, snippet, score }], total: 15000, took: "45ms" }

GET /v1/crawl/status
  Response: { pagesIndexed: 1200000000, crawlRate: "9800 pages/sec" }

High-Level Design

FR1: Crawl the Web

A URL Frontier (priority queue) feeds URLs to a distributed fleet of Crawler workers. Each worker downloads the page, extracts links (new URLs fed back to the frontier), and stores content.

flowchart LR
    FRONTIER["URL Frontier<br/>priority queue"]:::async
    CRAWLER["Crawler Workers"]:::service
    STORE[("Page Store<br/>object storage")]:::data
    DEDUP["URL Dedup<br/>Bloom filter"]:::service

    FRONTIER -->|"1. Dequeue URL"| CRAWLER
    CRAWLER -->|"2. Store page"| STORE
    CRAWLER -->|"3. Extract links"| DEDUP
    DEDUP -->|"4. Enqueue new URLs"| FRONTIER

    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3a2a4c,stroke:#c084fc,color:#e2e8f0

FR2: Build the Inverted Index

An Indexer reads crawled pages, tokenizes content, and builds the inverted index. The index maps each word to a posting list (pages containing that word, with positions and frequency).

flowchart LR
    STORE[("Page Store")]:::data
    KAFKA["Kafka<br/>new pages"]:::async
    INDEXER["Indexer"]:::service
    INDEX[("Inverted Index<br/>sharded")]:::data

    STORE -->|"1. Change events"| KAFKA
    KAFKA -->|"2. Index new documents"| INDEXER
    INDEXER -->|"3. Return results"| INDEX

    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3a2a4c,stroke:#c084fc,color:#e2e8f0

FR3: Serve Search Queries

The Query Service receives a user query, looks up each term in the inverted index, intersects posting lists, scores results by relevance (TF-IDF + PageRank), and returns top-K.

flowchart LR
    USER["User"]:::client
    QUERY["Query Service"]:::service
    INDEX[("Inverted Index<br/>sharded")]:::data
    CACHE[("Redis<br/>hot queries")]:::data

    USER -->|"1. Submit search query"| QUERY
    QUERY -->|"2. Lookup cached results"| CACHE
    CACHE -.->|miss| INDEX

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Deep Dives

Deep Dive 1: URL deduplication — avoiding redundant crawls

Bad: Maintain a hash set of all visited URLs in memory. At 10B URLs × 100 bytes each = 1TB of RAM. Doesn’t fit on a single machine. Also, the same page can be reached via different URLs (trailing slashes, query params, anchors).

Good: Use a Bloom filter for fast membership testing (probabilistic: may say “seen” for an unseen URL, but never misses a seen one). False positive rate of 1% at 10B URLs needs ~12GB — fits in memory. Normalize URLs (lowercase, remove fragments, sort params) before checking.

Great: Combine the Bloom filter with content-based dedup. After downloading a page, compute a SimHash (locality-sensitive hash) of the content. Two pages with similar content (mirror sites, syndicated articles) get the same SimHash — dedup at the content level, not just URL level. This eliminates near-duplicate pages that have different URLs but identical content, reducing index bloat by 30-40%.


Deep Dive 2: Politeness and crawl rate management

Bad: All crawler workers hit the same popular domain simultaneously. The site’s servers overload, they block your IP, and you lose access to that domain’s content entirely.

Good: Enforce per-domain rate limiting: max 1 request/second per domain. The URL Frontier maintains a per-domain queue with a “not-before” timestamp. Workers pick the next URL whose domain is eligible to be crawled. Respect robots.txt (fetch and cache it per domain, honor Crawl-delay directives).

Great: Use a two-level frontier. Back queue: per-domain queues with rate limiting (politeness). Front queue: priority queue that selects which domain to crawl next based on importance (PageRank of domain, freshness requirements). This ensures high-value domains (news sites, Wikipedia) are crawled frequently while staying polite. Assign each worker a set of domains via consistent hashing — this ensures DNS caching efficiency and persistent connections per worker-domain pair.


Deep Dive 3: Search ranking — beyond simple TF-IDF

Bad: Rank by keyword frequency (TF-IDF only). SEO spammers stuff keywords into pages and dominate results. Irrelevant but keyword-rich pages rank above authoritative sources.

Good: Combine TF-IDF with PageRank — a page’s authority is proportional to the number and quality of pages linking to it. This boosts authoritative sources (Wikipedia, official docs) above spam. PageRank is computed offline as a batch job over the link graph.

Great: Multi-signal ranking: TF-IDF (text relevance) + PageRank (authority) + freshness (prefer recent content for time-sensitive queries) + click-through rate (learn from user behavior over time). The scoring formula is a weighted combination, tuned via ML. For query latency, pre-compute static scores (PageRank) and combine with query-time scores (TF-IDF) during serving. Cache results for popular queries (top 1% of queries account for 30% of traffic) with a 1-minute TTL.


Deep Dive 4: Sharding the index — by term or by document?

Bad: Keep one index. No single machine holds 100s of TB of posting lists, so this doesn’t exist as an option.

Good: Term-partitioned (each shard owns a subset of words). A query for “distributed systems” touches only the two shards owning those terms, so per-query fan-out is small. The problem: posting lists for common words are enormous and unevenly distributed, so the shard owning “the” is a permanent hotspot, and multi-term queries must ship huge posting lists across the network to be intersected.

Great: Document-partitioned (each shard owns a subset of pages and indexes all their words). Every query goes to every shard, each computes its own local top-K, and an aggregator merges the results. Fan-out is wide but each shard does a small, bounded amount of work with no cross-shard data movement, and load is naturally even. This is what real search engines do.

The cost is tail latency: with 1000 shards, the query is as slow as the slowest one. Mitigate with hedged requests — if a shard hasn’t replied by the 95th-percentile mark, send a duplicate request to a replica and take whichever answers first. Combined with per-shard tiering (put high-PageRank documents in a small “top tier” that is searched first, and only fall through to the long-tail tier if there aren’t enough good results), most queries never touch the full index at all.


Deep Dive 5: Re-crawl scheduling — spending a fixed budget wisely

Bad: Re-crawl everything on a fixed cycle, e.g. every page every 30 days. Wastes most of the crawl budget re-fetching pages that never change, while breaking news sits stale for weeks.

Good: Tier by observed change rate. Track a content hash per URL across crawls; pages that changed last time get a shorter interval, unchanged pages get a longer one, with exponential backoff up to a cap. Use HTTP conditional requests (If-Modified-Since / ETag) so unchanged pages cost a 304 response instead of a full download — often a 10x bandwidth saving on the re-crawl path.

Great: Treat it as budget allocation against a freshness objective. Estimate each page’s change rate (a Poisson model fits well) and weight it by importance (PageRank, observed query and click traffic). Spend the crawl budget where P(changed) × importance is highest, so a news homepage is crawled every few minutes while an archived page drops to yearly. Sitemaps and lastmod hints, plus push protocols like IndexNow, let cooperative sites tell you what changed instead of you polling to find out.


Design Self-Audit

Question Answer
Crawler traps? Infinite calendars and session-ID URLs generate unbounded links. Defenses: cap URL depth and length, cap pages per domain, strip known session params during normalization, and detect near-duplicate content via SimHash.
A worker dies mid-fetch? The frontier uses visibility-timeout semantics (like SQS): an unacknowledged URL becomes eligible again after a timeout. Worst case a page is fetched twice, which is harmless — the crawl is idempotent.
Bloom filter false positive? A never-crawled URL is wrongly marked “seen” and is silently skipped. At 1% that’s acceptable for web crawling — those pages are almost always reachable via other links later. It is a deliberate trade of completeness for 12GB instead of 1TB.
Bloom filter can’t delete? Correct — standard Bloom filters don’t support removal, and it fills over time. Use a rotating/scalable variant, or rebuild periodically from the authoritative URL store.
Indexing falls behind? Search keeps serving the older index; only freshness suffers. Because indexing is offline and decoupled via the page store, crawler and indexer backlogs never affect query availability.
Index rebuild without downtime? Build the new index generation alongside the live one and atomically flip an alias once it passes validation. Never mutate the serving index in place.
robots.txt fetch fails? Fail closed — don’t crawl the domain until robots.txt is retrievable, and cache it with a TTL. Guessing “allowed” risks legal and reputational problems.

Final Architecture

flowchart LR
    SEED["Seed URLs"]:::client
    FRONTIER["URL Frontier<br/>front priority queues<br/>back per-domain queues"]:::async
    FETCH["Fetcher Workers"]:::service
    ROBOTS[("robots.txt<br/>cache")]:::data
    PARSE["Parser<br/>extract text and links"]:::service
    DEDUP["Dedup<br/>Bloom filter plus SimHash"]:::service
    STORE[("Page Store<br/>object storage 750TB")]:::data
    KAFKA["Kafka<br/>new and changed pages"]:::async
    INDEXER["Indexer"]:::service
    PAGERANK["PageRank<br/>batch job"]:::async
    INDEX[("Inverted Index<br/>document sharded")]:::data
    USER["Search User"]:::client
    AGG["Query Aggregator"]:::service
    CACHE[("Redis<br/>hot queries")]:::data

    SEED --> FRONTIER
    FRONTIER -->|"1. Dequeue eligible URL"| FETCH
    FETCH -->|"2. Check crawl rules"| ROBOTS
    FETCH -->|"3. Store raw page"| STORE
    FETCH -->|"4. Hand off content"| PARSE
    PARSE -->|"5. Check if seen"| DEDUP
    DEDUP -->|"6. Enqueue new URLs"| FRONTIER
    STORE -->|"7. Change events"| KAFKA
    KAFKA -->|"8. Tokenize and index"| INDEXER
    PAGERANK -->|"9. Static authority scores"| INDEXER
    INDEXER -->|"10. Write posting lists"| INDEX
    USER -->|"11. Submit query"| AGG
    AGG -->|"12. Check hot cache"| CACHE
    CACHE -.->|"miss: scatter gather"| INDEX

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
Color Meaning
🟣 Indigo Clients (Seed URLs, Search User)
🟢 Green Services
🟡 Yellow Data stores
🟪 Violet Queues / async batch jobs

How it works end-to-end (crawl path):

  1. Frontier selects the next URL — front queues decide which domain deserves attention by priority; back queues enforce when that domain may next be hit
  2. Fetcher checks crawl rules — robots.txt is fetched once per domain and cached, honoring Crawl-delay
  3. Raw page lands in object storage — 750TB of HTML belongs in a blob store, not a database
  4. Parser extracts text and outgoing links — the links are the crawler’s fuel; this is how discovery continues
  5. Dedup filters what’s already known — Bloom filter for URL-level dedup, SimHash for near-duplicate content
  6. New URLs return to the frontier, closing the discovery loop

How it works end-to-end (index and serve path):

  1. Page store changes publish to Kafka — this queue is the seam that decouples crawling from indexing, so either side can lag without breaking the other
  2. Indexer tokenizes and builds posting lists — entirely offline, never in a user’s request
  3. PageRank supplies static authority scores — computed as a batch job over the link graph and folded in at index time, so it costs nothing at query time
  4. Posting lists written to a document-sharded index — each shard owns a slice of the corpus
  5. Query aggregator receives the user query
  6. Hot query cache answers ~30% of traffic outright; on a miss the aggregator scatter-gathers across shards, merges each shard’s local top-K, and returns the global top 10

Key Technologies

Term What it is
URL Frontier The crawler’s scheduler. Two-level: front queues for priority, back queues for per-domain rate limiting. The heart of the crawl design.
Bloom Filter Probabilistic set membership in constant space. Answers “seen this URL?” for 10B URLs in 12GB instead of 1TB. Can false-positive, never false-negative.
SimHash A locality-sensitive hash where similar content yields similar hashes. Catches mirrored and syndicated pages that URL dedup misses.
Inverted Index Maps each term to a posting list of documents containing it. The core search data structure.
Document Sharding Each shard indexes a subset of pages. Wide fan-out per query but even load and no cross-shard traffic.
TF-IDF / BM25 Text relevance scoring: term frequency weighted down by how common the term is corpus-wide.
PageRank Link-graph authority — a page is important if important pages link to it. Computed offline as a static per-page score.
Hedged Requests Duplicate a slow request to a replica and take the first response. Cuts P99 when a query fans out to many shards.

What’s Expected at Each Level

Level Expectations
Mid URL Frontier + crawler fleet. Bloom filter for dedup. Inverted index concept. Basic TF-IDF ranking. Robots.txt politeness.
Senior Per-domain rate limiting with two-level frontier. Content-based dedup (SimHash). PageRank for authority. Index sharding by term or document.
Staff+ Consistent hashing for worker-domain affinity. Multi-signal ML ranking. Incremental index updates (not full rebuild). Freshness-based re-crawl prioritization. Cache strategy for query serving.

🎯 Key Takeaways



Understand the building blocks used in this design:

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.