#Design a Web Crawler (Search Engine Scale)
#1. Problem Statement & Clarifications
A Web Crawler is an automated system that systematically browses the World Wide Web to discover, download, and index web pages. In this design, we focus on a crawler built for a search engine like Google.
#Functional Requirements
- Discovery & Crawling: The system must crawl seed URLs, parse HTML pages, extract outbound links, and recursively add new URLs to the crawl frontier.
- Content Extraction: Extract page metadata (title, meta tags) and raw text body for downstream search indexing.
- Link Extraction: Extract all links (
<a href="...">) to build a link graph for page ranking algorithms (e.g., PageRank). - Deduplication: Avoid crawling duplicate URLs and parsing duplicate or near-duplicate web page content.
- Politeness (Robots.txt): Strict adherence to robots.txt files, crawl delays, and rate limiting per domain to avoid causing Denial of Service (DoS) on host servers.
#Non-Functional Requirements
| Requirement | Target |
|---|---|
| Scale | 1 Billion web pages crawled and updated per month. |
| Throughput (QPS) | ~386 pages/sec average crawl rate, with peak handling up to ~1,000 pages/sec. |
| Storage | Handle petabyte-scale data for raw web pages and link graphs. |
| Extensibility | Support different file types (.html, .pdf) and dynamic content rendering. |
| Robustness | Handle dead loops (crawler traps), DNS timeouts, and malformed HTML gracefully. |
#Out of Scope
- Search index storage and query ranking (e.g., designing Elasticsearch or a document indexer itself).
- Heavy media downloads (e.g., downloading large videos, audio files, or executable binaries).
- Real-time deep web crawling (crawling behind authenticated login walls).
#Assumptions
- The average size of a web page (HTML + metadata) is 100 KB.
- We discover ~50 Billion unique URLs over a 5-year lifecycle.
- Politeness delay is on average 1 second between requests to the same domain.
- Crawl priority is updated dynamically based on page updates (e.g., news sites crawled hourly, static blogs crawled monthly).
#2. Back-of-Envelope Estimation
To design the system, we calculate the requirements for bandwidth, storage, memory, and compute.
#Traffic Estimates
Monthly crawled pages: 1B
Crawl rate (avg): 1,000,000,000 / (30 days * 86,400s) ≈ 386 pages/sec
Crawl rate (peak 2x): ~800 - 1000 pages/sec
Discovered URLs/sec: 386 pages/sec * 50 links/page (avg) ≈ 19,300 URLs/sec (discovery QPS)#Storage Estimates
Avg Page Size: 100 KB
Raw Storage/month: 1B * 100 KB = 100 TB
Raw Storage/5 years: 100 TB * 12 months * 5 years = 6 PB (Object Store / Cold Storage)
Metadata Database (Cassandra/HBase):
URL record size: URL string (~100B) + hash (32B) + status (1B) + last_crawl_time (8B) ≈ 150 bytes
Discovered URLs (5yr): 50 Billion URLs
Metadata storage: 50B * 150 bytes = 7.5 TB (distributed DB storage)#Bandwidth Estimates
Download bandwidth: 386 pages/sec * 100 KB = 38.6 MB/s (or ~310 Mbps)
Peak bandwidth (2x): 80 MB/s (or ~640 Mbps)#Cache & Memory Estimates
DNS Cache: 10M domains. Hostname (30B) + IP (4B) = 34B per domain.
10M * 34B ≈ 340 MB memory.
Robots.txt Cache: 1M active domains. Avg size 1 KB.
1M * 1 KB = 1 GB memory.
Bloom Filter (URL): Target capacity n = 50B URLs, false positive rate p = 1% (0.01).
Bit array size m = - (n * ln p) / (ln 2)^2 ≈ 478 Billion bits ≈ 60 GB RAM.#3. API Design
The web crawler components communicate via RPC/Internal HTTP endpoints to minimize overhead.
#Worker to DNS Resolver / Cache
resolve_domain(host_name) -> IP Address#Worker to Robots.txt Parser / Cache
is_allowed(url, user_agent) -> Boolean#URL Frontier Interface
get_next_urls(worker_id, batch_size) -> List[URL]
ack_url(url, status_code, crawl_duration) -> Void#Content Parser to Deduplicator
check_and_register_content(document_hash) -> Boolean (True if unique, False if duplicate)#4. Data Model
A web crawler requires highly scalable storage that handles write-heavy workflows.
#1. URL Frontier & Metadata Store (Cassandra — sharded by Domain Hash)
We use a wide-column store like Apache Cassandra or HBase because they scale horizontally for high-throughput writes and allow efficient lookups by domain.
Table: crawled_urls
Partition Key: domain_hash (UUID)
Sort Key: url_hash (UUID)
{
domain_hash: "8f7c9e...",
url_hash: "1a2b3c...",
url_string: "https://example.com/page1",
status: "CRAWLED | FAILED | PENDING",
http_code: 200,
last_crawled_at: 1716982400,
content_hash: "a1s2d3f4...",
depth: 2,
priority: 50
}#2. Raw Web Page Storage (Object Storage — S3 / Google Cloud Storage)
To optimize costs at PB scale, raw web pages are compressed (e.g., GZIP/Zstandard) and stored as large blob files inside object storage.
Bucket Path: s3://crawler-page-archive/{year}/{month}/{day}/{domain_hash}/{url_hash}.html.gz
Metadata:
- original-url: "https://example.com/page1"
- crawl-timestamp: "1716982400"#3. URL Deduplication Registry (Distributed Redis Cluster)
To prevent crawling duplicate URLs, we use a distributed Bloom Filter hosted in a Redis cluster to check if a URL is already discovered.
- Key:
crawler:bloom:discovered_urls(60 GB RAM required).
#5. High-Level Design (HLD) & Scale Evolution
#High-Level Architecture Diagram
The following diagram details the decoupled, queue-driven architecture of the distributed web crawler:
graph TD
subgraph Core Frontier
FrontQueues[Front Priority Queues] -->|Queue Selector| DelayRouter[Delay Router]
DelayRouter -->|Politeness Mapping| BackQueues[Back Domain Queues]
end
subgraph Worker Pool
Worker[Crawl Worker] -->|Get URLs| BackQueues
Worker -->|DNS Resolve| DNSCache[(Local DNS Cache)]
Worker -->|Robots Check| RobotsCache[(Robots.txt Cache)]
Worker -->|Fetch HTML| Internet((World Wide Web))
end
subgraph Processing Pipeline
Worker -->|Raw HTML| Parser[Content Parser]
Parser -->|Text Content| ContentDedup{Content Deduplicator}
ContentDedup -->|Unique Page| ObjectStore[(Raw Page Archive - S3)]
Parser -->|Extracted URLs| URLFilter[URL Filter / Normalizer]
URLFilter -->|Filtered URLs| URLDedup{URL Deduplicator}
URLDedup -->|New URLs| FrontQueues
end
subgraph State Registries
URLDedup <-->|Bloom Filter Lookup| RedisBloom[(Redis Bloom Filter Cluster)]
ContentDedup <-->|SimHash Registry| Cassandra[(Cassandra Metadata DB)]
end#Scale Evolution
#Stage 1: Single-Instance Crawler (1K DAU / 100K Pages/month)
- Architecture: A single-threaded script running on a single VM. Uses local memory queue (FIFO) for the frontier. Stores HTML in a local directory.
- Deduplication: In-memory
Setcontaining seen URLs. - Bottleneck: Network I/O and DNS resolution blocking. Memory overflow as crawled URL set grows.
#Stage 2: Multi-threaded / Single Node (10M Pages/month)
- Architecture: A multi-threaded process. Introduce asynchronous I/O (e.g., libuv / Node.js or Go goroutines) to handle thousands of concurrent requests.
- DNS caching: Move DNS resolution to a local daemon (like Dnsmasq) to prevent overloading DNS servers.
- Deduplication: Use a local SQLite or RocksDB instance to handle deduplication persistent checks.
#Stage 3: Distributed FAANG-Scale (1B Pages/month)
- Architecture: Decoupled, multi-worker system coordinated by message queues like Kafka.
- Deduplication: Distributed Redis Bloom Filter (60 GB) checks URLs globally in sub-millisecond times.
- Politeness: Isolated domain queues with delay logic prevent overloading external websites.
#6. Deep Dive — Core Components
#1. URL Frontier & Politeness Policy
To satisfy both priority and politeness constraints, the URL Frontier is designed with two distinct queue layers:
┌───────────────────────────────┐
│ Incoming URLs │
└───────────────┬───────────────┘
▼
┌───────────────────────────┐
│ Prioritizer │
└─────────────┬─────────────┘
│ (Assigns priority 1..N)
▼
┌───────────────────────────┐
│ Front Queues (Priority) │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ Q1 │ │ Q2 │ │ QN │ │
│ └─────┘ └─────┘ └─────┘ │
└─────────────┬─────────────┘
│ (Weighted Round-Robin)
▼
┌───────────────────────────┐
│ Queue Selector │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Back Queues (Politeness) │
│ Domain A Domain B │
│ ┌───┐ ┌───┐ │
│ │ │ │ │ │
│ └───┘ └───┘ │
└─────────────┬─────────────┘
│ (Checks delay timers)
▼
┌───────────────────────────┐
│ Crawl Workers │
└───────────────────────────┘#Politeness Implementation:
- Domain-to-Queue Mapping: All URLs from a single domain (e.g.,
wikipedia.org) are routed to the same Back Queue. - Active Queue Table: Keeps track of domain queues that are currently being processed or are on hold.
- Schema:
(queue_id, last_crawled_time, delay_required)
- Schema:
- Queue Selector Logic:
- The selector pulls a queue ID from the queue.
- It checks the
last_crawled_time. If the time elapsed is less than thedelay_required(e.g., 1 second since the last hit), the queue ID is put on a temporary sleep queue or re-queued with a delay. - This ensures that a domain is never hit by more than one worker thread at a time and maintains the politeness interval.
#2. URL Deduplication (Bloom Filters)
Every page contains dozens of outbound links. To prevent re-crawling pages:
- Before inserting any URL into the URL Frontier, it is passed through the URL Deduplicator.
- We use a Redis Bloom Filter because it provides $O(1)$ lookups and uses 99% less memory than storing strings.
- Handling False Positives: If the Bloom Filter reports that the URL has been visited but it actually hasn't (1% chance), it will not be crawled. For a search engine, missing 1% of arbitrary web pages is acceptable and doesn't break the system.
#3. Content Deduplication (SimHash / MinHash)
Websites often replicate content under different URLs (e.g., printers-friendly layouts, mirror sites). Crawling duplicate content wastes bandwidth and storage.
- We generate a SimHash (locality-sensitive hash) of the parsed text body.
- Unlike cryptographic hashes (like SHA-256) where a single character change results in a completely different hash, SimHash produces similar hashes for similar documents.
- If the Hamming distance between the SimHash of the new page and any existing page in our registry is $\le 3$ (on a 64-bit scale), the page is marked as a duplicate and discarded.
#7. Low-Level Design (LLD)
#Page Fetching Flow Chart
Below is the sequence of decisions made by a Crawl Worker when processing a single URL.
[URL fetched from Back Queue]
│
▼
[Check Robots.txt Cache]
/ \
(Miss) (Hit)
/ \
[Fetch robots.txt] [Is URL allowed?]
│ / \
[Update Cache] (Yes) (No)
│ / \
└─────────────►/ [Discard & Ack]
│
▼
[DNS Resolution Cache]
/ \
(Miss) (Hit)
/ \
[Query Public DNS] [Fetch HTML Page]
│ │
[Update Cache] ▼
│ [Parse HTML & Links]
│ │
└───────────────────────┴────────► [Write Output]#Robots.txt Parser Pseudocode
import urllib.robotparser
import time
class RobotsTxtCache:
def __init__(self, ttl_seconds=86400):
self.cache = {} # domain -> (robot_parser, cached_at)
self.ttl = ttl_seconds
def is_allowed(self, url, user_agent="Googlebot"):
domain = self.extract_domain(url)
now = time.time()
# Check cache
if domain in self.cache:
parser, timestamp = self.cache[domain]
if now - timestamp < self.ttl:
return parser.can_fetch(user_agent, url)
# Cache miss: Fetch and Parse robots.txt
parser = urllib.robotparser.RobotFileParser()
parser.set_url(f"https://{domain}/robots.txt")
try:
parser.read()
except Exception:
# Default to permissive if robots.txt cannot be fetched (e.g., 404)
parser.allow_all = True
self.cache[domain] = (parser, now)
return parser.can_fetch(user_agent, url)
def extract_domain(self, url):
from urllib.parse import urlparse
return urlparse(url).netloc#8. Failure Handling & Edge Cases
- Crawler Traps (Infinite Loops): Dynamic pages that generate infinite sub-links (e.g.,
site.com/date?val=1->site.com/date?val=2).- Mitigation: Limit the maximum crawl depth (e.g., max depth of 5 from the seed URL). Use URL regex parsing to detect repeat query parameters. Limit the maximum number of pages crawled per domain.
- DNS Bottlenecks & Outages: Resolving DNS for 386 pages/sec can overload DNS servers or block worker threads.
- Mitigation: Workers cache resolved IPs locally. Run dedicated, high-availability local DNS recursive resolvers (e.g., Unbound or Bind9) within the crawling infrastructure to prevent external queries.
- Server Dynamic Throttling (HTTP 429 / 503):
- Mitigation: If a server returns
429 Too Many Requestsor503 Service Unavailable, parse theRetry-Afterheader. Put the domain back queue into "sleep" status for the requested period. Use exponential backoff for subsequent retries before marking the URL as failed.
- Mitigation: If a server returns
#9. Monitoring & Observability
Observability is crucial for detecting performance issues and misbehaving crawlers.
#Key Metrics
- Crawl Rate (QPS): Number of successfully crawled pages per second.
- Domain Error Rates: Count of 4xx/5xx HTTP responses grouped by domain (alerts if a worker is accidentally blocking a host).
- Frontier Queue Backlog: Size of priority and back queues to identify bottleneck domains or starved queues.
- Bloom Filter Saturation: The percentage of bits set to 1 in the URL Bloom Filter (if saturation exceeds 60%, the false positive rate increases dramatically).
#Alerting Strategy
- P0 Alert: If global crawl rate drops by >50% within 5 minutes (indicates DNS failure or gateway block).
- P1 Alert: If worker thread CPU utilization exceeds 95% or memory limits are breached.
- P2 Alert: Host block rate (403/429 responses) from a single domain exceeds 10% of attempts.
#10. Trade-off Summary
| Decision | Chose | Over | Because |
|---|---|---|---|
| Crawl Strategy | Breadth-First Search (BFS) | Depth-First Search (DFS) | BFS avoids getting stuck in a single deep website and discovers broader high-quality links. |
| Deduplication Store | Redis Bloom Filter | HBase/Database lookup | Redis is in-memory and provides sub-millisecond lookups, avoiding disk bottlenecks. |
| SimHash vs. Cryptographic Hash | SimHash | SHA-256 | SimHash allows near-duplicate content detection (e.g., templates with different ads), which SHA-256 cannot do. |
| Frontier Queue Storage | Distributed Queues (Kafka) | Relational Database (SQL) | SQL databases suffer from locks and slow indexing when polling queues at 20,000+ QPS. |
#11. Extensions & Follow-ups
#Dynamic Content Rendering (Single Page Applications)
Modern websites render content using JavaScript frameworks (React, Vue) after the initial HTML is loaded.
- Solution: The crawler routes pages containing JS tags to a specialized Rendering Worker Pool.
- These workers use headless Chromium browser instances (via Puppeteer or Playwright) to execute JavaScript, wait for DOM changes, and then parse the fully rendered HTML.
- Trade-off: Dynamic rendering increases latency by 10x-100x and uses significant memory/CPU, so it is reserved for high-priority pages only.
#Crawl Prioritization (Freshness vs. Discovery)
How do we decide when to re-crawl a page?
- Solution: Track historical updates of pages in the metadata database.
- Calculate the probability of page changes using a Poisson distribution.
- Assign shorter crawl intervals to frequently updated high-quality pages (e.g., news homepages: every 15 mins) and longer intervals to static pages (e.g., personal blogs: monthly).
#12. Cross-References
#Related Topics in This Repo
- E-Commerce Listing (#11) — Relies on Elasticsearch index building pipelines similar to parsing web documents.
- Distributed Cache (#15) — Explores Redis cluster design which holds our Bloom Filter.
#Building Blocks Used
- Apache Kafka — Coordinates high-throughput message streaming in the pipeline.
- Redis — Holds the Bloom Filter for instant URL deduplication.
- Load Balancer — Distributes crawling requests across worker pools.
- Bloom Filters — Mathematical foundation for memory savings.