1. Introduction — What Is a Web Crawler?
A web crawler (also called a spider, bot, or web robot) is an automated program that systematically browses the World Wide Web, downloading pages and extracting links for further crawling. Google's web crawler processes over 100 billion pages per day, consuming 1 petabyte of data daily. Bing crawls approximately 10 billion pages. The Common Crawl dataset, which is publicly available, contains 3.1 billion web pages and uses 250 TB of storage.
Web crawlers are the foundation of search engines, but they serve many other purposes: monitoring website changes, competitive intelligence, price comparison, academic research, content aggregation, and training machine learning models. Building a web crawler at scale requires solving several distributed systems challenges: managing billions of URLs in a priority queue, politeness constraints (not overwhelming servers), duplicate detection, distributed coordination, and fault tolerance across thousands of machines.
Interview Context: The web crawler design question tests your understanding of distributed crawling, URL management, deduplication at scale, and politeness constraints. It is a classic system design question frequently asked at Google, Microsoft, Amazon, and other companies with search or data acquisition teams.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
| F1 | Seed URL crawling | Must | Start from seed URLs and follow links recursively |
| F2 | Politeness | Must | Respect robots.txt, rate limit per domain |
| F3 | URL deduplication | Must | Don't crawl the same URL twice |
| F4 | Content deduplication | Should | Detect mirror/duplicate content pages |
| F5 | Re-crawling | Must | Periodically re-crawl pages for freshness |
| F6 | URL priority | Should | High-priority pages crawled more frequently |
| F7 | Content storage | Must | Store crawled HTML, metadata, extracted text |
| F8 | Domain-level management | Should | Track per-domain crawl state and stats |
| F9 | Link extraction | Must | Extract and normalize all links from pages |
| F10 | Partial crawling | Nice | Crawl only sections of pages (CSS selectors) |
Non-Functional Requirements
| Requirement | Target | Rationale |
| Crawl rate | 1 billion pages/day | Google-scale crawl for comprehensive index |
| Crawl latency | < 1 hour from discovery to crawl | Content freshness for time-sensitive pages |
| Availability | 99.9% | Crawler can tolerate brief downtime |
| Storage | Petabyte-scale | Storing billions of HTML pages |
| Politeness | < 1 request/second per domain | Avoid overloading target servers |
| Dedup accuracy | > 99% dedup rate | Minimize wasted crawl resources |
3. Capacity Estimation & Back-of-Envelope
Daily Crawl Volume
| Metric | Calculation | Result |
| Pages to crawl per day | Given | 1 billion pages |
| Average page size | 500 KB (HTML + embedded resources) | 500 KB |
| Daily download volume | 1B × 500 KB | 500 TB/day |
| Average QPS | 1B / 86,400 | ~11,600 pages/second |
| Peak QPS (3x) | 11,600 × 3 | ~35,000 pages/second |
| Unique domains | Estimated active domains | ~200 million |
| Pages per domain per day | 1B / 200M | ~5 pages/domain/day |
| Links per page (average) | 50 | 50 billion new URLs discovered/day |
Storage Estimates
| Data | Size per entry | Count/day | Daily | Annual |
| Raw HTML | 500 KB | 1B pages | 500 TB | 182 PB |
| Extracted text | 50 KB | 1B pages | 50 TB | 18 PB |
| URL metadata | 1 KB | 50B URLs | 50 TB | 18 PB |
| Bloom filter | 10 bits/URL | 10B URLs | 1.25 GB | 456 GB |
| robots.txt cache | 5 KB | 200M domains | 1 TB (one-time) | 1 TB |
Bandwidth Estimates
| Metric | Calculation | Result |
| Inbound bandwidth | 500 TB / 86,400 sec | ~5.8 GB/sec (46 Gbps) |
| Outbound (links + metadata) | 50 TB / 86,400 sec | ~580 MB/sec (4.6 Gbps) |
| Total per crawler node | 46 Gbps / 1000 nodes | ~46 Mbps per node |
4. Data Model & Storage Schema
Entity Relationship
erDiagram
CRAWLED_PAGE {
bigint id PK
varchar url
varchar domain
text content_hash
text content
int content_length
datetime crawled_at
int http_status
varchar content_type
}
URL_ENTRY {
bigint id PK
varchar url
varchar domain
int priority
varchar status
datetime discovered_at
datetime last_crawled_at
int crawl_count
varchar crawl_group
}
DOMAIN_INFO {
varchar domain PK
int robots_disallow_count
int crawl_delay
datetime last_crawl_at
int pages_crawled_today
varchar ip_address
}
CRAWL_JOB {
bigint id PK
varchar crawler_id
varchar status
datetime started_at
datetime completed_at
int pages_crawled
int pages_failed
}
URL_ENTRY ||--o{ CRAWLED_PAGE : "produces"
DOMAIN_INFO ||--o{ URL_ENTRY : "owns"
CRAWL_JOB ||--o{ URL_ENTRY : "processes"
Cassandra Schema (URL Frontier — Write-Optimized)
CQL
CREATE TABLE url_frontier (
crawl_group text, -- Priority group: "high", "medium", "low"
domain text, -- For politeness grouping
url text,
priority int, -- Within-group priority
status text, -- 'pending', 'crawling', 'completed', 'failed'
discovered_at timestamp,
last_crawled_at timestamp,
next_crawl_at timestamp, -- When this URL should be crawled next
crawl_count int,
content_hash text, -- For deduplication
PRIMARY KEY (crawl_group, domain, url)
) WITH CLUSTERING ORDER BY (domain ASC, url ASC);
CREATE TABLE crawled_content (
url_hash text, -- SHA-256 of URL
url text,
domain text,
crawled_at timestamp,
http_status int,
content_type text,
content blob, -- Raw HTML
extracted_text text, -- Cleaned text content
links list<text>, -- Extracted outbound links
content_hash text, -- For content dedup
metadata map<text, text>,
PRIMARY KEY (url_hash, crawled_at)
) WITH CLUSTERING ORDER BY (crawlled_at DESC)
AND default_time_to_live = 7776000; -- 90 days TTL
CREATE TABLE domain_state (
domain text PRIMARY KEY,
robots_txt blob,
robots_disallow_paths list<text>,
crawl_delay int, -- seconds between requests
last_crawl_at timestamp,
pages_crawled_today int,
daily_limit int,
avg_response_time_ms int,
error_rate float
);
Redis Structures
C#
// URL deduplication bloom filter (Redis module)
// FP rate: 0.1% with 10 bits per URL for 10 billion URLs
redis.call('BF.RESERVE', 'url_bloom', 0.001, 10000000000);
redis.call('BF.ADD', 'url_bloom', normalizedUrl);
// Domain crawl rate limiting
// Key: domain_rate:{domain}
// Sorted set with timestamps for sliding window
redis.call('ZADD', 'domain_rate:example.com', timestampMs, requestId);
redis.call('ZREMRANGEBYSCORE', 'domain_rate:example.com', 0, timestampMs - 1000);
// Crawl queue priorities (Redis Sorted Sets)
// Score = priority + time_decay
redis.call('ZADD', 'crawl_queue:high', score, url);
redis.call('ZPOPMIN', 'crawl_queue:high');
5. API Design
External API
HTTP
// Submit URLs for crawling
POST /api/v1/crawl/submit
{
"urls": [
"https://example.com/page1",
"https://example.com/page2"
],
"priority": "high",
"callback_url": "https://your-app.com/webhook/crawl-complete",
"metadata": {
"campaign": "product_launch",
"tag": "competitor_analysis"
}
}
// Check crawl status
GET /api/v1/crawl/{job_id}/status
// Get crawled content
GET /api/v1/crawl/{job_id}/content?page=1&format=json
// List all crawl jobs
GET /api/v1/crawl/jobs?status=completed&limit=20
// Delete/cancel crawl job
DELETE /api/v1/crawl/{job_id}
// Get crawl statistics
GET /api/v1/stats/domains?top=100
Internal APIs
HTTP
// Worker fetches next URL from frontier
GET /internal/v1/frontier/next?worker_id=crawler-42&count=100
// Worker reports crawl result
POST /internal/v1/crawl/result
{
"url": "https://example.com/page1",
"worker_id": "crawler-42",
"status": "success",
"http_status": 200,
"content_length": 45230,
"content_hash": "sha256:abc123...",
"links_found": [
"https://example.com/page2",
"https://other.com/page1"
],
"crawl_time_ms": 1250
}
// DNS resolver service
POST /internal/v1/dns/resolve
{
"domains": ["example.com", "other.com"]
}
// robots.txt fetcher
GET /internal/v1/robots/{domain}
6. High-Level Architecture
flowchart TB
subgraph Input["Input Layer"]
SEED[Seed URLs]
API[URL Submission API]
end
subgraph Frontier["URL Frontier"]
PRIORITY[Priority Queue]
DOMAIN_Q[Domain Queue]
DEDUP[URL Deduplication]
end
subgraph Fetchers["Fetcher Layer (Distributed)"]
F1[Fetcher Worker 1]
F2[Fetcher Worker 2]
F3[Fetcher Worker N]
end
subgraph Processing["Processing Layer"]
PARSE[HTML Parser]
EXTRACT[Link Extractor]
CHECK[robots.txt Checker]
end
subgraph Storage["Storage Layer"]
CONTENT[(Content Store)]
URL_DB[(URL Metadata)]
DOMAIN_DB[(Domain State)]
end
subgraph Recrawl["Re-crawl Scheduler"]
SCHED[Crawl Scheduler]
FRESH[Freshness Tracker]
end
SEED & API --> DEDUP --> PRIORITY --> DOMAIN_Q
DOMAIN_Q --> F1 & F2 & F3
F1 & F2 & F3 --> PARSE --> EXTRACT
EXTRACT --> DEDUP
F1 & F2 & F3 --> CONTENT
CHECK --> DOMAIN_DB
SCHED --> PRIORITY
Component Responsibilities
| Component | Responsibility | Scaling Strategy |
| URL Frontier | Priority queue managing billions of URLs | Distributed across nodes using consistent hashing |
| Fetcher Workers | Download web pages, respect politeness | Horizontal scaling (1000+ workers) |
| URL Dedup | Bloom filter + URL normalization | Distributed bloom filter, local + global |
| HTML Parser | Extract text, links, metadata | Embedded in fetcher workers |
| robots.txt Checker | Cache and enforce crawl rules | Redis cache + periodic refresh |
| Content Store | Persist crawled content | HDFS / S3 with partitioning by date |
| Re-crawl Scheduler | Determine when to re-crawl pages | Separate service with priority calculation |
'@
7. URL Frontier & Priority Queue
Frontier Architecture
flowchart TB
subgraph Frontier["URL Frontier (Distributed)"]
subgraph Priority["Priority Queues"]
P1[High Priority]
P2[Medium Priority]
P3[Low Priority]
end
subgraph DomainQueues["Per-Domain Queues"]
D1[example.com]
D2[other.com]
D3[third.com]
end
end
F[Fetcher Worker] --> P1
P1 --> D1
P1 --> D2
P1 --> D3
Priority Calculation
C#
public class UrlPriorityCalculator
{
public int CalculatePriority(UrlEntry url, DomainState domain)
{
int score = 50; // Base score
// PageRank factor (if known)
if (url.PageRank.HasValue)
score += (int)(url.PageRank.Value * 30); // 0-30 points
// Freshness factor � how recently was it updated?
if (url.LastCrawledAt.HasValue)
{
var age = DateTime.UtcNow - url.LastCrawledAt.Value;
if (age < TimeSpan.FromHours(1))
score += 20; // Very fresh, crawl again soon
else if (age < TimeSpan.FromDays(1))
score += 10; // Moderately fresh
else if (age > TimeSpan.FromDays(30))
score -= 10; // Stale, lower priority
}
// Domain authority
if (domain.IsHighAuthority)
score += 10;
// Change frequency � pages that change often get higher priority
if (url.ChangeFrequency == "daily")
score += 15;
else if (url.ChangeFrequency == "weekly")
score += 5;
// Link depth � shallower pages are higher priority
score -= url.DepthFromSeed * 2;
// Error penalty � URLs that recently failed get lower priority
if (url.ConsecutiveFailures > 3)
score -= 20;
return Math.Clamp(score, 0, 100);
}
}
Frontier Storage Design
| Design Choice | Implementation | Trade-off |
| In-memory queue | Priority queue in each worker | Fast but not durable, lost on crash |
| Redis sorted sets | Priority = score, URL = member | Fast, shared, but memory-limited |
| Cassandra-backed | Partitioned by domain + priority | Durable, scalable, but higher latency |
| Local disk + distributed | Write-ahead log per worker | Best throughput, complex recovery |
8. Web Fetching Pipeline
sequenceDiagram
participant W as Worker
participant F as URL Frontier
participant R as robots.txt Cache
participant D as DNS Cache
participant H as HTTP Client
participant S as Content Store
participant E as Link Extractor
W->>F: Get next URL
F-->>W: URL + metadata
W->>R: Check robots.txt
R-->>W: Allowed? + delay
W->>D: Resolve DNS
D-->>W: IP address
W->>H: HTTP GET
H-->>W: HTML response
W->>S: Store content
W->>E: Extract links
E-->>W: New URLs
W->>F: Submit new URLs
W->>F: Report crawl result
Fetcher Implementation
C#
public class WebFetcher : BackgroundService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IFrontierService _frontier;
private readonly IRobotsChecker _robotsChecker;
private readonly IDnsCache _dnsCache;
private readonly IContentStore _contentStore;
private readonly ILinkExtractor _linkExtractor;
private readonly IDeduplicator _deduplicator;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var urlEntry = await _frontier.GetNextUrlAsync(
workerId: _workerId,
batchSize: 10);
foreach (var entry in urlEntry)
{
try
{
await CrawlUrlAsync(entry, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to crawl {Url}", entry.Url);
await _frontier.ReportFailureAsync(entry);
}
}
}
}
private async Task CrawlUrlAsync(UrlEntry entry, CancellationToken ct)
{
var domain = entry.Domain;
// 1. Check robots.txt
var robotsResult = await _robotsChecker.CheckAsync(domain, entry.Url);
if (!robotsResult.Allowed)
{
_logger.LogInformation("Blocked by robots.txt: {Url}", entry.Url);
await _frontier.SkipAsync(entry, "robots.txt disallowed");
return;
}
// 2. Respect crawl delay
if (robotsResult.CrawlDelay > 0)
{
await Task.Delay(TimeSpan.FromSeconds(robotsResult.CrawlDelay), ct);
}
// 3. Rate limit per domain
if (!await _rateLimiter.AllowAsync(domain))
{
await _frontier.RequeueAsync(entry);
return;
}
// 4. DNS resolution
var ip = await _dnsCache.ResolveAsync(domain);
// 5. Fetch the page
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("User-Agent", _config.CrawlerUserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
var response = await client.GetAsync(entry.Url, ct);
if (!response.IsSuccessStatusCode)
{
await _frontier.ReportFailureAsync(entry, response.StatusCode);
return;
}
var html = await response.Content.ReadAsStringAsync(ct);
var contentHash = ComputeHash(html);
// 6. Content deduplication
if (await _deduplicator.IsDuplicateContentAsync(contentHash))
{
_logger.LogInformation("Duplicate content: {Url}", entry.Url);
await _frontier.SkipAsync(entry, "duplicate content");
return;
}
// 7. Store content
await _contentStore.StoreAsync(new CrawledPage
{
Url = entry.Url,
Domain = domain,
Content = html,
ContentHash = contentHash,
CrawledAt = DateTime.UtcNow,
HttpStatusCode = (int)response.StatusCode,
ContentLength = html.Length
});
// 8. Extract links
var links = _linkExtractor.ExtractLinks(html, entry.Url);
var newUrls = new List<string>();
foreach (var link in links)
{
var normalized = NormalizeUrl(link, entry.Url);
if (normalized != null && !await _deduplicator.IsDuplicateUrlAsync(normalized))
{
newUrls.Add(normalized);
}
}
// 9. Submit new URLs to frontier
if (newUrls.Any())
{
await _frontier.SubmitUrlsAsync(newUrls, discoveredFrom: entry.Url);
}
// 10. Report success
await _frontier.ReportSuccessAsync(entry, linksFound: links.Count);
}
}
9. URL Filtering & Deduplication
URL Normalization
| Rule | Before | After |
| Lowercase scheme and host | HTTP://Example.COM/Page | http://example.com/page |
| Remove default ports | http://example.com:80/page | http://example.com/page |
| Remove trailing slash | http://example.com/page/ | http://example.com/page |
| Remove fragment | http://example.com/page#section | http://example.com/page |
| Remove session IDs | http://example.com/page?sid=abc123 | http://example.com/page |
| Decode percent-encoding | http://example.com/pa%67e | http://example.com/page |
| Sort query parameters | http://example.com/page?b=2&a=1 | http://example.com/page?a=1&b=2 |
| Remove tracking params | http://example.com/page?utm_source=google | http://example.com/page |
Two-Level Deduplication
C#
public class TwoLevelDeduplicator
{
private readonly IBloomFilter _bloomFilter; // Level 1: Fast probabilistic check
private readonly IContentHashStore _contentStore; // Level 2: Exact check
// Level 1: URL deduplication using bloom filter
public async Task<bool> IsDuplicateUrlAsync(string normalizedUrl)
{
// Bloom filter: O(1) check, 0.1% false positive rate
if (!_bloomFilter.MightContain(normalizedUrl))
{
return false; // Definitely not seen before
}
// False positive � confirm with exact check
return await _contentStore.UrlExistsAsync(normalizedUrl);
}
// Level 2: Content deduplication using SimHash
public async Task<bool> IsDuplicateContentAsync(string contentHash)
{
var simHash = ComputeSimHash(contentHash);
// Find similar hashes within Hamming distance threshold
var similarHashes = await _contentStore.FindSimilarHashesAsync(simHash, threshold: 3);
if (similarHashes.Count == 0)
return false;
// For each similar hash, do full comparison
foreach (var existingHash in similarHashes)
{
if (ComputeHammingDistance(simHash, existingHash) <= 3)
{
return true; // Content is similar enough to be a duplicate
}
}
return false;
}
private ulong ComputeSimHash(string content)
{
var tokens = Tokenize(content);
var vector = new int[64]; // 64-bit SimHash
foreach (var token in tokens)
{
var hash = HashFunction(token);
for (int i = 0; i < 64; i++)
{
vector[i] += ((hash >> i) & 1) == 1 ? 1 : -1;
}
}
ulong simHash = 0;
for (int i = 0; i < 64; i++)
{
if (vector[i] > 0)
simHash |= (1UL << i);
}
return simHash;
}
}
Deduplication Performance
| Method | Space | Time | Accuracy | Use Case |
| Bloom filter | 10 bits/URL | O(1) | 99.9% (0.1% FP) | URL dedup, first pass |
| SimHash | 8 bytes/content | O(1) | ~95% (near-duplicates) | Content dedup |
| Exact hash (SHA-256) | 32 bytes/content | O(n) | 100% | Exact content dedup |
| URL set (Redis) | 200 bytes/URL | O(1) | 100% | Precise dedup, small sets |
| Local bloom + global set | Variable | O(1) | 99.9% | Best of both worlds |
10. Politeness & robots.txt
robots.txt Parsing
C#
public class RobotsTxtChecker
{
private readonly IDistributedCache _cache;
public async Task<RobotsResult> CheckAsync(string domain, string url)
{
var robots = await GetOrFetchRobotsTxtAsync(domain);
if (robots == null)
return new RobotsResult { Allowed = true }; // No robots.txt = everything allowed
// Check if URL matches any Disallow pattern
var path = new Uri(url).AbsolutePath;
foreach (var disallowPath in robots.DisallowPaths)
{
if (path.StartsWith(disallowPath, StringComparison.OrdinalIgnoreCase))
{
return new RobotsResult
{
Allowed = false,
Reason = $"Disallowed by robots.txt: {disallowPath}"
};
}
}
// Check crawl-delay
var crawlDelay = robots.CrawlDelay ?? 1; // Default 1 second
return new RobotsResult
{
Allowed = true,
CrawlDelay = crawlDelay,
SitemapUrl = robots.SitemapUrl
};
}
private async Task<RobotsTxt> GetOrFetchRobotsTxtAsync(string domain)
{
var cacheKey = $"robots:{domain}";
var cached = await _cache.GetAsync(cacheKey);
if (cached != null)
return JsonSerializer.Deserialize<RobotsTxt>(Encoding.UTF8.GetString(cached));
// Fetch robots.txt
try
{
var url = $"https://{domain}/robots.txt";
var response = await _httpClient.GetAsync(url, TimeSpan.FromSeconds(5));
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var parsed = ParseRobotsTxt(content);
// Cache for 24 hours
await _cache.SetAsync(cacheKey,
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(parsed)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
return parsed;
}
}
catch { }
return null; // Fetch failed, assume allowed
}
}
Politeness Rules
| Rule | Implementation | Default |
| robots.txt compliance | Check before every crawl | Always enforced |
| Crawl delay | Per-domain rate limiting | 1 second between requests |
| Concurrent connections | Max 1 connection per domain | Prevents connection flooding |
| User-Agent identification | Send identifying User-Agent | CompanyBot/1.0 (+https://company.com/bot) |
| Daily page limit | Max pages per domain per day | 10,000 pages/domain/day |
| Off-peak crawling | Crawl during target server off-peak | Analyze server response times |
| Back off on errors | Exponential backoff on 429/503 | Start at 5s, max 1 hour |
| Respect Crawl-delay | Honor robots.txt Crawl-delay directive | Per-domain configurable |
Why Politeness Matters: In 2011, Google was sued by a French luxury company for over-crawling their website and causing server overload. In 2020, LinkedIn sued hiQ Labs for scraping public profiles. Violating robots.txt or overloading servers can lead to legal action, IP blacklisting, and reputational damage. Always crawl respectfully.
11. HTML Parsing & Content Extraction
Parsing Pipeline
C#
public class HtmlContentExtractor
{
public ExtractedContent Extract(string html, string baseUrl)
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
var content = new ExtractedContent
{
Title = ExtractTitle(doc),
MetaDescription = ExtractMetaDescription(doc),
TextContent = ExtractTextContent(doc),
Links = ExtractLinks(doc, baseUrl),
Images = ExtractImages(doc, baseUrl),
Metadata = ExtractMetadata(doc),
Language = DetectLanguage(doc),
CanonicalUrl = ExtractCanonicalUrl(doc)
};
return content;
}
private List<string> ExtractLinks(HtmlDocument doc, string baseUrl)
{
var links = new List<string>();
foreach (var anchor in doc.DocumentNode.SelectNodes("//a[@href]") ?? Enumerable.Empty<HtmlNode>())
{
var href = anchor.GetAttributeValue("href", "");
if (string.IsNullOrEmpty(href)) continue;
// Resolve relative URLs
var absoluteUrl = new Uri(new Uri(baseUrl), href).ToString();
// Filter out non-HTTP links (mailto:, javascript:, tel:)
if (absoluteUrl.StartsWith("http://") || absoluteUrl.StartsWith("https://"))
{
links.Add(absoluteUrl);
}
}
return links.Distinct().ToList();
}
private string ExtractTextContent(HtmlDocument doc)
{
// Remove script, style, nav, footer elements
var nodesToRemove = doc.DocumentNode.SelectNodes(
"//script|//style|//nav|//footer|//header|//aside|//noscript");
if (nodesToRemove != null)
{
foreach (var node in nodesToRemove)
node.Remove();
}
// Extract text from body
var body = doc.DocumentNode.SelectSingleNode("//body");
return body?.InnerText?.Trim() ?? "";
}
}
Content Extraction Features
| Feature | How It Works | Use Case |
| Title extraction | Parse <title> and <h1> tags | Page identity, search indexing |
| Meta description | Parse meta name="description" | Search result snippets |
| Text extraction | Remove scripts/styles, extract body text | Content analysis, dedup |
| Link extraction | Parse <a href> with URL resolution | Discovery of new pages |
| Image extraction | Parse <img src> with lazy-load handling | Image search indexing |
| Language detection | Analyze text content for language | Language-specific processing |
| Structured data | Parse JSON-LD, Schema.org markup | Rich search results |
| Open Graph tags | Parse og:title, og:description, og:image | Social media previews |
12. Distributed Architecture
flowchart TB
subgraph Master["Master Node"]
M1[URL Frontier Manager]
M2[Work Distributor]
M3[Status Aggregator]
end
subgraph Workers["Worker Nodes (1000+)"]
W1[Worker 1]
W2[Worker 2]
W3[Worker N]
end
subgraph Storage["Distributed Storage"]
S1[(URL Frontier DB)]
S2[(Content Store)]
S3[(DNS Cache)]
end
M1 --> S1
M2 --> W1 & W2 & W3
W1 & W2 & W3 --> S2
W1 & W2 & W3 --> S3
W1 & W2 & W3 --> M3
Work Distribution Strategy
| Strategy | How It Works | Pros | Cons |
| Random assignment | Master assigns random URLs to workers | Simple, even load | No domain affinity, poor cache hit |
| Domain-based sharding | Worker owns specific domains | Local robots.txt cache, rate limiting | Uneven load if domains differ in size |
| Work stealing | Idle workers steal from busy workers | Adaptive to load | Complex coordination |
| Kafka consumer groups | URLs partitioned in Kafka topics | High throughput, automatic rebalancing | Requires Kafka infrastructure |
Domain-Based Sharding Implementation
C#
public class DomainShardRouter
{
private readonly int _totalShards;
private readonly int _shardId;
public bool IsMyDomain(string domain)
{
var hash = HashFunction(domain);
return (hash % _totalShards) == _shardId;
}
// Each worker processes URLs for its assigned domains
public async Task ProcessUrlsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// Fetch URLs only for domains assigned to this shard
var urls = await _frontier.GetUrlsForMyShardsAsync(
_shardId, _totalShards, batchSize: 100);
foreach (var url in urls)
{
if (!IsMyDomain(url.Domain))
{
// Forward to correct shard
var targetShard = HashFunction(url.Domain) % _totalShards;
await _frontier.ForwardToShardAsync(url, targetShard);
continue;
}
await CrawlUrlAsync(url);
}
}
}
}
13. Content Storage & Indexing
Storage Architecture
flowchart TB
A[Crawler Workers] --> B[Kafka: crawled-pages]
B --> C[Indexer Workers]
C --> D[HDFS / S3: Raw HTML]
C --> E[Elasticsearch: Text Index]
C --> F[PostgreSQL: URL Metadata]
D --> G[MapReduce: Content Analysis]
HDFS Storage Layout
Bash
# Daily crawl partition structure
/ crawl-data/
/ 2025/
/ 01/
/ 15/
/ html/
/ part-00000.gz # Compressed HTML pages
/ part-00001.gz
/ ...
/ metadata/
/ part-00000.json # URL metadata (status, hash, links)
/ ...
/ extracted-text/
/ part-00000.txt # Cleaned text content
/ ...
/ links/
/ part-00000.csv # Source URL ? Target URL mappings
/ ...
# Storage per day (1 billion pages, 500KB each)
# Raw HTML: 500 TB/day ? compressed to ~100 TB/day (5:1 gzip ratio)
# Metadata: 50 TB/day ? compressed to ~5 TB/day
# Total: ~105 TB/day, ~38 PB/year
Storage Options Comparison
| Storage | Throughput | Cost/TB/month | Best For |
| HDFS | Very high (sequential) | $20-40 | Batch processing, MapReduce |
| S3 / GCS | High (object storage) | $23 | Durable archive, CDN origin |
| Cassandra | High (random access) | $100-200 | URL metadata, recent content |
| Elasticsearch | Medium (indexed) | $150-300 | Full-text search of content |
| PostgreSQL | Medium (ACID) | $100-200 | URL state, job tracking |
14. Re-crawling & Freshness
Freshness Strategy
C#
public class RecrawlScheduler
{
public async Task<TimeSpan> CalculateRecrawlIntervalAsync(UrlEntry url, DomainState domain)
{
var baseInterval = url.ChangeFrequency switch
{
"always" => TimeSpan.FromMinutes(5),
"hourly" => TimeSpan.FromHours(1),
"daily" => TimeSpan.FromDays(1),
"weekly" => TimeSpan.FromDays(7),
"monthly" => TimeSpan.FromDays(30),
_ => TimeSpan.FromDays(7) // Default: weekly
};
// Adjust based on observed change rate
if (url.ChangeHistory != null && url.ChangeHistory.Count >= 3)
{
var recentChanges = url.ChangeHistory
.Where(c => c > DateTime.UtcNow.AddDays(-30))
.Count();
if (recentChanges >= 10)
baseInterval = TimeSpan.FromHours(1); // Changes frequently
else if (recentChanges == 0)
baseInterval = baseInterval.Add(TimeSpan.FromDays(7)); // Rarely changes
}
// Adjust based on page importance
if (url.PageRank.HasValue && url.PageRank.Value > 0.8)
baseInterval = TimeSpan.FromHours(Math.Max(1, baseInterval.TotalHours / 2));
// Respect domain crawl limits
if (domain.PagesCrawledToday >= domain.DailyLimit)
baseInterval = baseInterval.Add(TimeSpan.FromHours(1));
return baseInterval;
}
public async Task ScheduleRecrawlAsync(UrlEntry url)
{
var interval = await CalculateRecrawlIntervalAsync(url, url.DomainState);
url.NextCrawlAt = DateTime.UtcNow.Add(interval);
await _frontier.UpdateNextCrawlAsync(url);
}
}
Freshness Metrics
| Metric | Definition | Target |
| Median freshness | 50% of pages re-crawled within | < 24 hours |
| 90th percentile freshness | 90% of pages re-crawled within | < 7 days |
| Stale page rate | % of indexed pages with outdated content | < 5% |
| Change detection rate | % of page changes detected within 24h | > 90% |
| Recrawl coverage | % of discovered URLs re-crawled monthly | > 80% |
15. DNS Resolution at Scale
DNS Caching Architecture
C#
public class DnsResolutionService
{
private readonly IDistributedCache _redisCache;
private readonly ConcurrentDictionary<string, DnsEntry> _localCache = new();
public async Task<string> ResolveAsync(string domain)
{
// Level 1: Local in-memory cache (per worker)
if (_localCache.TryGetValue(domain, out var localEntry) &&
localEntry.ExpiresAt > DateTime.UtcNow)
{
return localEntry.IpAddress;
}
// Level 2: Redis distributed cache
var cached = await _redisCache.GetStringAsync($"dns:{domain}");
if (cached != null)
{
var entry = JsonSerializer.Deserialize<DnsEntry>(cached);
_localCache[domain] = entry; // Populate local cache
return entry.IpAddress;
}
// Level 3: Actual DNS resolution
var addresses = await Dns.GetHostAddressesAsync(domain);
var ip = addresses.FirstOrDefault()?.ToString();
if (ip != null)
{
var entry = new DnsEntry
{
IpAddress = ip,
ExpiresAt = DateTime.UtcNow.AddMinutes(5) // Short TTL for local
};
_localCache[domain] = entry;
await _redisCache.SetStringAsync($"dns:{domain}",
JsonSerializer.Serialize(entry),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
}
return ip;
}
}
DNS Caching Benefits
| Cache Level | Latency | Hit Rate | Size |
| Local in-memory | < 0.01ms | 60% | 10K entries / worker |
| Redis distributed | < 1ms | 35% | 10M entries (20 GB) |
| DNS query | 5-50ms | 5% (misses) | Unlimited |
16. Error Handling & Resilience
Error Classification
| Error Type | HTTP Status | Action | Retry? |
| Not Found | 404 | Mark URL as dead, stop crawling | No |
| Forbidden | 403 | Back off, check robots.txt | After 1 hour |
| Too Many Requests | 429 | Respect Retry-After, exponential backoff | Yes, with delay |
| Server Error | 500-503 | Exponential backoff, max 3 retries | Yes, with backoff |
| Timeout | N/A | Increase timeout, retry once | Once |
| DNS failure | N/A | Cache failure, retry after 1 hour | After delay |
| Connection refused | N/A | Domain may be down, retry in 6 hours | After long delay |
| SSL error | N/A | Log and skip, may be cert issue | No |
Circuit Breaker per Domain
C#
public class DomainCircuitBreaker
{
private readonly ConcurrentDictionary<string, CircuitState> _circuits = new();
public async Task<bool> ShouldCrawlAsync(string domain)
{
var state = _circuits.GetOrAdd(domain, _ => new CircuitState());
if (state.Status == CircuitStatus.Open)
{
if (DateTime.UtcNow - state.LastFailure > state.RecoveryTimeout)
{
state.Status = CircuitStatus.HalfOpen;
return true; // Allow one test request
}
return false; // Circuit open, skip this domain
}
return true;
}
public void RecordResult(string domain, bool success)
{
var state = _circuits.GetOrAdd(domain, _ => new CircuitState());
if (success)
{
state.FailureCount = 0;
state.Status = CircuitStatus.Closed;
}
else
{
state.FailureCount++;
state.LastFailure = DateTime.UtcNow;
if (state.FailureCount >= 5)
{
state.Status = CircuitStatus.Open;
state.RecoveryTimeout = TimeSpan.FromMinutes(
Math.Min(60, state.FailureCount * 5)); // 5-60 min backoff
}
}
}
}
17. Monitoring & Observability
Key Metrics Dashboard
| Panel | Metric | Visualization | Alert Threshold |
| Crawl Rate | Pages crawled per second | Time series | Drop > 50% |
| Success Rate | 2xx responses / total requests | Gauge | < 90% |
| Queue Depth | URLs pending in frontier | Time series | > 10B (backlog) |
| Dedup Rate | Duplicates / total discovered | Gauge | < 80% (too many new URLs) |
| Avg Crawl Latency | Time from URL pick to completion | Histogram | > 30 seconds |
| Domain Error Rate | Failed domains / total domains | Time series | > 10% |
| Bandwidth Usage | MB/sec downloaded | Time series | > 90% of capacity |
| DNS Cache Hit Rate | Cache hits / total lookups | Gauge | < 90% |
| robots.txt Compliance | URLs blocked by robots.txt | Counter | Spike |
| Worker Health | Active workers / total | Gauge | < 80% active |
Alerting Rules
| Alert | Condition | Severity | Action |
| Crawl rate drop | < 50% of target for 30 minutes | P1 | Check worker health, network, DNS |
| High error rate | > 20% errors for 15 minutes | P1 | Check for IP block, network issue |
| Queue overflow | Frontier > 10 billion URLs | P2 | Scale workers, check dedup |
| Worker crash loop | Worker restarts > 3 in 10 minutes | P2 | Investigate memory, exception logs |
| robots.txt violation | Crawl attempted on disallowed URL | P0 | Fix robots.txt checker immediately |
18. Security & Anti-Abuse
Security Measures
| Threat | Risk | Mitigation |
| Honeypot traps | Crawler follows hidden links to trap bots | Respect nofollow, check CSS visibility |
| IP blocking | Target servers block crawler IPs | Rotate IPs, respect rate limits, identify as bot |
| CAPTCHA/Challenge | Pages require human verification | Stop crawling, don't attempt to solve |
| Malicious redirects | Infinite redirect loops or malware | Limit redirects (10 max), scan for malware |
| Content injection | Server detects crawler, injects different content | Use consistent User-Agent, compare content |
| DDoS via crawl | Crawler accidentally overloads a site | Per-domain rate limits, circuit breakers |
| Data exfiltration risk | Crawler accesses sensitive data | Only crawl public content, respect auth walls |
Responsible Crawling Practices
- Identify yourself: Use a descriptive User-Agent string with contact info
- Respect robots.txt: Always check and honor the directives
- Rate limit: No more than 1 request per second per domain
- Handle errors gracefully: Back off on 429/503, don't retry aggressively
- Provide opt-out: Honor removal requests promptly
- Don't circumvent protections: If a site blocks you, respect it
- Minimize impact: Cache robots.txt, DNS results, and avoid redundant requests
19. Case Studies � Production Crawlers
Google Crawler (Googlebot)
| Aspect | Implementation |
| Crawl volume | 100+ billion pages/day |
| Architecture | Distributed across multiple data centers |
| Fetch method | Rendering engine (like Chrome) for JavaScript pages |
| Politeness | Adjusts rate based on server response times |
| Freshness | Important pages re-crawled multiple times per day |
| Dedup | SimHash for near-duplicate detection |
Common Crawl
| Aspect | Implementation |
| Crawl volume | 3-5 billion pages per monthly crawl |
| Storage | 250-400 TB per crawl (compressed) |
| Format | WARC (Web ARChive) files |
| Access | Free, public dataset on AWS S3 |
| Architecture | Apache Nutch-based crawler |
| Usage | Academic research, ML training, startup prototyping |
Other Notable Crawlers
| Crawler | Scale | Purpose | Key Feature |
| Bingbot | 10B pages/day | Bing search index | JavaScript rendering support |
| Yandex Bot | 3B pages/day | Yandex search index | Russian language optimization |
| Applebot | Unknown | Siri, Spotlight | Privacy-focused crawling |
| Amazonbot | Unknown | Alexa, product search | Product page optimization |
20. Cost Estimation
Monthly Cost (1B pages/day)
| Component | Specification | Monthly Cost |
| Crawler workers (500) | c5.large (2 vCPU, 4GB) | $34,560 |
| Fetcher workers (200) | c5.xlarge (4 vCPU, 8GB) | $27,648 |
| HDFS cluster (50 nodes) | d3.xlarge (4 vCPU, 60GB HDD) | $43,200 |
| Cassandra (20 nodes) | i3.xlarge (4 vCPU, 30GB SSD) | $29,760 |
| Redis (10 nodes) | r5.large (2 vCPU, 13GB) | $5,760 |
| Kafka (6 brokers) | k5.xlarge | $12,960 |
| PostgreSQL | db.r5.xlarge (4 vCPU, 32GB) | $2,700 |
| Bandwidth (100Gbps) | Data transfer | $15,000 |
| Total | | ~$171,588/month |
21. Edge Cases
| Edge Case | Problem | Solution |
| Infinite redirect loop | Page redirects A?B?A infinitely | Limit redirects to 10, detect cycles |
| Spider trap | Calendar generates infinite pages | Limit pages per domain, detect URL patterns |
| Massive page (100MB+) | Out-of-memory on fetcher | Stream processing, max page size limit |
| Binary content (PDF, images) | Parser fails on non-HTML | Check Content-Type, use appropriate parser |
| Encoding issues (non-UTF8) | Garbled text content | Detect encoding, convert to UTF-8 |
| JavaScript-heavy pages | Content not in initial HTML | Headless browser rendering (Puppeteer) |
| Dynamic URL parameters | Same content with different params | Normalize URLs, filter tracking params |
| Authentication required | 403 on login-only pages | Skip, don't attempt authentication |
| robots.txt changed | Previously allowed URL now blocked | Re-check robots.txt periodically |
| Content spam | Low-quality or auto-generated content | Content quality scoring, demote in index |
22. Interview Q&A
Q1: How would you design a web crawler that can crawl 1 billion pages per day?
Start with the architecture: distributed fetcher workers pulling URLs from a shared frontier (Cassandra-backed priority queue). Use domain-based sharding for politeness (one worker per domain shard). Deduplicate URLs with a bloom filter for speed and a hash store for accuracy. Store crawled content in HDFS/S3 for batch processing. Use Kafka as the work distribution bus. Scale to 1 billion/day by adding more workers (each handles ~200K pages/day).
Q2: How do you handle URL deduplication at the scale of 10 billion URLs?
Use a two-level approach: (1) Bloom filter for fast probabilistic check (O(1), 0.1% false positive, 10 bits per URL = 12.5 GB for 10B URLs). (2) For false positives, confirm with exact hash storage in Cassandra/Redis. The bloom filter eliminates 99.9% of duplicates without any database lookup. For content deduplication, use SimHash for near-duplicate detection (finds pages with similar content even if URLs differ).
Q3: How do you ensure politeness while crawling at high speed?
Implement per-domain rate limiting using Redis sorted sets (sliding window algorithm). Maximum 1 request per second per domain, with crawl-delay respected from robots.txt. Use domain-based sharding so each worker owns specific domains and maintains local rate limiting state. Implement exponential backoff on 429/503 responses. Circuit breaker pattern per domain to stop crawling domains that are consistently failing.
Q4: How would you handle JavaScript-heavy pages that don't have content in the initial HTML?
Use a headless browser (Puppeteer/Playwright) for pages that require JavaScript rendering. First, classify pages by whether they need rendering (check for common JS frameworks, AJAX calls). For non-JS pages, use regular HTTP fetching (fast, cheap). For JS-required pages, use headless browser (slow, expensive). This hybrid approach balances crawl speed with content completeness.
Q5: How do you detect and handle spider traps (pages that generate infinite URLs)?
Implement multiple detection mechanisms: (1) Limit total pages per domain (e.g., 10K per day). (2) Detect URL patterns that suggest dynamic generation (calendar pages, session IDs, infinite scrolling). (3) Monitor the ratio of new URLs discovered vs. pages crawled � if a single domain is generating disproportionate URLs, throttle it. (4) Use URL depth limits (max 10 levels from seed).
Q6: How do you prioritize which pages to crawl first?
Use a multi-factor priority score: PageRank (if known), page change frequency (from previous crawls), URL depth (shallower is higher priority), domain authority, freshness requirement (news sites need more frequent crawling), and business value. Implement this as a priority queue in Redis with composite scoring. High-priority URLs are crawled immediately; low-priority URLs may wait hours or days.
23. Conclusion
Designing a web crawler at scale requires mastering several distributed systems challenges: managing billions of URLs in a priority queue, implementing polite crawling with per-domain rate limits, detecting duplicates efficiently across 10+ billion URLs, and handling the diverse landscape of web content (HTML, JavaScript, PDFs, images, redirects, errors).
The key insight for interviews is that a web crawler is fundamentally a work scheduling problem. You have a massive queue of URLs to process, constrained by politeness rules (rate limits, robots.txt), and you need to maximize the quality and freshness of the crawled content. The architecture should be designed around this core constraint: a distributed URL frontier feeding thousands of fetcher workers, with deduplication at the front and content storage at the back.
Remember that web crawlers have real-world impact. Over-crawling can overload servers, violate terms of service, and even lead to legal action. Always design for politeness, identify your crawler clearly, and respect the wishes of website operators. The best crawlers are invisible � they efficiently discover and re-crawl content without causing any disruption to the websites they visit.