Design a Web Crawler (Googlebot)
An end-to-end interview-ready walkthrough — from capacity estimation through deep dives on URL frontier design, deduplication, politeness, adaptive scheduling, and fault tolerance. Structured to mirror the arc of a 45-minute system design interview.
Requirements
A web crawler is the backbone of every search engine — it discovers, downloads, and indexes the internet. The challenge isn't fetching one page; it's fetching billions while being polite, avoiding traps, detecting duplicates, and staying fresh. The requirements define whether you're building a weekend scraper or a Googlebot-class system.
Functional Requirements
Core business logic & features
- 01.Seed URL IngestionAccept a set of seed URLs and recursively discover new URLs by parsing fetched pages.
- 02.HTML FetchingDownload web pages over HTTP/HTTPS, handling redirects, timeouts, and encoding.
- 03.Link Extraction & NormalizationParse HTML to extract outgoing links, canonicalize URLs, and feed them back to the frontier.
- 04.Robots.txt ComplianceFetch and respect robots.txt rules per domain — honor Disallow, Crawl-delay, and sitemap directives.
- 05.Content StorageStore raw HTML and extracted metadata in durable object storage for downstream indexing.
- 06.Duplicate DetectionDetect and skip already-seen URLs and near-duplicate page content to avoid wasted work.
Non-Functional
System constraints
Scale
1 billion pages/month — ~385 pages/sec sustained, with burst capacity to 1,000/sec.
Freshness
Full re-crawl of the known web every 2 weeks. High-priority pages re-crawled daily.
Politeness
Never overwhelm a single domain. Per-host rate limits, typically 1 req/sec or per robots.txt Crawl-delay.
Fault Tolerance
No single failure loses crawl progress. Resume from last checkpoint on crash.
🎯 Clarifying questions worth asking
Each one changes the architecture significantly:
- Do we need to render JavaScript? (headless browser pool vs static HTML fetch — 10× cost difference)
- Are we crawling the entire web or a specific domain? (single-domain is trivially different from internet-scale)
- What's the freshness SLA? (news sites need hourly; corporate sites need weekly)
- Do we store just URLs or full page content? (storage grows from GBs to PBs)
- Multi-region or single datacenter? (geo-local crawling reduces latency to target servers)
- Budget for DNS lookups? (at 385 URLs/sec, DNS becomes a bottleneck without caching)
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| URL discovery + HTML fetch | Full-text indexing / ranking | Indexing is a separate system (inverted index, PageRank) — different interview problem |
| Robots.txt compliance | Legal compliance (GDPR, CCPA) | Legal is a policy layer, not an architecture decision |
| URL + content deduplication | Semantic duplicate detection (same article, different site) | Requires NLP — separate ML pipeline |
| Politeness / rate limiting | Anti-bot evasion (CAPTCHA solving, IP rotation) | Ethical crawlers don't evade — they respect signals |
| Adaptive re-crawl scheduling | Real-time change detection (WebSub, RSS polling) | Push-based freshness is a different architecture |
💡 Interviewer signal
The strongest opening move is: "A crawler has two hard problems — politeness at scale and freshness under resource constraints. Everything else is plumbing." This frames the entire discussion around the two dimensions interviewers care about most.
Back-of-Envelope Estimation
The numbers for a web crawler are what separate a toy project from a production system. At internet scale, every component — DNS, network, storage, queues — hits a ceiling. Deriving these numbers out loud shows the interviewer you understand where the bottlenecks live.
Traffic: Pages per second
Our target is 1 billion pages per month with a full refresh every 2 weeks. That means we're not just crawling new pages — we're re-crawling the entire known corpus regularly. The math drives the fetcher fleet size.
Target: 1B pages/month
Average crawl rate:
1,000,000,000 / (30 days × 86,400 sec) ≈ 385 pages/sec
Re-crawl budget (2-week full refresh):
1B pages / (14 days × 86,400 sec) ≈ 827 pages/sec for re-crawl alone
Combined (new + re-crawl):
~1,200 pages/sec sustained
Peak (burst during off-hours): ~2,000 pages/sec
Per-fetcher throughput:
Average page fetch: ~2 sec (DNS + TCP + TLS + download + parse)
Pages/sec per worker: 0.5
Workers needed: 1,200 / 0.5 = 2,400 concurrent fetcher threads
Implication:
→ ~50 machines with 50 threads each, or fewer with async I/O
→ Async fetchers (epoll/io_uring) can handle 200+ concurrent connections per core
Storage
Every fetched page needs to be stored for downstream processing (indexing, ranking, content extraction). The storage math determines whether we use a single object store or need tiered storage.
Per-page estimate:
Raw HTML (compressed) : ~100 KB average (gzip reduces ~500KB → ~100KB)
URL metadata : ~500 bytes (URL, fetch time, status, headers)
Extracted text : ~20 KB (stripped HTML)
─────────────────────────────
Total per page ≈ 120 KB compressed
Monthly storage:
1B pages × 120 KB = 120 TB/month (raw content)
URL metadata only: 1B × 500 B = 500 GB/month
Annual storage:
~1.4 PB/year for full content
~6 TB/year for URL metadata alone
Implication:
→ Object storage (S3/GCS) for page content — cheap, durable, append-only
→ Hot metadata in a database (URL status, last-crawl time, priority)
→ Cold content archived after 90 days to cheaper storage tier
Network bandwidth
Network is often the overlooked bottleneck. At 1,200 pages/sec with an average page size of 500KB (uncompressed), the bandwidth requirements are substantial.
Inbound (fetching pages):
1,200 pages/sec × 500 KB (uncompressed avg) = 600 MB/s = 4.8 Gbps
With gzip (most servers support it): ~120 MB/s = ~1 Gbps
Outbound (to object storage):
1,200 pages/sec × 100 KB (compressed) = 120 MB/s = ~1 Gbps
DNS queries:
1,200 unique domains/sec worst case (if every page is a new domain)
Realistic with caching: ~100 DNS queries/sec (90%+ cache hit rate)
Implication:
→ Need 2-4 Gbps network capacity across the fetcher fleet
→ DNS caching is critical — without it, DNS becomes the bottleneck
→ Connection pooling per domain reduces TCP/TLS handshake overhead
URL frontier sizing
The is the heart of the crawler — it holds every URL we've discovered but haven't yet fetched. Its size determines whether we can keep it in memory or need disk-backed queues.
Known URLs (after months of crawling):
~10 billion URLs discovered (most of the web)
Active frontier (pending crawl): ~2 billion URLs
Per-URL entry in frontier:
URL hash (for dedup) : 8 bytes (64-bit fingerprint)
Priority score : 4 bytes
Domain ID : 4 bytes
Last-seen timestamp : 8 bytes
─────────────────────────────
≈ 24 bytes per entry (metadata only; full URL stored separately)
Frontier memory:
2B entries × 24 bytes = 48 GB (metadata fits in RAM on a single large box)
Full URLs (avg 100 chars): 2B × 100 B = 200 GB (needs disk or distributed)
Implication:
→ Frontier metadata can live in-memory on a beefy machine (64-128 GB RAM)
→ Full URL strings stored on disk (RocksDB / LevelDB) with in-memory index
→ Bloom filter for "already seen" check: 10B URLs × 1.2 bytes = ~12 GB
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Crawl rate (sustained): ~1,200 pages/sec
Crawl rate (peak): ~2,000 pages/sec
Fetcher workers needed: ~2,400 concurrent threads
Storage (monthly): ~120 TB content + 500 GB metadata
Network inbound: ~1 Gbps (compressed)
Frontier size: ~2B pending URLs, 48 GB metadata
Bloom filter (seen URLs): ~12 GB for 10B URLs at 1% FP
DNS queries: ~100/sec with 90% cache hit
Robots.txt cache: ~50M domains × 2KB = 100 GB
Component Interfaces
A web crawler isn't a user-facing API — it's an internal distributed system. Instead of REST endpoints, we define the contracts between components: how the Frontier talks to Fetchers, how Fetchers hand off to the Parser, and how the Parser feeds back into the Frontier. These interfaces determine how independently each component can scale and fail.
Frontier → Fetcher interface
The Frontier is the scheduler. It decides what to crawl next and hands work to Fetcher workers. The contract must encode enough context for the Fetcher to be polite (domain rate limits, robots.txt rules) without requiring the Fetcher to look anything up itself.
type CrawlTask = {
taskId: string; // Idempotency key for dedup on retry
url: string; // Fully normalized URL to fetch
domainId: string; // Hash of the domain — used for rate-limit bucketing
priority: number; // 0 (highest) to 100 (lowest)
allowedDelaySec: number; // Min seconds between requests to this domain
robotsRules: {
disallowPaths: string[]; // Paths we must NOT fetch
crawlDelay: number | null; // Explicit delay from robots.txt
};
retryCount: number; // How many times this URL has been attempted
maxRetries: number; // Give up after this many failures
fetchDeadlineMs: number; // Timeout for the entire fetch operation
};
Fetcher → Parser interface
Once a Fetcher downloads a page, it produces a result that the Parser consumes. The result includes the raw content, HTTP metadata, and timing information for observability. Failed fetches still produce a result — the Parser needs to know about failures to update the Frontier's retry state.
type FetchResult = {
taskId: string;
url: string;
domainId: string;
status: "success" | "failed" | "robots_blocked" | "timeout" | "dns_error";
httpStatus: number | null; // null if connection failed
headers: Record<string, string>; // Content-Type, Last-Modified, ETag
body: Buffer | null; // Raw HTML bytes (null on failure)
contentHash: string; // SHA-256 of body — for content dedup
fetchedAt: string; // ISO timestamp
latencyMs: number; // Total fetch time (DNS + TCP + TLS + download)
redirectChain: string[]; // All intermediate URLs if redirected
};
Parser → Frontier interface (feedback loop)
The Parser extracts links from fetched pages and feeds them back to the Frontier. This is the crawl's growth mechanism — every fetched page potentially discovers hundreds of new URLs. The Parser also produces metadata for the content store.
type ParseResult = {
sourceUrl: string;
discoveredUrls: NormalizedUrl[]; // Extracted + canonicalized outgoing links
pageMetadata: {
title: string;
description: string;
language: string;
contentType: string; // text/html, application/pdf, etc.
lastModified: string | null; // From HTTP header or <meta> tag
canonicalUrl: string | null; // <link rel="canonical"> if present
};
contentFingerprint: string; // SimHash for near-duplicate detection
outboundLinkCount: number; // Used for PageRank-style priority scoring
};
type NormalizedUrl = {
url: string; // Fully resolved, canonicalized URL
anchorText: string; // Text of the <a> tag — useful for relevance
domainId: string; // Pre-computed domain hash
isInternal: boolean; // Same domain as source?
};
Control plane interfaces
Beyond the data path, the crawler needs a control plane for operators to manage crawl behavior — pause domains, adjust priorities, trigger re-crawls, and monitor health.
| Operation | Input | Effect |
|---|---|---|
| Pause domain | domainId | All pending tasks for this domain are held; active fetches complete |
| Adjust priority | URL pattern + new priority | Matching URLs in frontier get re-scored; takes effect on next dequeue |
| Force re-crawl | URL or domain | Bypasses freshness check; enqueues immediately at high priority |
| Blocklist domain | domainId + reason | Permanently skip; purge from frontier; reject future discoveries |
| Get crawl stats | time range | Returns pages/sec, error rate, queue depth, domain distribution |
💡 Why interfaces matter in the interview
Defining these contracts early shows the interviewer you think in terms of failure boundaries and independent scaling. The Frontier doesn't care how the Fetcher downloads a page (HTTP/1.1, HTTP/2, headless browser). The Parser doesn't care how the Frontier prioritizes. Each component can be replaced, scaled, or failed independently — and that's the whole point of the separation.
High-Level Architecture
A web crawler is a pipeline, not a request-response system. Data flows in one direction: URLs enter the Frontier, get fetched, get parsed, and the extracted links feed back into the Frontier. The architecture is a loop — and the challenge is making that loop run at 1,200 iterations/sec without falling over.
We split the system into five core components, each independently scalable and each with a different failure mode. The separation exists because each component has a fundamentally different bottleneck: the Frontier is CPU-bound (priority scoring), Fetchers are I/O-bound (network), the Parser is CPU-bound (HTML parsing), and the Content Store is throughput-bound (disk writes).
The crawl loop
The core data flow is a continuous loop. URLs are dequeued from the Frontier, fetched by workers, parsed for content and links, and the discovered links are fed back into the Frontier. Each iteration of this loop processes one page and potentially discovers 50-100 new URLs.
URL Frontier
Priority queue of URLs
Fetcher Workers
Download HTML (async I/O)
DNS Cache
Resolve domain → IP
Parser
Extract links + metadata
Dedup Service
URL + content dedup
Content Store
S3 / object storage
The loop is not synchronous — each stage operates as an independent service connected by message queues. The Frontier produces tasks to a queue, Fetchers consume and produce results to another queue, and the Parser consumes results and produces both stored content and new URL discoveries. This decoupling means a slow Parser doesn't block Fetchers, and a Fetcher timeout doesn't stall the Frontier.
Component responsibilities
Each component has a single, clear job. If you can't describe what it does in one sentence, it's doing too much.
URL Frontier
Priority queue holding billions of pending URLs. Enforces per-domain politeness delays. Dequeues the highest-priority URL whose domain isn't currently rate-limited. The brain of the crawler.
Fetcher Workers
Stateless async HTTP clients. Download pages respecting timeouts and redirects. Report success/failure back. Scale horizontally — add more workers for more throughput. No business logic.
DNS Resolver + Cache
Local DNS cache (TTL-aware) shared across all Fetchers. Reduces external DNS lookups from 1,200/sec to ~100/sec. Falls back to multiple upstream resolvers for redundancy.
HTML Parser
Extracts outgoing links, normalizes URLs (resolve relative paths, strip tracking params, canonicalize). Extracts page metadata (title, language, last-modified). CPU-bound — scales on cores.
Deduplication Service
Two layers: URL dedup (Bloom filter — have we seen this URL before?) and content dedup (SimHash — is this page near-identical to one we already stored?). Prevents wasted fetches and storage.
Content Store
Object storage (S3/GCS) for raw HTML. Metadata DB (Postgres/Cassandra) for URL status, fetch history, priority scores. Append-only for content; mutable for metadata.
Robots.txt Manager
Fetches and caches robots.txt for every domain. Refreshes every 24h. Provides allow/disallow decisions to the Frontier before task dispatch. Respects Crawl-delay directives.
Crawl Scheduler
Decides WHEN to re-crawl each URL based on change frequency, importance (PageRank), and freshness decay. Feeds re-crawl tasks back into the Frontier at calculated intervals.
Three data flow paths
Path 1: Discovery (new URL enters the system)
When the Parser extracts a link from a fetched page, it goes through URL normalization, then hits the for dedup. If it's genuinely new, it gets scored (based on domain authority, link context, and discovery depth) and inserted into the Frontier.
Parser
Extracts link
URL Normalizer
Canonicalize
Bloom Filter
Seen before?
Priority Scorer
Assign score
Frontier
Enqueue
Path 2: Fetch (URL gets crawled)
The Frontier dequeues the highest-priority URL whose domain isn't rate-limited. The Fetcher resolves DNS (cached), opens a connection (pooled), downloads the page, and produces a FetchResult. On success, the result goes to the Parser. On failure, it goes back to the Frontier with an incremented retry count.
Frontier
Dequeue task
Rate Limiter
Domain delay check
DNS Cache
Resolve IP
Fetcher
HTTP GET
Parser Queue
Enqueue result
Path 3: Storage (content persisted)
After parsing, the raw HTML is compressed and written to object storage. The URL metadata (status, fetch time, content hash) is updated in the metadata DB. If the content hash matches an existing page (near-duplicate), we skip storage and just update the metadata to point to the existing copy.
Parser
Content + metadata
Content Dedup
SimHash check
Object Store
Compressed HTML
Metadata DB
URL status update
Why this separation matters
Each component has a different scaling axis and failure mode:
- Frontier — scales on memory (queue size) and CPU (priority scoring). Failure = crawl stops. Must be replicated.
- Fetchers — scale on network I/O and connection count. Failure = one URL retried. Stateless, trivially replaceable.
- Parser — scales on CPU cores. Failure = one page re-parsed. Stateless, trivially replaceable.
- Content Store — scales on disk throughput. Failure = data loss if not replicated. Use managed object storage.
- Dedup Service — scales on memory (Bloom filter size). Failure = duplicate work (wasteful but not catastrophic).
Total time per page (end-to-end): ~2-3 seconds
0.0ms ─ Frontier dequeue (in-memory priority queue)
0.1ms ─ Rate-limit check (per-domain token bucket)
5ms ─ DNS resolution (cached — 0.1ms hit, 50ms miss)
50ms ─ TCP + TLS handshake (pooled connections skip this)
500ms ─ HTTP response download (avg 500KB page)
100ms ─ HTML parsing + link extraction
50ms ─ URL normalization + Bloom filter check (per discovered URL)
20ms ─ Content hash computation (SHA-256)
10ms ─ SimHash computation (near-duplicate fingerprint)
100ms ─ Object storage write (async, non-blocking)
10ms ─ Metadata DB update
─────────────────────────────────────
~850ms on cache-hit DNS + pooled connection
~2,500ms on cold start (new domain, no connection pool)
Implication:
→ With 2,400 concurrent workers, we achieve 1,200 pages/sec
→ Connection pooling per domain cuts 50-100ms per fetch
→ DNS caching cuts 45ms per fetch on average
💡 What to say to the interviewer
"The crawler is a pipeline with a feedback loop. Each stage is independently scalable and connected by durable queues. The Frontier is the brain (what to crawl), Fetchers are the hands (how to crawl), and the Parser is the eyes (what did we find). Failures in any stage are isolated — a Fetcher crash loses one page, not the crawl."
URL Frontier & Prioritization
The URL Frontier is the most complex component in the crawler — it's not just a queue, it's a scheduler that must balance three competing concerns: priority (crawl important pages first), politeness (don't overwhelm any single domain), and freshness (re-crawl pages that change frequently). Getting this wrong means either crawling garbage or getting IP-banned by every major website.
Bad: Single FIFO queue
The naive approach is a single first-in-first-out queue. URLs are added as they're discovered and processed in order. This is what you'd build in a weekend project — and it fails catastrophically at scale.
Queue: [url1.com/a, url1.com/b, url1.com/c, url2.com/x, url1.com/d, ...]
Problem 1 — Politeness violation:
If a page has 100 outgoing links to the same domain, all 100 get
enqueued consecutively. The crawler hammers that domain with 100
requests in seconds → gets IP-banned.
Problem 2 — No priority:
A newly discovered link to nytimes.com/breaking-news sits behind
10,000 links to some random blog's archive pages. Important pages
wait hours while garbage gets crawled first.
Problem 3 — Starvation:
Popular domains with millions of pages dominate the queue.
Small but important domains never get crawled.
Good: Priority queue with per-domain buckets
The improvement is to separate priority from politeness. Use a priority queue to decide what to crawl next, but enforce per-domain rate limits so we never send more than N requests/sec to any single host. This is the Mercator architecture — the standard reference design for web crawlers.
The Frontier is split into two layers: a front queue (priority-based) that decides importance, and a back queue (domain-based) that enforces politeness. URLs flow from front → back → fetcher.
FRONT QUEUES (priority-based):
┌─────────────────────────────────────────┐
│ Queue 0 (highest): breaking news, homepages, sitemaps │
│ Queue 1 (high): recently changed pages, high PageRank │
│ Queue 2 (medium): normal discovery, internal links │
│ Queue 3 (low): deep archive pages, low-authority │
└─────────────────────────────────────────┘
Biased selection: Queue 0 gets 40% of dequeues, Queue 1 gets 30%, etc.
BACK QUEUES (domain-based):
┌─────────────────────────────────────────┐
│ Bucket[hash("nytimes.com")]: [url1, url2, url3] │
│ Bucket[hash("github.com")]: [url4, url5] │
│ Bucket[hash("blog.xyz")]: [url6] │
└─────────────────────────────────────────┘
Each bucket has a "next_allowed_fetch_time" timestamp.
Dequeue: pick the bucket with the earliest allowed time → pop one URL.
FLOW:
New URL → Priority Scorer → Front Queue (by priority)
Front Queue → Router (by domain hash) → Back Queue
Back Queue → Rate Limiter → Fetcher
This solves politeness and priority, but has a scaling problem: with 50 million active domains, you need 50 million back-queue buckets. In-memory, that's manageable (~2 GB for bucket metadata). But the full URL strings for 2 billion pending URLs don't fit in RAM.
Optimal: Distributed frontier with disk-backed queues
The production solution partitions the Frontier across multiple machines by domain hash. Each partition owns a subset of domains and manages their back-queues independently. Within each partition, a instance stores the full URL strings on disk, while an in-memory index tracks priorities and domain scheduling metadata.
// Each frontier partition owns domains where hash(domain) % N == partitionId
class FrontierPartition {
// In-memory: domain scheduling state (~40 bytes per domain)
domainSchedule: Map<string, {
nextAllowedFetchTime: number; // Unix ms
pendingCount: number;
crawlDelay: number; // From robots.txt
priority: number; // Aggregate domain priority
}>;
// On-disk (RocksDB): full URL queue per domain
// Key: `${domainId}:${priority}:${urlHash}`
// Value: serialized CrawlTask
urlStore: RocksDB;
// In-memory: min-heap of domains sorted by nextAllowedFetchTime
// Only domains with pendingCount > 0 are in the heap
readyHeap: MinHeap<{ domainId: string; readyAt: number }>;
dequeue(): CrawlTask | null {
// Pop domain with earliest readyAt that's <= now
const domain = this.readyHeap.peekMin();
if (!domain || domain.readyAt > Date.now()) return null;
// Fetch highest-priority URL for this domain from RocksDB
const task = this.urlStore.getHighestPriority(domain.domainId);
// Update domain's next allowed time
domain.readyAt = Date.now() + domain.crawlDelay;
this.readyHeap.decreaseKey(domain);
return task;
}
}
Priority scoring
Not all URLs are equal. A homepage changes hourly; a 2015 blog post hasn't changed in years. The priority score combines multiple signals to decide what's worth crawling next. The scoring function runs when a URL is first discovered and again when it's time for a re-crawl decision.
priority = w1 × pageRankScore // Domain/page authority (0-1)
+ w2 × freshnessDecay // How stale is our copy? (0-1)
+ w3 × changeFrequency // How often does this page change? (0-1)
+ w4 × depthPenalty // Deeper pages get lower priority (0-1)
+ w5 × discoveryRecency // Recently discovered = boost (0-1)
Typical weights:
w1 = 0.3 (authority matters most)
w2 = 0.25 (freshness is critical for news/dynamic sites)
w3 = 0.2 (frequently changing pages need more attention)
w4 = 0.15 (shallow pages are usually more important)
w5 = 0.1 (slight boost for newly discovered URLs)
Examples:
nytimes.com/ → 0.3×0.9 + 0.25×0.8 + 0.2×0.95 + 0.15×1.0 + 0.1×0.5 = 0.81
random-blog.com/p/123 → 0.3×0.1 + 0.25×0.2 + 0.2×0.05 + 0.15×0.3 + 0.1×0.3 = 0.16
| Approach | Pros | Cons | Verdict |
|---|---|---|---|
| Single FIFO queue | Simple to implement | No priority, no politeness, domain starvation | ❌ Toy only |
| Priority queue (no domain awareness) | Important pages first | Hammers popular domains; gets IP-banned | ❌ Unusable |
| Mercator two-layer (in-memory) | Priority + politeness; proven design | Doesn't scale past ~100M URLs in RAM | ⚠️ Good for medium scale |
| Distributed + disk-backed (chosen) | Scales to billions; per-domain politeness; fault-tolerant | More complex; needs partition rebalancing | ✅ Production-grade |
What the optimal Frontier guarantees
- ✅No domain receives more than 1 request per crawl-delay interval
- ✅High-priority URLs are crawled within minutes of discovery
- ✅Frontier state survives machine crashes (RocksDB WAL + replication)
- ✅Adding more partitions scales linearly with domain count
- ✅Re-crawl scheduling is adaptive — pages that change get crawled more often
🔑 The key insight interviewers want
The Frontier is not a queue — it's a scheduler. It must answer "what is the highest-priority URL whose domain is not currently rate-limited?" in O(log N) time. That's a min-heap of domains sorted by next-allowed-time, combined with a per-domain priority queue of URLs. Two data structures, not one.
Fetcher Workers & Politeness
Fetcher workers are the hands of the crawler — they do the actual HTTP requests. The challenge isn't making one request; it's making 1,200/sec across millions of domains without getting banned, overwhelming small servers, or wasting resources on timeouts. The fetcher design is where politeness meets performance.
Bad: Synchronous single-threaded fetcher
A single thread making blocking HTTP requests, waiting for each response before starting the next. At 2 seconds per page, this gives you 0.5 pages/sec — you'd need 2,400 threads just to hit the target rate. Thread-per-request doesn't scale.
// Thread 1:
fetch("https://example.com/page1") // blocks 2 sec
fetch("https://example.com/page2") // blocks 2 sec
fetch("https://example.com/page3") // blocks 2 sec
Throughput: 0.5 pages/sec per thread
For 1,200 pages/sec: need 2,400 OS threads
Memory: 2,400 threads × 1MB stack = 2.4 GB just for stacks
Context switching: catastrophic at 2,400 threads
Problems:
→ OS thread limit (~10K per process)
→ Context switch overhead dominates CPU time
→ No connection reuse between requests to same domain
→ No timeout handling — one slow server blocks the thread forever
Good: Async I/O with connection pooling
Use non-blocking I/O (epoll on Linux, kqueue on macOS) to handle thousands of concurrent connections on a single thread. Each fetcher process manages hundreds of in-flight requests simultaneously. Add connection pooling per domain to reuse TCP+TLS connections across multiple pages on the same host.
class AsyncFetcher {
// Connection pool: reuse TCP+TLS connections per domain
private connectionPool: Map<string, ConnectionPool>;
// Event loop handles 500+ concurrent requests per process
private inFlight: Map<string, PendingRequest>;
private maxConcurrent = 500;
async fetch(task: CrawlTask): Promise<FetchResult> {
// Get or create pooled connection for this domain
const conn = await this.connectionPool
.getOrCreate(task.domainId, { maxIdle: 5, idleTimeout: 30_000 });
// Non-blocking fetch with timeout
const response = await Promise.race([
conn.get(task.url, {
headers: { "User-Agent": "EventLoopedBot/1.0" },
followRedirects: 5,
maxResponseSize: 10_000_000, // 10MB cap
}),
timeout(task.fetchDeadlineMs),
]);
return buildFetchResult(task, response);
}
}
// 50 machines × 10 processes × 500 concurrent = 250,000 in-flight
// At 2 sec avg latency: 250,000 / 2 = 125,000 pages/sec capacity
// We only need 1,200/sec — massive headroom for bursts
Optimal: Domain-aware fetcher fleet with adaptive throttling
The production design adds domain awareness to the fetcher layer itself. Each fetcher is assigned a set of domains (by consistent hash), which means connection pools are maximally reused and per-domain rate state is local. Combined with adaptive throttling that backs off when servers respond slowly or return 429s.
class DomainAwareFetcher {
// This fetcher owns domains where hash(domain) % fleetSize == myId
private myDomains: Set<string>;
// Per-domain state (local — no distributed coordination needed)
private domainState: Map<string, {
connectionPool: ConnectionPool;
tokenBucket: TokenBucket; // Rate limiter
avgLatencyMs: ExponentialMovingAvg;
consecutiveErrors: number;
backoffUntil: number; // Exponential backoff on errors
}>;
async fetch(task: CrawlTask): Promise<FetchResult> {
const state = this.domainState.get(task.domainId);
// Adaptive throttling: if domain is slow, reduce concurrency
if (state.avgLatencyMs.value > 5000) {
state.tokenBucket.setRate(0.2); // Slow down to 1 req/5sec
}
// Exponential backoff on consecutive errors
if (state.consecutiveErrors >= 3) {
state.backoffUntil = Date.now() + Math.min(
1000 * Math.pow(2, state.consecutiveErrors),
3600_000 // Max 1 hour backoff
);
return { ...task, status: "backoff" };
}
// Token bucket: respect robots.txt Crawl-delay
await state.tokenBucket.acquire();
const startTime = Date.now();
const result = await this.doFetch(task, state.connectionPool);
// Update adaptive state
state.avgLatencyMs.update(Date.now() - startTime);
if (result.status === "success") {
state.consecutiveErrors = 0;
} else {
state.consecutiveErrors++;
}
return result;
}
}
Robots.txt compliance
Every ethical crawler must respect . The robots.txt file is fetched once per domain (refreshed every 24 hours) and cached. Before any URL is dispatched to a fetcher, the Frontier checks it against the cached rules.
# Example robots.txt for nytimes.com
User-agent: *
Disallow: /search
Disallow: /admin
Crawl-delay: 2
User-agent: EventLoopedBot
Allow: /
Crawl-delay: 1
Sitemap: https://www.nytimes.com/sitemap.xml
─────────────────────────────────────
Crawler behavior:
1. Before first fetch to any domain → GET /robots.txt
2. Parse rules for our User-Agent (fall back to *)
3. Cache with 24h TTL (refresh daily)
4. On every URL dispatch: check path against Disallow rules
5. Honor Crawl-delay as minimum interval between requests
6. Fetch sitemap URLs → add to frontier with high priority
Edge cases:
- robots.txt returns 404 → assume everything is allowed
- robots.txt returns 5xx → retry 3 times, then assume disallow-all (safe default)
- robots.txt > 500KB → reject (likely not a real robots.txt)
- Crawl-delay > 60s → respect it, but deprioritize the domain
| Politeness Mechanism | What it prevents | Implementation |
|---|---|---|
| Per-domain rate limit | Overwhelming a single server | Token bucket per domain; default 1 req/sec, override from robots.txt |
| Robots.txt compliance | Crawling disallowed paths | Cached rules checked before dispatch; 24h refresh |
| Adaptive backoff | Hammering slow/failing servers | Exponential backoff on consecutive errors; max 1h |
| Connection pooling | TCP/TLS handshake flood | Reuse connections per domain; max 5 idle connections |
| User-Agent identification | Anonymous crawling (looks like an attack) | Clear bot name + contact URL in User-Agent header |
| Time-of-day awareness | Crawling during peak hours | Reduce rate during business hours for the domain's timezone |
What gets your crawler banned
- ❌Ignoring robots.txt Crawl-delay directives
- ❌No User-Agent or a spoofed browser User-Agent
- ❌Fetching the same URL repeatedly (retry storms)
- ❌Opening 100+ concurrent connections to one domain
- ❌Crawling during peak traffic hours without throttling
- ❌Following infinite redirect chains (301 → 302 → 301 → ...)
🔑 DNS caching — the hidden bottleneck
At 1,200 URLs/sec, you're potentially resolving 1,200 unique domains/sec. Public DNS resolvers (8.8.8.8) rate-limit at ~1,000 QPS. The solution: run a local DNS cache (unbound/dnsmasq) with aggressive TTL extension. Cache hit rate should be 90%+ — most crawled domains are visited repeatedly. Without DNS caching, DNS resolution alone takes 50-200ms per fetch and becomes the bottleneck.
Deduplication (URL + Content)
The web is full of duplicates. The same page lives at multiple URLs (with/without www, with/without trailing slash, with tracking parameters). Different pages have identical content (syndicated articles, scraped copies). Without deduplication, a crawler wastes bandwidth, storage, and compute re-fetching and re-storing content it already has. At 1B pages/month, even 5% duplication means 50M wasted fetches.
Deduplication happens at two levels: URL-level (have we seen this exact URL before?) and content-level (is this page's content identical or near-identical to something we already stored?).
URL Deduplication
Bad: HashSet in memory
Store every seen URL in a HashSet. At 10 billion URLs with an average length of 100 bytes, that's 1 TB of RAM — impossible on any single machine. Even storing just 64-bit hashes requires 80 GB, which is feasible but expensive and doesn't survive restarts without persistence.
Good: Bloom filter for fast rejection
A uses ~1.2 bytes per element at 1% false positive rate. For 10 billion URLs, that's ~12 GB — fits comfortably in RAM on a single machine. The trade-off: 1% of genuinely new URLs will be incorrectly marked as "already seen" and skipped. For a crawler, this is acceptable — we'll discover those URLs again on the next crawl cycle.
Parameters:
n = 10 billion URLs (expected total over crawler lifetime)
p = 0.01 (1% false positive rate — acceptable for crawling)
Optimal bit array size:
m = -(n × ln(p)) / (ln(2))²
m = -(10B × ln(0.01)) / (ln(2))²
m ≈ 95.8 billion bits ≈ 12 GB
Optimal hash functions:
k = (m/n) × ln(2) = 9.6 × 0.693 ≈ 7 hash functions
Lookup time: O(k) = O(7) — constant time, ~100 nanoseconds
Insert time: O(k) = O(7) — constant time
Trade-off:
→ 1% of new URLs are falsely rejected (re-discovered next cycle)
→ 0% of seen URLs are falsely accepted (no duplicates slip through)
→ 12 GB RAM vs 80 GB for a full hash set — 6.7× savings
Optimal: Bloom filter + persistent hash store
The production approach layers a Bloom filter (fast in-memory rejection) with a persistent hash store (ground truth). The Bloom filter handles 99% of lookups in nanoseconds. The 1% that pass through (potential new URLs) are verified against a disk-backed hash store (RocksDB or a distributed KV store) that holds the 64-bit fingerprint of every URL ever seen.
class UrlDeduplicator {
// Layer 1: In-memory Bloom filter (12 GB, 1% FP rate)
private bloomFilter: BloomFilter; // 10B capacity
// Layer 2: Persistent hash store (RocksDB on SSD)
// Key: 64-bit URL fingerprint, Value: empty (existence check only)
private hashStore: RocksDB;
async isNew(url: string): Promise<boolean> {
const fingerprint = xxhash64(normalizeUrl(url));
// Fast path: Bloom filter says "definitely not seen" → it's new
if (!this.bloomFilter.mightContain(fingerprint)) {
return true; // Guaranteed new
}
// Slow path: Bloom filter says "maybe seen" → verify against disk
// This happens for ~1% of genuinely new URLs (false positives)
// and 100% of actually-seen URLs (true positives)
const exists = await this.hashStore.exists(fingerprint);
return !exists;
}
async markSeen(url: string): Promise<void> {
const fingerprint = xxhash64(normalizeUrl(url));
this.bloomFilter.add(fingerprint);
await this.hashStore.put(fingerprint, EMPTY_VALUE);
}
}
URL Normalization (before dedup)
Before checking if a URL is a duplicate, we must normalize it. Without normalization, the same page appears as dozens of "different" URLs — each wasting a fetch.
Input URLs that all point to the SAME page:
https://Example.COM/path/page.html
https://example.com/path/page.html?utm_source=twitter&utm_medium=social
https://example.com/path/page.html#section-3
https://example.com/path/../path/page.html
https://www.example.com/path/page.html
http://example.com/path/page.html
Normalization steps (in order):
1. Lowercase scheme and host → https://example.com/...
2. Remove default port (80/443) → strip :443 from HTTPS URLs
3. Remove fragment (#...) → #section-3 is client-side only
4. Remove tracking parameters → strip utm_*, fbclid, gclid, etc.
5. Sort remaining query params → ?b=2&a=1 → ?a=1&b=2
6. Resolve path (../ and ./) → /path/../path/ → /path/
7. Remove trailing slash → /path/ → /path (configurable)
8. Handle www subdomain → www.example.com → example.com (configurable)
9. Decode unnecessary percent-encoding → %41 → A
Output: https://example.com/path/page.html
Content Deduplication
URL dedup catches exact URL matches. But the web has millions of pages with different URLs but identical (or near-identical) content: syndicated news articles, product pages with different sort orders, paginated views with overlapping content. Content dedup catches these.
Bad: Exact hash comparison (SHA-256)
Hash the entire page content and compare. This catches exact duplicates but misses near-duplicates — pages that differ by a single ad banner, timestamp, or session ID. In practice, exact duplicates are rare; near-duplicates are everywhere.
Optimal: SimHash for near-duplicate detection
produces a 64-bit fingerprint where similar documents produce similar fingerprints. Two pages are considered near-duplicates if their SimHash values differ by fewer than 3 bits (Hamming distance ≤ 3). This catches pages that are 95%+ similar — different ads, timestamps, or navigation elements don't fool it.
function computeSimHash(htmlContent: string): bigint {
// 1. Extract text content (strip HTML tags, scripts, styles)
const text = extractVisibleText(htmlContent);
// 2. Tokenize into shingles (overlapping n-grams)
const shingles = generateShingles(text, 3); // 3-word shingles
// 3. Hash each shingle and accumulate weighted bit vector
const vector = new Array(64).fill(0);
for (const shingle of shingles) {
const hash = xxhash64(shingle);
for (let bit = 0; bit < 64; bit++) {
vector[bit] += (hash >> BigInt(bit)) & 1n ? 1 : -1;
}
}
// 4. Convert to binary fingerprint
let fingerprint = 0n;
for (let bit = 0; bit < 64; bit++) {
if (vector[bit] > 0) fingerprint |= (1n << BigInt(bit));
}
return fingerprint;
}
function isNearDuplicate(hash1: bigint, hash2: bigint): boolean {
// Hamming distance: count differing bits
const xor = hash1 ^ hash2;
let distance = 0;
let bits = xor;
while (bits > 0n) {
distance++;
bits &= bits - 1n; // Clear lowest set bit
}
return distance <= 3; // ≤3 bits different = near-duplicate
}
| Technique | Catches | Misses | Cost |
|---|---|---|---|
| Exact hash (SHA-256) | Byte-for-byte identical pages | Pages differing by 1 character (ads, timestamps) | O(n) compute, 32 bytes storage per page |
| SimHash (chosen) | Pages with 95%+ text similarity | Pages with same structure but different content | O(n) compute, 8 bytes storage per page |
| MinHash + LSH | Pages with configurable similarity threshold | Very short pages (not enough shingles) | O(n) compute, 100+ bytes per page (multiple hashes) |
💡 Interview tip: explain the layering
"We use three layers of dedup: URL normalization catches the same page at different URLs. Bloom filter catches URLs we've already fetched. SimHash catches different URLs serving the same content. Each layer is cheaper than the next — normalization is free, Bloom filter is nanoseconds, SimHash requires fetching the page first. We want to reject as early as possible."
Crawl Scheduling & Freshness
A crawler doesn't just discover new pages — it must keep existing pages fresh. The internet changes constantly: news sites update hourly, e-commerce prices change daily, and blog archives haven't changed in years. The scheduler decides when to re-crawl each URL, balancing freshness against the finite crawl budget. This is the optimization problem at the heart of every production crawler.
Bad: Fixed interval re-crawl
Re-crawl every URL every N days regardless of how often it changes. This wastes budget on static pages and under-serves dynamic ones. If N=7 days and you have 1B URLs, you need 1.65K pages/sec just for re-crawls — leaving almost no budget for new discovery.
Strategy: Re-crawl every URL every 7 days
Budget consumed by re-crawl:
1B URLs / (7 × 86,400 sec) = 1,653 pages/sec
Total budget: 1,200 pages/sec
→ Re-crawl alone EXCEEDS our total capacity!
Even at 14 days:
1B URLs / (14 × 86,400 sec) = 827 pages/sec
→ 69% of budget on re-crawl, only 31% for new discovery
The real problem:
- 80% of pages haven't changed since last crawl (wasted fetches)
- 5% of pages change hourly but only get checked weekly (stale)
- No differentiation between nytimes.com/breaking and archive.org/1998
Good: Change-frequency-based scheduling
Track how often each page actually changes (by comparing content hashes across crawls). Pages that change frequently get shorter re-crawl intervals; pages that never change get pushed to monthly or longer. This is a simple exponential backoff/speedup model.
function computeRecrawlInterval(url: UrlMetadata): number {
const { lastCrawled, lastChanged, crawlCount, changeCount } = url;
// Change rate: what fraction of crawls found changes?
const changeRate = changeCount / Math.max(crawlCount, 1);
// Base interval from change rate
let intervalHours: number;
if (changeRate > 0.8) intervalHours = 6; // Changes almost every time → 6h
else if (changeRate > 0.5) intervalHours = 24; // Changes often → daily
else if (changeRate > 0.2) intervalHours = 72; // Changes sometimes → 3 days
else if (changeRate > 0.05) intervalHours = 168; // Rarely changes → weekly
else intervalHours = 720; // Almost never → monthly
// Decay: if we haven't seen a change in a while, slow down
const hoursSinceChange = (Date.now() - lastChanged) / 3_600_000;
if (hoursSinceChange > intervalHours * 3) {
intervalHours = Math.min(intervalHours * 2, 720); // Double, cap at 30 days
}
return intervalHours;
}
// Result: news homepages crawled every 6h, blog archives every 30 days
// Budget savings: ~60% fewer re-crawls vs fixed interval
Optimal: Multi-signal adaptive scheduling with budget allocation
The production approach combines change frequency with page importance and explicitly allocates the crawl budget across tiers. Instead of computing intervals per-URL independently, the scheduler treats the crawl budget as a finite resource and optimizes for maximum freshness-weighted coverage.
// The scheduler allocates the total crawl budget across priority tiers
const TOTAL_BUDGET_PER_SEC = 1200;
const TIER_ALLOCATION = {
CRITICAL: 0.30, // 360/sec — homepages, news, sitemaps
HIGH: 0.25, // 300/sec — frequently changing, high PageRank
MEDIUM: 0.20, // 240/sec — moderate change rate
LOW: 0.15, // 180/sec — rarely changing pages
DISCOVERY: 0.10, // 120/sec — brand new URLs never crawled before
};
function computePriority(url: UrlMetadata): { tier: Tier; score: number } {
// Multi-signal scoring
const importanceScore =
0.35 * url.pageRank + // Authority
0.25 * url.changeFrequency + // How often it changes
0.20 * freshnessDecay(url.lastCrawled) + // How stale our copy is
0.10 * url.inboundLinkCount / MAX_LINKS + // Popularity
0.10 * (url.isHomepage ? 1 : 0); // Homepages always matter
// Assign to tier based on score
const tier = importanceScore > 0.7 ? "CRITICAL"
: importanceScore > 0.5 ? "HIGH"
: importanceScore > 0.3 ? "MEDIUM"
: "LOW";
return { tier, score: importanceScore };
}
// Freshness decay: exponential — urgency grows over time
function freshnessDecay(lastCrawledMs: number): number {
const hoursSinceCrawl = (Date.now() - lastCrawledMs) / 3_600_000;
const expectedInterval = getExpectedInterval(url); // From change history
return 1 - Math.exp(-hoursSinceCrawl / expectedInterval);
// Returns 0 when just crawled, approaches 1 as staleness grows
}
Leveraging HTTP caching headers
Smart crawlers use HTTP headers to avoid re-downloading unchanged content. The pattern sends the stored ETag or Last-Modified value with the request. If the server responds 304, we know the page hasn't changed — no download, no parsing, no storage write. Just update the "last checked" timestamp.
First crawl:
GET /article/123 HTTP/1.1
Host: example.com
Response:
200 OK
ETag: "abc123"
Last-Modified: Sat, 25 May 2026 10:00:00 GMT
Content-Length: 45000
[... 45KB of HTML ...]
Re-crawl (conditional):
GET /article/123 HTTP/1.1
Host: example.com
If-None-Match: "abc123"
If-Modified-Since: Sat, 25 May 2026 10:00:00 GMT
Response (unchanged):
304 Not Modified
[no body — saves 45KB of bandwidth]
Response (changed):
200 OK
ETag: "def456"
[... new content ...]
Savings at scale:
If 80% of re-crawls return 304:
→ 80% × 500KB avg page = 400KB saved per re-crawl
→ At 800 re-crawls/sec: 320 MB/sec bandwidth saved
→ ~2.5 Gbps saved — more than our total inbound capacity!
| Strategy | Budget efficiency | Freshness quality | Complexity |
|---|---|---|---|
| Fixed interval (7 days) | Poor — wastes 80% on unchanged pages | Poor — dynamic pages are stale for days | Trivial |
| Change-frequency adaptive | Good — 60% fewer wasted fetches | Good — dynamic pages checked more often | Moderate — needs change history tracking |
| Multi-signal + budget allocation (chosen) | Excellent — budget optimized for max freshness | Excellent — importance × staleness drives priority | High — needs scoring pipeline + tier management |
| Conditional GET (complementary) | Excellent — 80% of re-crawls cost zero bandwidth | Perfect — checks without downloading | Low — just store ETag/Last-Modified per URL |
🔑 The freshness-budget trade-off
You can never keep the entire web fresh. The question is: given a fixed crawl budget of 1,200 pages/sec, which pages should be fresh RIGHT NOW? The answer is always importance × staleness. A stale copy of nytimes.com is worse than a stale copy of a 2015 blog post. The scheduler's job is to maximize the sum of (importance × freshness) across all known URLs.
Fault Tolerance & Retries
A crawler running 24/7 at 1,200 pages/sec will encounter every possible failure mode: DNS timeouts, connection resets, HTTP 5xx errors, malformed HTML, disk full, OOM kills, network partitions, and machine crashes. The system must handle all of these gracefully without losing crawl progress or getting stuck in retry storms.
Failure categories and handling
Not all failures are equal. Some are transient (retry will succeed), some are permanent (retry is pointless), and some are ambiguous (might succeed later). The retry strategy must distinguish between these to avoid wasting budget on hopeless URLs.
TRANSIENT (retry with backoff):
- DNS timeout → retry after 30s (DNS might be overloaded)
- TCP connection reset → retry after 60s (server might be restarting)
- HTTP 500/502/503 → retry after 5min (server error, likely temporary)
- HTTP 429 Too Many Requests → respect Retry-After header, or backoff 10min
- Network timeout → retry after 2min (congestion or slow server)
PERMANENT (do not retry):
- HTTP 404 Not Found → mark URL as dead, remove from frontier
- HTTP 410 Gone → mark URL as permanently removed
- DNS NXDOMAIN → domain doesn't exist, blocklist
- robots.txt disallow → respect indefinitely (check again in 24h)
- Content too large → skip (>10MB is likely not a web page)
- Invalid SSL cert → skip (security risk)
AMBIGUOUS (limited retries):
- HTTP 403 Forbidden → might be rate-limited, retry 2× then give up
- Connection refused → server might be down, retry 3× over 24h
- Redirect loop → mark as broken after 5 redirects
- Malformed response → retry once, then mark as unparseable
Retry strategy
The retry mechanism uses to avoid overwhelming recovering servers. Each URL has a retry counter and a maximum retry limit. After exhausting retries, the URL is marked as permanently failed and removed from the frontier.
function computeRetryDelay(retryCount: number, baseDelay: number): number {
// Exponential: 1min, 2min, 4min, 8min, 16min, 32min (cap)
const exponentialDelay = baseDelay * Math.pow(2, retryCount);
const cappedDelay = Math.min(exponentialDelay, 32 * 60 * 1000); // 32 min max
// Jitter: ±25% randomization to prevent thundering herd
const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
return cappedDelay + jitter;
}
// Retry budget per URL
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 60_000; // 1 minute
// After 5 retries: 1min + 2min + 4min + 8min + 16min = 31 min total wait
// If still failing after 31 minutes → mark as dead, move on
Checkpoint and recovery
The crawler must survive machine crashes without losing significant progress. The key insight: the Frontier is the only stateful component that's hard to reconstruct. Fetchers and Parsers are stateless — losing one just means re-processing a few pages. But losing the Frontier means losing billions of URL priorities and scheduling state.
FRONTIER STATE TO PRESERVE:
- URL queue contents (2B entries, ~200 GB on disk)
- Per-domain scheduling metadata (50M domains, ~2 GB)
- Bloom filter state (12 GB)
- In-flight tasks (currently being fetched)
CHECKPOINT STRATEGY:
1. RocksDB WAL (Write-Ahead Log):
- Every enqueue/dequeue is logged before execution
- On crash: replay WAL from last checkpoint
- Checkpoint interval: every 5 minutes
- WAL size between checkpoints: ~500 MB
2. Bloom filter snapshots:
- Serialize to disk every hour (12 GB write)
- On crash: load last snapshot + replay URL discoveries from WAL
- Acceptable: some URLs re-fetched (Bloom filter slightly stale)
3. In-flight task recovery:
- Tasks dispatched to fetchers have a "lease" timeout (5 min)
- If fetcher doesn't ACK within lease → task returns to queue
- Duplicate fetches are harmless (idempotent operation)
RECOVERY TIME:
- RocksDB restart: ~30 seconds (replay WAL)
- Bloom filter reload: ~60 seconds (mmap from disk)
- Total recovery: < 2 minutes
- Pages lost during crash: ~2 min × 1,200/sec = ~144,000 pages
(re-discovered on next crawl cycle — acceptable)
Distributed failure handling
With the Frontier partitioned across multiple machines, we need to handle partition failures without losing the entire crawl. Each partition is replicated to a standby that takes over on failure.
| Component | Failure impact | Recovery mechanism | Data loss |
|---|---|---|---|
| Fetcher worker crash | ~500 in-flight tasks orphaned | Lease timeout → tasks return to queue automatically | Zero — tasks are retried |
| Parser crash | ~100 unparsed fetch results | Results re-read from queue (at-least-once delivery) | Zero — results are re-processed |
| Frontier partition crash | 1/N of domains temporarily uncrawlable | Standby replica promotes; replays WAL from last checkpoint | < 5 min of enqueue operations (from WAL gap) |
| DNS cache crash | All fetches slow down (cache miss → external DNS) | Cache rebuilds organically from fetcher lookups | Zero — just temporary latency increase |
| Object storage unavailable | Content can't be persisted | Buffer in local disk; flush when storage recovers | Zero if buffer doesn't overflow; oldest pages dropped if it does |
| Full network partition | Entire crawl stops | Crawl resumes from checkpoint when network recovers | Duration of partition × crawl rate (re-crawled later) |
Fault tolerance design principles
- ✅Every dispatched task has a lease timeout — no task is ever permanently lost
- ✅All state changes are WAL-logged before execution (crash-safe)
- ✅Fetchers and Parsers are stateless — crash and restart with zero coordination
- ✅Bloom filter staleness causes duplicate work, not data loss (safe degradation)
- ✅Duplicate fetches are idempotent — re-fetching a page is wasteful but not harmful
- ✅Circuit breaker per domain — stop retrying domains that are consistently failing
💡 What to tell the interviewer
"The crawler is designed for crash-only recovery. Every component can be killed at any time and restarted without coordination. The worst case is duplicate work (re-fetching pages), never data loss or stuck state. This is possible because fetching a page is inherently idempotent — doing it twice is wasteful but correct."
Handling Dynamic Content
The modern web is increasingly JavaScript-heavy. Single-page applications (React, Angular, Vue) render content client-side — the initial HTML is often just a skeleton with a <div id="root"></div> and a bundle of JS. A traditional crawler that only fetches raw HTML sees an empty page. Handling this requires a fundamentally different fetching strategy — and it's 10-50× more expensive than static HTML fetching.
Bad: Render every page with a headless browser
The naive approach is to run every URL through a headless browser (Puppeteer/Playwright). This guarantees you see the fully-rendered page, but the cost is catastrophic at scale.
Cost of headless browser rendering:
- Memory: ~200 MB per browser tab (Chrome)
- CPU: 2-5 seconds to render a JS-heavy page
- Throughput: ~0.3 pages/sec per tab
To hit 1,200 pages/sec with rendering:
- Need 4,000 concurrent browser tabs
- Memory: 4,000 × 200 MB = 800 GB RAM
- Machines: ~25 machines with 32 GB each (just for rendering)
- Cost: ~$50K/month in compute alone
vs. static HTML fetching:
- Memory: ~5 MB per concurrent connection
- CPU: negligible (just network I/O)
- Throughput: 500+ pages/sec per machine
- Cost: ~$2K/month for the same throughput
Reality: only ~15-20% of the web requires JS rendering.
Rendering everything wastes 80% of your budget on pages that don't need it.
Good: Detect-then-render (two-pass approach)
First fetch the raw HTML. If it contains meaningful content (text, links, metadata), process it normally. If it looks like a JS shell (minimal text, heavy script tags, framework markers), route it to the rendering pool. This way, only pages that actually need rendering pay the cost.
function needsRendering(html: string, url: string): boolean {
// Heuristic 1: Very little visible text relative to HTML size
const visibleText = extractVisibleText(html);
const textRatio = visibleText.length / html.length;
if (textRatio < 0.05) return true; // Less than 5% text → likely JS-rendered
// Heuristic 2: Framework markers
const frameworkMarkers = [
'id="root"', // React
'id="app"', // Vue
'id="__next"', // Next.js (but SSR pages are fine)
'ng-app', // Angular
'data-reactroot', // React
];
const hasFramework = frameworkMarkers.some(marker => html.includes(marker));
// Heuristic 3: Very few outgoing links extracted
const linkCount = (html.match(/<as+[^>]*href/gi) || []).length;
if (hasFramework && linkCount < 3) return true;
// Heuristic 4: Known JS-heavy domains (learned from history)
if (isKnownJSDomain(url)) return true;
return false;
}
Optimal: Tiered rendering with domain-level learning
The production approach learns which domains need rendering and which don't. After crawling a few pages from a domain, the system classifies the entire domain as "static" or "JS-rendered" and routes all future fetches accordingly. This avoids per-page detection overhead and allows pre-allocation of rendering resources.
// Domain classification (updated after every crawl batch)
enum RenderingTier {
STATIC = "static", // Raw HTML has full content — no rendering needed
PARTIAL_JS = "partial_js", // Some pages need rendering, some don't
FULL_JS = "full_js", // All pages require rendering
}
class RenderingRouter {
// Learned classification per domain (persisted)
private domainTier: Map<string, {
tier: RenderingTier;
confidence: number; // 0-1, based on sample size
sampleSize: number;
lastUpdated: number;
}>;
// Rendering pool: shared headless browser instances
private renderPool: BrowserPool; // 200 tabs across 10 machines
async route(task: CrawlTask, rawHtml: string): Promise<string> {
const domain = this.domainTier.get(task.domainId);
if (domain?.tier === "STATIC" && domain.confidence > 0.9) {
return rawHtml; // Skip rendering — domain is known static
}
if (domain?.tier === "FULL_JS" && domain.confidence > 0.9) {
return await this.renderPool.render(task.url, {
waitUntil: "networkidle",
timeout: 10_000,
blockResources: ["image", "font", "media"], // Save bandwidth
});
}
// Unknown or partial — use per-page heuristic
if (needsRendering(rawHtml, task.url)) {
const rendered = await this.renderPool.render(task.url, {
waitUntil: "networkidle",
timeout: 10_000,
});
this.updateDomainClassification(task.domainId, true);
return rendered;
}
this.updateDomainClassification(task.domainId, false);
return rawHtml;
}
}
Rendering pool design
The is a separate service that accepts render requests and returns fully-rendered HTML. It manages browser lifecycle, tab recycling, memory limits, and crash recovery.
RENDERING POOL SIZING:
Target: render 200 pages/sec (15-20% of total crawl rate)
Avg render time: 5 seconds per page
Concurrent tabs needed: 200 × 5 = 1,000 tabs
Per machine (32 GB RAM):
- 50 tabs × 200 MB = 10 GB for browsers
- 22 GB for OS + buffers
- Throughput: 50 / 5 = 10 pages/sec per machine
Fleet: 1,000 / 50 = 20 machines for rendering
OPTIMIZATIONS:
1. Block unnecessary resources:
- Images, fonts, media → don't load (saves 60% bandwidth)
- Third-party scripts (analytics, ads) → block
- Only load first-party JS needed for content
2. Tab recycling:
- Reuse tabs across renders (avoid cold-start overhead)
- Kill and recreate tabs every 50 renders (prevent memory leaks)
3. Timeout and abort:
- Hard timeout: 10 seconds (if not rendered by then, use raw HTML)
- Network idle detection: stop waiting once no requests for 500ms
4. Caching:
- Cache rendered output for pages that rarely change
- Share browser cache across tabs for same-domain resources
| Approach | Cost (for 1,200 pages/sec) | Coverage | Complexity |
|---|---|---|---|
| Render everything | ~$50K/month (800 GB RAM) | 100% — sees all content | High — massive browser fleet management |
| Never render (static only) | ~$2K/month | ~80% — misses JS-rendered content | Low — simple HTTP fetching |
| Detect-then-render (per page) | ~$12K/month | ~95% — catches most JS pages | Medium — detection heuristics + render pool |
| Domain-level learning (chosen) | ~$10K/month | ~97% — domain classification is accurate | Medium-high — learning pipeline + render pool |
🔑 The 80/20 of rendering
Google's crawler (Googlebot) uses a two-wave approach: first crawl fetches raw HTML and indexes what it can. A second wave renders JS-heavy pages hours or days later. This is acceptable because most content that matters for search is in the initial HTML (titles, headings, meta tags). The JS rendering catches the rest on a delayed schedule. For an interview, mentioning this two-wave approach shows awareness of real-world trade-offs.
Monitoring & Observability
A crawler running at 1,200 pages/sec generates enormous operational surface area. Without proper observability, you won't know if you're crawling garbage, getting blocked by half the internet, or slowly filling disk until the system crashes. The monitoring system must answer three questions at all times: "Are we crawling fast enough?", "Are we crawling the right things?", and "Is anything broken?"
Key metrics to track
These are the metrics that should be on the primary dashboard — the ones an on-call engineer checks first when something feels wrong.
THROUGHPUT METRICS:
pages_fetched_per_sec — Are we hitting our 1,200/sec target?
pages_parsed_per_sec — Is the parser keeping up with fetchers?
urls_discovered_per_sec — Is the crawl still finding new content?
urls_enqueued_per_sec — Is the frontier growing or shrinking?
ERROR METRICS:
fetch_error_rate — % of fetches that fail (target: <5%)
dns_failure_rate — DNS resolution failures (target: <0.1%)
timeout_rate — Fetches that exceed deadline (target: <3%)
http_4xx_rate — Client errors (404s, 403s)
http_5xx_rate — Server errors (indicates target site issues)
robots_blocked_rate — URLs rejected by robots.txt
QUEUE HEALTH:
frontier_depth — Total pending URLs (should be stable, not growing unbounded)
frontier_oldest_task_age — How long has the oldest URL been waiting?
in_flight_tasks — Currently being fetched (should match worker count)
retry_queue_depth — URLs waiting for retry (growing = problem)
RESOURCE METRICS:
bloom_filter_fill_ratio — How full is the Bloom filter? (>80% = time to resize)
content_store_write_rate — MB/sec to object storage
dns_cache_hit_rate — Should be >90% (below = new domain explosion)
connection_pool_utilization — Are we reusing connections effectively?
FRESHNESS METRICS:
avg_page_staleness_hours — How old is our average cached page?
recrawl_304_rate — % of re-crawls that return "not modified"
pages_changed_on_recrawl — % of re-crawls that found new content
Alerting thresholds
Not every metric deviation is an emergency. The alerting system should distinguish between "investigate when convenient" and "wake someone up at 3 AM."
| Alert | Threshold | Severity | Action |
|---|---|---|---|
| Crawl rate drop | < 800 pages/sec for 10 min | Critical | Check fetcher fleet health, network, DNS |
| Error rate spike | > 10% fetch failures for 5 min | Critical | Check if a major CDN/host is blocking us |
| Frontier growing unbounded | > 5B pending URLs | Warning | Discovery outpacing fetch rate; adjust priority thresholds |
| Bloom filter > 85% full | Fill ratio > 0.85 | Warning | Plan Bloom filter resize or rebuild with larger capacity |
| DNS cache hit rate drop | < 80% for 30 min | Warning | Crawling many new domains; check if it's intentional |
| Object storage write failures | Any failures for 2 min | Critical | Content being lost; pause crawl or buffer to local disk |
| Single domain > 50% of traffic | Domain concentration > 50% | Warning | Possible crawl trap; investigate and potentially blocklist |
Crawl quality metrics
Beyond operational health, we need to measure whether the crawler is doing useful work. High throughput means nothing if we're crawling spam, duplicates, or irrelevant content.
CONTENT QUALITY:
duplicate_content_rate — % of fetched pages that are near-duplicates
(target: <5%; higher = dedup is failing)
empty_page_rate — Pages with <100 chars of visible text
(target: <2%; higher = crawling JS shells without rendering)
language_distribution — Are we crawling the languages we want?
domain_diversity — How many unique domains per hour?
(low = stuck on a few sites)
CRAWL TRAP DETECTION:
urls_per_domain_histogram — Flag domains with >100K pending URLs
url_depth_distribution — Flag URLs with path depth > 10
url_pattern_entropy — Low entropy = generated URLs (calendar traps)
pages_with_no_outlinks — Dead ends (might be error pages)
FRESHNESS QUALITY:
time_to_first_crawl — How quickly do we crawl newly discovered URLs?
(target: <4 hours for high-priority)
recrawl_found_change_rate — When we re-crawl, how often was it worth it?
(target: >20%; lower = wasting budget on static pages)
important_page_staleness — Max staleness for top-1000 domains
(target: <24 hours)
Observability best practices for crawlers
- ✅Log every fetch with URL, status, latency, and content hash (structured logs)
- ✅Sample 1% of fetched pages for manual quality review weekly
- ✅Track per-domain error rates — a single domain's issues shouldn't hide in aggregates
- ✅Dashboard showing real-time crawl map (domains being crawled right now)
- ✅Automated crawl trap detection: alert when a domain generates >10K URLs/hour
- ✅Weekly freshness report: what % of known important pages are <24h old?
💡 Interview tip: mention observability proactively
Most candidates never mention monitoring. Bringing it up unprompted — "I'd want dashboards showing crawl rate, error rate by domain, frontier depth, and freshness distribution" — signals production experience. It shows you've operated systems, not just designed them on whiteboards.
Trade-offs Consolidated
Every decision in this design was a trade. A web crawler lives in constant tension between speed and politeness, freshness and efficiency, completeness and cost. Bundling the trade-offs in one place gives a candidate a compact story to walk through at the end of the interview.
| Decision | We picked | Why | What we gave up |
|---|---|---|---|
| Frontier architecture | Distributed + disk-backed (RocksDB) | Scales to billions of URLs; survives crashes via WAL | Complexity of partition management; slower than pure in-memory |
| URL dedup | Bloom filter + persistent hash store | 12 GB RAM for 10B URLs; O(1) lookup; zero false negatives | 1% false positives (some new URLs skipped); needs periodic rebuild |
| Content dedup | SimHash (Hamming distance ≤ 3) | Catches 95%+ near-duplicates in O(1) comparison | Misses structural duplicates with different text; 8 bytes per page stored |
| Fetcher model | Async I/O with domain-aware routing | 500+ concurrent connections per process; connection reuse | More complex than thread-per-request; domain affinity adds routing logic |
| Politeness enforcement | Per-domain token bucket + robots.txt | Never overwhelms any single server; legally compliant | Reduces effective throughput; popular domains crawled slowly |
| Re-crawl scheduling | Multi-signal adaptive with budget allocation | Maximizes freshness per crawl-budget unit | Requires change history tracking; scoring function needs tuning |
| JS rendering | Domain-level classification + render pool | Only 15-20% of pages rendered; 5× cheaper than render-all | ~3% of JS pages missed; classification needs periodic retraining |
| Content storage | Object storage (S3) + metadata DB | Cheap, durable, append-only; metadata queryable separately | Higher latency than local disk; egress costs for downstream consumers |
| Fault tolerance | WAL + lease-based task recovery | < 2 min recovery; no task permanently lost | Duplicate fetches on crash (wasteful but correct); WAL adds write overhead |
| DNS resolution | Local cache with aggressive TTL extension | 90%+ cache hit rate; reduces external DNS from 1,200/sec to ~100/sec | Stale DNS entries may route to old IPs; need TTL floor of 5 min |
The fundamental tensions
💬 Speed vs Politeness
We could crawl 10× faster if we ignored rate limits. But getting IP-banned by major sites means we can't crawl them at all. Politeness is a long-term investment — a crawler that respects limits today can still crawl tomorrow. The optimal point is: crawl as fast as each domain allows, never faster.
💬 Freshness vs Coverage
Every page we re-crawl is a page we could have discovered for the first time. With a fixed budget of 1,200/sec, spending 70% on re-crawls means only 30% for new discovery. The right split depends on the use case: a news search engine favors freshness; a general search engine favors coverage.
💬 Completeness vs Cost
Rendering every JS page gives 100% content coverage but costs 25× more than static fetching. The 80/20 rule applies: 80% of useful content is in static HTML. The remaining 20% costs disproportionately more to capture. For most use cases, 95% coverage at 5× cost beats 100% coverage at 25× cost.
💬 Memory vs Accuracy (Bloom filter)
A larger Bloom filter means fewer false positives (fewer new URLs incorrectly skipped). But memory is finite. At 1% FP rate, we skip ~10M genuinely new URLs per billion checked. Those URLs will be re-discovered on the next crawl cycle — so the cost is delayed discovery, not permanent loss. Acceptable trade-off.
🎯 The trade-off that defines seniority
The biggest divide between junior and senior answers is whether the candidate can articulate the tension between freshness and politeness. "We want to re-crawl nytimes.com every hour, but robots.txt says Crawl-delay: 2 seconds. With 500K pages on nytimes.com, crawling all of them at 0.5 req/sec takes 11.5 days. So we prioritize: homepage and top stories every hour, section pages daily, archives monthly." That reasoning is what separates a senior answer from a textbook one.
Evolution Path
No one builds a billion-page crawler on day one. The strongest interview answers start simple and evolve the design as scale demands it. Each evolution step is triggered by a specific bottleneck — not by premature optimization. Walking through this progression shows the interviewer you understand when complexity is justified.
Stage 1: Single-machine crawler (~10K pages/day)
A single Python script with a queue, an HTTP client, and a SQLite database. Good enough for crawling a single domain or a small set of seed URLs. This is what you build in a weekend.
In-memory queue
Python list
requests.get()
Sync HTTP
BeautifulSoup
Parse HTML
SQLite
Store URLs + content
Capacity: ~0.1 pages/sec (sync, single-threaded)
Storage: SQLite handles ~1M rows before slowing down
Memory: Queue grows unbounded if discovery > fetch rate
Breaking points:
→ At 100K URLs in queue: memory pressure
→ At 1M stored pages: SQLite writes slow to 10/sec
→ At 10 pages/sec target: need async I/O (single thread can't keep up)
→ At multiple domains: need politeness (currently hammers one domain)
Trigger to evolve: "I need more than 10K pages/day"
Stage 2: Multi-threaded with politeness (~1M pages/day)
Add async I/O (aiohttp or Go goroutines), a proper priority queue, per-domain rate limiting, and Postgres for metadata. Still runs on a single beefy machine but handles 10-50 pages/sec.
Priority queue
Heap + domain buckets
Async fetchers (50)
aiohttp / Go
DNS cache
Local unbound
Parser pool
4 CPU cores
Postgres
URL metadata
Local disk
Raw HTML files
Capacity: ~10-50 pages/sec
Storage: Postgres handles 100M rows; local disk holds ~10 TB
Memory: 32 GB handles frontier of ~50M URLs
Breaking points:
→ At 50M frontier URLs: need disk-backed queue (RAM limit)
→ At 100 pages/sec: single machine's network saturated (~50 MB/sec)
→ At 10B seen URLs: Bloom filter needs 12 GB (still fits, but tight)
→ At 100M stored pages: local disk I/O becomes bottleneck
Trigger to evolve: "I need more than 1M pages/day, or I need fault tolerance"
Stage 3: Distributed crawler (~100M pages/month)
Distribute the frontier across multiple machines (partitioned by domain hash). Fetchers become a separate stateless fleet. Content goes to object storage. Message queues connect components. This is where the architecture from our deep dives kicks in.
Frontier (5 partitions)
RocksDB + heap
Task queue (Kafka)
Durable dispatch
Fetcher fleet (20)
Async, domain-aware
Result queue
Fetch results
Parser fleet (10)
Stateless
S3 + Postgres
Content + metadata
Capacity: ~400 pages/sec (100M/month)
Storage: S3 handles unlimited; Postgres sharded for metadata
Frontier: 5 partitions × 64 GB = handles 2B URLs
Breaking points:
→ At 1,200 pages/sec: need 50+ fetcher machines
→ At 10B URLs: Bloom filter needs distributed or partitioned approach
→ At 50M domains: robots.txt cache needs its own service
→ At multi-region: need geo-local fetching for latency
Trigger to evolve: "I need 1B pages/month with global coverage"
Stage 4: Internet-scale crawler (~1B+ pages/month)
The full architecture described in this problem. Distributed frontier with replication, domain-aware fetcher fleet with adaptive throttling, rendering pool for JS pages, multi-signal scheduling, and comprehensive observability. This is Googlebot-class.
| Dimension | Stage 1 | Stage 2 | Stage 3 | Stage 4 |
|---|---|---|---|---|
| Pages/sec | 0.1 | 10-50 | ~400 | 1,200+ |
| Machines | 1 | 1 (beefy) | ~35 | ~100+ |
| Frontier | Python list | In-memory heap | RocksDB (5 partitions) | RocksDB (20+ partitions, replicated) |
| Storage | SQLite | Postgres + local disk | S3 + sharded Postgres | S3 + Cassandra/DynamoDB |
| Dedup | Python set | Bloom filter (single) | Bloom filter + RocksDB | Distributed Bloom + SimHash |
| Politeness | time.sleep(1) | Per-domain token bucket | Distributed rate limiter | Adaptive throttling + robots.txt service |
| JS rendering | None | None | Optional render pool | Domain-classified render fleet (20 machines) |
| Fault tolerance | None (restart from scratch) | Postgres is durable | WAL + lease-based recovery | Replicated frontier + auto-failover |
Evolution triggers — when to add complexity
Each evolution step is triggered by a specific, measurable bottleneck. Never add complexity preemptively.
TRIGGER → ACTION
"Queue doesn't fit in RAM"
→ Move to disk-backed queue (RocksDB/LevelDB)
"Single machine's network is saturated"
→ Distribute fetchers across multiple machines
"Losing crawl progress on crashes"
→ Add WAL to frontier; lease-based task recovery
"Getting IP-banned by major sites"
→ Add per-domain rate limiting; respect robots.txt Crawl-delay
"Re-crawling pages that haven't changed"
→ Add conditional GET (If-Modified-Since / ETag)
→ Add change-frequency tracking for adaptive scheduling
"Missing content on JS-heavy sites"
→ Add headless browser rendering pool (start with top-100 JS domains)
"Can't tell if crawler is healthy"
→ Add metrics pipeline (pages/sec, error rate, freshness)
"Single frontier machine is SPOF"
→ Partition frontier; add standby replicas
"DNS is the bottleneck"
→ Deploy local DNS cache with aggressive TTL extension
💡 How to present this in an interview
Start with Stage 2 (single machine, async, polite) and say: "This handles our first 1M pages/day. When we hit [specific bottleneck], we evolve to [next stage]." Then walk through Stage 3 and 4 as the interviewer asks "what if we need more?" This shows you don't over-engineer from the start — you add complexity only when the simple design breaks.
Follow-ups & Common Traps
The last 10 minutes of the interview are where candidates separate. The interviewer probes edge cases, failure modes, and real-world operational challenges. These are the questions worth pre-loading — each one tests whether you've thought beyond the happy path.
Curveball follow-ups
Q:How do you detect and escape crawl traps (infinite URL generation)?
A: Crawl traps are pages that generate infinite unique URLs — calendars (/2026/01/01, /2026/01/02, ...), session-based URLs, or query parameter combinatorics. Detection: (1) cap URL depth at 15 levels, (2) cap URLs per domain at 100K in the frontier, (3) detect low-entropy URL patterns (regex matching repeated structures), (4) track 'new content per fetch' ratio — if a domain generates 1000 URLs but each page has <5% unique content, it's a trap. Escape: blocklist the URL pattern, not the entire domain.
Q:A major site (Wikipedia) has 60M pages. How do you crawl it without violating politeness?
A: At 1 req/sec (typical Crawl-delay), crawling 60M pages takes 694 days. You can't crawl all of Wikipedia in one pass. Solution: prioritize. Crawl the top 1M pages (by PageRank/inbound links) first — these cover 95% of search traffic. Re-crawl the top 10K daily, top 100K weekly, rest monthly. Use sitemaps (Wikipedia provides them) to discover pages without crawling. Use conditional GET to check freshness without downloading. Effective coverage: 99% of useful content with 5% of the full crawl budget.
Q:How do you handle sites that serve different content to crawlers vs browsers (cloaking)?
A: Cloaking detection: periodically fetch the same URL with both a bot User-Agent and a browser User-Agent. Compare content hashes — if they differ significantly (SimHash distance > 10), flag the domain for review. For known cloakers: use the headless browser rendering path (which executes JS and looks like a real browser). Note: some cloaking is legitimate (serving simplified HTML to bots for performance). Only flag when the content semantics differ, not just the presentation.
Q:How would you add multi-region crawling?
A: Deploy fetcher fleets in multiple regions (US-East, EU-West, Asia-Pacific). Route crawl tasks to the region closest to the target domain's servers — reduces fetch latency from 200ms to 20ms for geo-local requests. The Frontier remains centralized (or uses a single leader partition per domain) to avoid duplicate crawls. Cross-region coordination: each region reports fetched URLs back to the central dedup service. Benefit: 3-5× latency reduction for international sites, plus some sites block non-local IPs.
Q:The Bloom filter is 85% full. What do you do?
A: Three options: (1) Rebuild with 2× capacity — create a new 24 GB filter, replay all URL hashes from the persistent store, swap atomically. Downtime: ~10 minutes. (2) Use a counting Bloom filter that supports deletion — remove URLs we know are dead (404/410). (3) Use a cascading Bloom filter: when the primary fills up, create a secondary for new URLs. Check both on lookup. Option 1 is simplest and preferred — schedule it during low-traffic hours.
Q:How do you handle pages behind authentication (login walls)?
A: Generally, don't. Ethical crawlers respect that authenticated content is private. For sites that offer crawler-specific access (e.g., news sites with a 'first click free' policy or API keys for bots), configure per-domain credentials in the fetcher. Store credentials encrypted, rotate regularly. Never crawl content that requires user impersonation. For the interview: mention this as an out-of-scope decision and explain why — it's a policy choice, not an architecture one.
Q:What happens when a domain changes its robots.txt to disallow everything?
A: Respect it immediately. On the next robots.txt refresh (every 24h, or on first fetch failure), detect the change. Purge all pending URLs for that domain from the frontier. Mark existing stored content as 'robots-blocked' (don't serve it in search results). Don't delete the content — the site might re-allow crawling later. Log the event for operator review. This is both legally and ethically required.
Q:How do you prioritize newly discovered URLs vs re-crawls?
A: Budget allocation: dedicate 10-15% of crawl capacity exclusively to new URL discovery. This ensures the crawler keeps expanding its coverage even when re-crawl demand is high. Within the discovery budget, prioritize by: (1) domain authority of the source page, (2) anchor text relevance, (3) whether the domain is already known (new domains get a boost). The remaining 85-90% goes to re-crawls, prioritized by importance × staleness.
Q:A single fetcher machine is consuming 80% of your crawl budget on one domain. What's wrong?
A: Likely a crawl trap or a misconfigured domain affinity. Diagnosis: check the URL pattern — if it's generating infinite URLs (calendar, pagination, search facets), it's a trap. Fix: (1) cap per-domain fetch rate at 5% of total budget, (2) add URL pattern detection to identify generated URLs, (3) alert when any domain exceeds 10% of frontier depth. Prevention: the domain-aware fetcher routing should distribute load, but a single domain with millions of pages can still dominate if priority scoring is miscalibrated.
Q:How do you handle the 'soft 404' problem (pages that return 200 but show error content)?
A: Soft 404s are pages that return HTTP 200 but display 'page not found' or generic error content. Detection: (1) content similarity — if a page's SimHash matches the domain's known error page template, flag it. (2) Text classification — look for phrases like 'page not found', 'no results', 'this page doesn't exist'. (3) Outbound link analysis — error pages typically have very few unique outbound links. Action: mark as dead, don't store content, don't extract links. Google estimates 10-15% of the web returns soft 404s.
Common traps (and how to avoid them)
Ignoring crawl traps
Not implementing depth limits or per-domain URL caps. A single calendar page can generate infinite URLs and consume your entire crawl budget.
✅Cap URL depth at 15, cap per-domain frontier at 100K URLs, detect repetitive URL patterns, and monitor per-domain fetch ratios.
Designing for internet-scale from minute one
Reaching for Kafka, Cassandra, and 100-machine clusters before explaining why a single machine doesn't work.
✅Start with a single-machine design, identify where it breaks, and evolve. 'Postgres handles 1M URLs fine; we shard when writes exceed X/sec.'
Forgetting robots.txt entirely
Designing a crawler that ignores politeness. This is both an ethical failure and a practical one — you'll get IP-banned immediately.
✅Mention robots.txt compliance in the first 2 minutes. It's a hard requirement, not an optimization.
Storing everything in a database
Putting raw HTML content in Postgres/MySQL. At 120 TB/month, relational databases are the wrong tool for blob storage.
✅Object storage (S3/GCS) for content, database for metadata only. Separate the 'what did we fetch' (metadata) from 'what did it contain' (content).
No deduplication strategy
Fetching the same page from different URLs (with/without www, with tracking params, etc.) and storing it multiple times.
✅URL normalization before enqueue, Bloom filter for seen-URL check, SimHash for content dedup. Three layers, each catching different duplicates.
Fixed re-crawl intervals for all pages
Re-crawling every URL every 7 days regardless of change frequency. Wastes 80% of budget on pages that haven't changed.
✅Adaptive scheduling based on change history. Pages that change get crawled more often; static pages get crawled less. Use conditional GET to check without downloading.
Ignoring DNS as a bottleneck
Making a fresh DNS lookup for every fetch. At 1,200 URLs/sec, this overwhelms public DNS resolvers and adds 50-200ms per fetch.
✅Local DNS cache with 90%+ hit rate. Aggressive TTL extension (minimum 5 minutes). Multiple upstream resolvers for redundancy.
No mention of monitoring
Designing the system without explaining how you'd know if it's working correctly or degrading.
✅Mention key metrics proactively: crawl rate, error rate, frontier depth, freshness distribution. Shows production experience.
🔥 The deepest trap — conflating crawler with search engine
A web crawler is NOT a search engine. The crawler discovers and fetches pages. The search engine indexes, ranks, and serves queries. They're separate systems with separate interviews. If the interviewer asks "design a web crawler," don't spend time on inverted indexes, PageRank computation, or query serving. Mention that these are downstream consumers of the crawler's output, then stay focused on the crawl pipeline itself.
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale target: 1B pages/month → ~1,200 pages/sec sustained, ~2,000 peak.
Fetcher fleet: ~2,400 concurrent workers (async I/O). 50 machines × 50 connections each.
Storage: ~120 TB/month content (S3). ~500 GB/month URL metadata (Postgres/Cassandra).
Frontier: Distributed priority queue (RocksDB). 2B pending URLs. Partitioned by domain hash.
URL dedup: Bloom filter (12 GB, 10B capacity, 1% FP) + persistent hash store (RocksDB).
Content dedup: SimHash fingerprint (64-bit). Near-duplicate if Hamming distance ≤ 3.
Politeness: Per-domain token bucket (default 1 req/sec). Robots.txt compliance. Adaptive backoff.
Priority scoring: PageRank (0.35) + change frequency (0.25) + freshness decay (0.20) + depth (0.15) + recency (0.10).
Re-crawl strategy: Adaptive: news pages every 6h, normal pages weekly, archives monthly. Conditional GET for 304s.
JS rendering: Domain-classified render pool. Only 15-20% of pages need it. 20 machines, 1,000 tabs.
DNS: Local cache (unbound). 90%+ hit rate. Reduces external lookups from 1,200/sec to ~100/sec.
Fault tolerance: WAL + lease-based recovery. < 2 min recovery time. Duplicate fetches are idempotent.
Crawl traps: Depth cap (15), per-domain URL cap (100K), pattern detection, content uniqueness ratio.
Key insight: Frontier is a scheduler, not a queue. Must answer: 'highest-priority URL whose domain isn't rate-limited.'
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements. Ask: full web or specific domains? JS rendering needed? Freshness SLA?
- 5–10 min: Capacity estimation. Derive pages/sec, storage/month, frontier size, Bloom filter sizing.
- 10–15 min: Component interfaces. Frontier → Fetcher → Parser → Store. Show the feedback loop.
- 15–25 min: High-level architecture. Draw the crawl loop. Explain three data paths (discovery, fetch, store).
- 25–35 min: Deep dive on whichever the interviewer probes — likely Frontier design or deduplication.
- 35–40 min: Trade-offs. Speed vs politeness, freshness vs coverage, completeness vs cost.
- 40–45 min: Follow-ups. Crawl traps, JS rendering, multi-region, evolution path.
💡 The single sentence that defines a senior answer
"A web crawler is a feedback loop with a scheduling problem at its core — the Frontier must continuously answer 'what is the most valuable page I can fetch right now without violating any domain's rate limit?' Everything else — fetchers, parsers, dedup, storage — is plumbing around that central scheduling decision."