6. Web Crawler Intermediate → Advanced

A. Realistic Interview Prompt

"Design a web crawler that starts from a set of seed URLs, discovers and downloads pages across the web, extracts links to crawl further, and stores the content — at the scale of billions of pages."

This problem looks like a simple graph-traversal exercise (BFS/DFS over links) until scale and etiquette enter the picture. The two ideas that separate a toy crawler from a real one are: politeness (a crawler that hits one server too fast is indistinguishable from a denial-of-service attack, and will get the crawler's IP blocked) and the URL frontier's prioritization problem (deciding what to crawl next isn't a plain queue — it has to balance fairness across domains, priority, and revisit frequency simultaneously).

B. Difficulty

Intermediate to state ("BFS from seed URLs, download, extract links, repeat") escalating to Advanced the moment politeness, duplicate detection at billions-of-pages scale, and freshness/recrawl scheduling enter — three genuinely distinct sub-problems layered on top of what looks like a simple traversal.

C. What This Problem Tests

D. Requirements

Functional

  1. Given a set of seed URLs, fetch each page, extract outbound links, and add new, previously-unseen URLs to the crawl queue
  2. Store downloaded page content (and metadata) for downstream consumption (e.g. indexing — out of scope itself)
  3. Respect each site's robots.txt exclusion rules and avoid overloading any single host
  4. Periodically recrawl previously-seen pages to keep content fresh

Non-Functional

Out of scope: the search-indexing/ranking pipeline that consumes crawled content, JavaScript-heavy rendering (assume mostly static HTML, with headless rendering named as a known extension, not designed in depth).

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1What's the target scale — pages/month, and how large is "the web" we're crawling (whole internet vs. a bounded domain set)?Sets every downstream throughput/storage numberCapacity Estimation (G)
2Must we respect robots.txt and crawl-delay directives?Assumed yes for any legitimate crawler — confirms politeness is mandatory, not optionalDeep Dive 2 (N)
3Do we need near-duplicate content detection, or just exact-URL dedup?Near-duplicate detection needs content fingerprinting, a materially bigger system than a URL-seen-setDeep Dive 4 (P)
4How fresh does content need to be — recrawled daily, weekly, or priority-based on how often a page tends to change?Determines whether recrawl is a fixed schedule or an adaptive, priority-driven oneDeep Dive 6 (R)
5Is dynamic/JS-rendered content in scope?Requires a headless-browser rendering tier, a substantial architectural additionDeep Dive 7 (S)
6What happens with malicious or malformed pages — infinite redirect loops, deliberately generated infinite link mazes?Requires explicit trap-detection safeguards, not just "the crawler will naturally stop"Deep Dive 7 (S)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
Pages/month target1 billionBase input for every number below
Pages/sec (average)1B ÷ (30 × 86,400s) ≈ ~385 pages/secSets the average fetch-throughput target for the whole crawler fleet
Average page size~500KB (HTML + inline assets metadata, media handled separately)Base input for bandwidth and storage
Bandwidth385/sec × 500KB ≈ ~190MB/sec sustainedA real, non-trivial network-egress number to size the fetcher fleet against
Storage/month1B × 500KB ≈ ~500TB/monthConfirms a distributed blob store, not a single-machine filesystem, is required (Q)
Distinct hosts (illustrative)~100M-500M distinct domains reachable from seeds at this scaleMotivates per-host queue partitioning in the Frontier (M) — the fan-out is enormous
Politeness delay per host~1 request per host per few seconds (a conservative, illustrative default absent an explicit crawl-delay)Directly limits how much of the 385 pages/sec can come from any one host — this is why parallelism must come from host diversity, not per-host request rate (N)

H. Core Entities

EntityPurposeKey Fields
URLA discovered link, queued or crawledurl, host, priority, last_crawled_at, status
PageDownloaded content for one URLurl, content_hash (P), storage_ref, fetched_at, content_type
HostA domain being crawled — the unit politeness is scoped tohost, robots_rules, crawl_delay, last_request_at
FrontierQueue (ephemeral, per-host)Pending URLs waiting to be fetched, one queue per hostlives in the frontier's shared store (K) — losing it on a crash just means those URLs get rediscovered later

I. API Design

Internal / operator-facing only — this system has almost no external API:
  POST /v1/seeds              { urls: [...] }         -> enqueue new seed URLs
  GET  /v1/crawl-stats                                -> pages/sec, queue depth, error rate
  POST /v1/hosts/{host}/pause                          -> operator override for a misbehaving/blocking host

Internal worker contract (not externally exposed):
  Frontier.dequeue() -> URL                (respecting per-host politeness, M/N)
  Fetcher.fetch(URL) -> { html, headers, status }
  Parser.extractLinks(html) -> [URL]

Why so little external API surface: unlike every previous problem, a web crawler has no end-user-facing request path at all — it's an autonomous internal pipeline whose only "customers" are the seed-injection interface and whatever downstream system (a search indexer) consumes its output. Naming this explicitly is itself a useful observation — it signals the design should focus entirely on the internal pipeline's throughput and correctness, not on external API ergonomics.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirement in isolation: a single worker, one global FIFO queue of URLs, fetch-parse-enqueue in a loop.

flowchart LR Seeds(["Seed URLs"]) --> Q[("Single Global Queue")] Q --> W["Single Worker:
fetch -> parse -> extract links"] W -- "new URLs" --> Q W --> Store[("Page Storage")]

V1 — one queue, one worker. Correct in shape, but has no concept of politeness or fleet parallelism at all.

Why this breaks immediately, not eventually: two failures happen simultaneously the moment this needs to run at real scale. First, a single global FIFO has no per-host awareness — nothing stops the worker from fetching 50 URLs from the same host back-to-back, which is exactly the impolite behavior that gets a crawler IP-banned. Second, one worker fetching one page at a time can never approach 385 pages/sec (G) — network fetch latency alone (hundreds of ms per request) caps a single worker at a few requests/sec. This V1 fails on both scale and etiquette before any refinement is even considered.

K. Data Model & Store Choice

Frontier queue state: a distributed queue/priority-queue system (e.g. Kafka partitioned by host-hash, or a Redis-backed priority structure) — must support per-host partitioning (M) and priority ordering, and losing an in-flight URL on a crash is recoverable (it simply gets rediscovered via a re-crawl or another inbound link), so strict durability isn't required here, the same "ephemeral, recoverable state" reasoning applied to Rate Limiter's counters and Uber's location index.

Crawled content: a blob store (S3-class) for raw page bytes, referenced by a metadata row in a wide-column store keyed by URL, storing content_hash, fetch time, and status — the same "small structured metadata pointing at large blob storage" split used for media in the News Feed and Chat System problems.

Seen-URL and content-fingerprint sets (P): Bloom filters — a probabilistic set membership structure, chosen specifically because billions of URLs make an exact set too large to hold cheaply, and false positives (occasionally skipping a URL that was actually new) are an acceptable trade against the massive space savings, unlike the false negatives a naive set could never produce anyway.

L. End-to-End Flow — One Crawl Cycle

sequenceDiagram participant F as Frontier (M) participant Pol as Politeness Gate (N) participant Fetch as Fetcher Worker participant Parse as Parser participant Dedup as Dedup Filter (P) participant Store as Storage (Q) F->>Pol: dequeue next URL for host X Pol->>Pol: check robots.txt + rate limit for host X alt allowed now Pol-->>Fetch: proceed Fetch->>Fetch: HTTP GET Fetch->>Parse: raw HTML Parse->>Dedup: content hash + extracted links Dedup->>Dedup: seen before? (URL + content fingerprint) alt new content Dedup->>Store: persist page Dedup->>F: enqueue new, unseen links else duplicate Dedup->>Dedup: discard, no re-store end else not yet allowed (politeness delay) Pol-->>F: requeue for later end

M. Deep Dive 1 — The URL Frontier

Requirement this addresses: directly fixes V1's flaw of treating the crawl queue as one undifferentiated FIFO (J) — this is the central data structure of the entire system, modeled closely on Google's own published Mercator crawler architecture.

flowchart TB Front["Front Queues
(priority buckets)"] --> Selector{"Priority Selector"} Selector --> Back["Back Queues
(one per host)"] Back --> Assign{"Host mapping table"} Assign -- "host A" --> QA["Queue: host A"] Assign -- "host B" --> QB["Queue: host B"] Assign -.-> QN["... one queue per active host"]

Front queues express priority; back queues express per-host fairness. A URL passes through both.

Why two layers, not one: a single priority queue alone would let a host with thousands of high-priority URLs monopolize the crawler (the fairness problem); a single per-host-queue layer alone would have no way to express "crawl this page sooner because it's more important" (the priority problem). The two-layer front-queue/back-queue split — front queues bucket URLs by priority, a selector round-robins fairly across back queues, and each back queue maps to exactly one host — solves both simultaneously: a worker pulling from the back queues can never accidentally starve or flood any single host, regardless of how the front queues prioritized the URLs feeding into them.

Why the host-to-back-queue mapping matters: ensuring exactly one back queue per active host (not per URL) is what makes per-host politeness (N) enforceable at all — if one host's URLs could land in multiple back queues, two different workers could pull from the same host concurrently with no coordination.

N. Deep Dive 2 — Politeness

Requirement this addresses: the politeness NFR (D) — treated as a hard correctness constraint, not a tunable performance knob.

Why this is non-negotiable, not just good manners: a crawler that fetches too aggressively from one host looks identical, from that host's perspective, to a denial-of-service attack — the practical consequence is IP-blocking or legal exposure, either of which ends the crawl for that host (and potentially others sharing the same IP range) permanently. This makes politeness a functional requirement with real failure consequences, not a "nice to have."

O. Deep Dive 3 — Distributed Crawling at Scale

Requirement this addresses: the throughput NFR (D, G) — a single worker (V1) can never approach 385 pages/sec; this requires a real distributed fetcher fleet.

Why parallelism has to come from host diversity, not per-host request rate: politeness (N) caps how fast any one host can be hit, so the entire 385 pages/sec target must be assembled from many different hosts being crawled concurrently, not from crawling any single host faster — directly explaining why the frontier's per-host queue structure (M) isn't just a fairness nicety, it's the mechanism that makes horizontal scaling possible at all.

flowchart LR Frontier[("Sharded Frontier (M)")] --> W1["Fetcher Worker 1"] Frontier --> W2["Fetcher Worker 2"] Frontier -.-> WN["... Fetcher Worker N"] W1 --> DNS[("DNS Cache")] W2 --> DNS WN --> DNS

Many workers pull from many different hosts' back queues concurrently — parallelism via host diversity, not per-host speed.

Why a shared DNS cache matters at this scale: a DNS lookup is a real, non-trivial latency cost per new host encountered; with hundreds of millions of distinct hosts (G), a shared cache across the whole fetcher fleet avoids redundant lookups for hosts multiple workers happen to crawl — a small, concrete optimization with an outsized effect given the sheer host cardinality involved.

P. Deep Dive 4 — Duplicate Detection

Requirement this addresses: avoiding wasted crawl/storage capacity on content already seen — a distinct problem at two different levels.

LevelProblemMechanism
Exact-URL dedupThe same URL discovered via multiple inbound links shouldn't be queued twiceA Bloom filter of already-seen/queued URLs (K) — probabilistic, but false positives (rarely skipping a genuinely new URL) are an acceptable trade against holding billions of exact URLs in memory
Near-duplicate content dedupDifferent URLs (mirrors, tracking-parameter variants, syndicated copies) serving effectively the same content shouldn't both be stored and processed downstreamA content fingerprint (e.g. SimHash or MinHash over the page's shingled content) compared against previously-seen fingerprints — designed specifically to detect near-identical content, not just byte-exact duplicates

Why exact hashing (e.g. a plain SHA-256 of page bytes) isn't sufficient on its own: two pages that are substantively identical (the same article with a different ad banner, or a tracking query parameter appended to an otherwise-identical URL) will still produce completely different exact hashes — a fingerprinting scheme like SimHash is deliberately designed so that near-identical inputs produce near-identical fingerprints, making "close enough" content detectable via a cheap similarity comparison rather than requiring exact equality.

Q. Deep Dive 5 — Storage at Scale

Requirement this addresses: the ~500TB/month storage volume from G — this needs to be durable, cheap-per-byte, and organized for whatever downstream consumer (a search indexer) reads it.

Why a blob store, not a database, for the raw content: page HTML is large, write-once, and read infrequently after initial processing — exactly the access pattern object storage is built for and priced for, versus a database optimized for small, frequently-updated rows. The metadata table (URL, content hash, fetch timestamp, storage pointer) stays small and queryable in a proper database, while the bulk bytes live in cheap blob storage — the same pattern used for media in News Feed (R) and Chat System, just applied to crawled HTML instead of user-uploaded photos/video.

Compression: HTML compresses extremely well (large amounts of repetitive markup/whitespace) — storing compressed content routinely cuts the effective storage bill by more than half, a cheap, high-leverage optimization at this volume.

R. Deep Dive 6 — Freshness & Recrawl Scheduling

Requirement this addresses: the recrawl functional requirement (D) — crawling is a continuous process, not a one-time batch job that terminates once the frontier empties.

Why a fixed blanket recrawl interval is the wrong default: a news homepage changes multiple times a day; a static reference page might not change for years — recrawling both on the same fixed schedule wastes enormous capacity on the reference page while under-serving freshness on the news page. An adaptive, priority-based recrawl schedule — estimating each page's historical change frequency (did the content hash (P) change since the last fetch?) and using that to set the next recrawl priority — directly mirrors how real crawlers like Googlebot actually operate, and is a meaningfully stronger answer than a fixed interval.

flowchart LR Fetch["Recrawl fetch"] --> Compare{"content_hash changed
since last fetch?"} Compare -- "yes, frequently" --> High["Higher recrawl priority
(shorter interval)"] Compare -- "no, rarely" --> Low["Lower recrawl priority
(longer interval)"] High --> Front["Feed back into Frontier (M)
front queue, priority bucket"] Low --> Front

Why this reuses the Frontier (M) rather than needing a separate scheduling system: a recrawl is simply a URL being re-enqueued into the same front-queue/back-queue structure that handles first-time crawls, just with a priority informed by observed change frequency instead of link-discovery order — one mechanism serving both the initial crawl and the ongoing refresh cycle.

S. Deep Dive 7 — Crawler Traps & Extensibility

Requirement this addresses: the robustness NFR (D) and the extensibility NFR — a crawler operating autonomously across the open, occasionally-hostile web needs defenses that a request/response API serving trusted internal traffic never needed.

T. Full Solution at Scale — Final Architecture

flowchart TB Seeds(["Seed URLs"]) --> Frontier subgraph Frontier [" URL Frontier (M) "] direction LR FrontQ["Front Queues
(priority)"] BackQ["Back Queues
(per-host)"] FrontQ --> BackQ end BackQ -- "politeness gate (N)" --> Fleet subgraph Fleet [" Distributed Fetcher Fleet (O) "] direction LR W1["Worker 1"] W2["... Worker N"] end Fleet --> DNS[("Shared DNS Cache (O)")] Fleet --> Parse["Parser
(pluggable, S)"] Parse --> Dedup{"Dedup Filter (P)
URL + content fingerprint"} Dedup -- "new" --> Storage[("Blob Store + Metadata (Q)")] Dedup -- "new links" --> Frontier Storage -.-> Recrawl["Freshness / Recrawl
Scheduler (R)"] Recrawl -.-> Frontier

Everything above is the composition of M through S — no new component introduced here.

U. Design Evolution

What stays stable throughout: the Frontier's dequeue/enqueue contract from L — every version underneath changes without the fetch-parse-store loop's shape needing to change.

V. Consistency Model

Almost the entire system is best-effort and eventually consistent by nature — losing a queued URL on a crash just means it's rediscovered later via another inbound link or the next recrawl cycle (R); a duplicate slipping through the Bloom filter's false-positive/negative edge cases causes, at worst, one wasted or skipped fetch, not a correctness failure anyone downstream would notice. There is no analogous "one atomic step" requirement here the way Rate Limiter or Uber had — this system's entire design philosophy is that individual failures are cheap and recoverable, so no component needs strong coordination guarantees.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 single global queueNo per-host fairness, no parallelismFront/back queue frontier structure (M)
Frontier with unbounded per-host ratePoliteness violations risk IP-blocking, ending the crawl for that hostEnforced per-host rate limiting + robots.txt (N)
Single worker fetchingCan't approach the required pages/sec throughputDistributed fetcher fleet, parallel via host diversity (O)
Unbounded recrawl schedulingFixed intervals waste capacity on rarely-changing pages, under-serve fast-changing onesAdaptive, change-frequency-based recrawl priority (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Frontier structureSingle global priority queueFront/back two-layer queue (chosen)Single queue is simpler but can't express fairness and priority simultaneously; two layers solve both at the cost of one extra indirection
URL dedupExact in-memory setBloom filter (chosen)An exact set has zero false positives but is far too large to hold at billions-of-URLs scale; a Bloom filter trades a small false-positive rate for a dramatically smaller footprint
Content dedupExact hashSimilarity fingerprint (SimHash, chosen)Exact hashing is simpler but misses near-identical content with trivial variations; fingerprinting catches near-duplicates at the cost of a fuzzier, probabilistic match
Recrawl schedulingFixed interval for all pagesAdaptive, change-frequency-driven (chosen)Fixed is simple to reason about; adaptive better matches real content behavior at the cost of tracking change history per page
Politeness enforcementRely solely on robots.txtrobots.txt + a self-imposed floor (chosen)Relying only on robots.txt is simpler but many sites specify no crawl-delay at all, leaving no protection; a self-imposed floor is the safer, more defensible default

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"A specific host is blocking our crawler."Back off that host's back queue and surface it via the operator API (I) for investigation — no fleet-wide change neededThat host's queue state onlyEverything else
"We need to crawl JavaScript-heavy single-page apps."Plug a headless-browser renderer into the parser interface (S) for hosts that need itNew parser implementationFrontier, politeness, dedup, storage
"How do you prioritize crawling more 'important' pages first?"Feed a page-importance signal (e.g. inbound link count, a simple PageRank-style score) into the front queue's priority bucket assignmentFront queue priority scoring in MBack queue fairness, politeness, fetch mechanics
"Storage costs are too high."More aggressive compression, and shorter retention for low-value/rarely-changing pages informed by R's freshness signalStorage policy (Q)Frontier, fetching, dedup
"A crawler trap is consuming a disproportionate share of capacity."Directly exercises S's per-host depth/URL-count capsNone — this is exactly what S already handlesEverything

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a web crawler that starts from seed URLs and crawls billions of pages.
Candidate: The core structure is a queue of URLs to visit, workers that fetch and parse them, and newly discovered links feeding back into the queue. But a single queue doesn't work at real scale — it has no concept of not overwhelming any one host, and no way to prioritize more important pages.
Interviewer: How would you fix that?
Candidate: I'd use a two-layer frontier — similar to what Google's own Mercator crawler paper describes. Front queues bucket URLs by priority; a selector then round-robins across back queues, where each back queue holds URLs for exactly one host. That guarantees no single host gets flooded regardless of how many high-priority URLs point to it.
Interviewer: Why does per-host matter so much?
Candidate: Politeness. Hitting one host too fast looks like a denial-of-service attack from their side, and gets the crawler's IP blocked — that's a hard failure for that host, not just a performance issue. I'd enforce robots.txt rules plus a self-imposed minimum delay between requests to the same host, even when robots.txt doesn't specify one.
Interviewer: If politeness limits per-host speed, how do you hit real throughput targets?
Candidate: Parallelism has to come from crawling many different hosts concurrently, not from crawling any one host faster — that's exactly what the per-host back queues enable. A large fleet of fetcher workers each pull from different hosts' queues at once.
Interviewer: How do you avoid storing the same content twice?
Candidate: Two levels. Exact-URL duplicates are cheap to catch with a Bloom filter of seen URLs — a plain set would be too large at billions of URLs, and a small false-positive rate is an acceptable trade. But near-duplicate content reachable via different URLs — mirrors, tracking parameters — needs content fingerprinting, something like SimHash, since an exact hash would treat two nearly-identical pages as completely unrelated.
Interviewer: Last one — content changes over time. How do you keep it fresh?
Candidate: I wouldn't use one fixed recrawl interval for everything — a news page and a static reference page have very different change rates. I'd track whether a page's content hash actually changed on each recrawl and use that to adjust its priority going forward, feeding it back into the same frontier structure rather than building a separate scheduling system.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Hello InterviewFrontier-first framing, explicit politeness-as-a-hard-constraint discussion, staff-level freshness/recrawl treatmentClearest articulation of the frontier as the central data structure — adopted directly for MLighter on the specific fingerprinting algorithm choices for near-duplicate detectionThe front-queue/back-queue framing and the "politeness is a hard requirement, not a performance knob" stance used throughout N
ByteByteGoDetailed URL frontier, DNS resolver, and content-storage component breakdown with real numeric estimatesThe most concrete numeric-estimation treatment of any source reviewed for this problemLess depth on adaptive/change-frequency-driven recrawl specificallyThe bandwidth/storage capacity math adapted into G
donnemartin/system-design-primer (GitHub)General crawler architecture walkthrough covering the frontier, DNS caching, and robots.txt handlingGood grounding in the general queue+worker+dedup shape used consistently across this document's other fanout-style problemsLess detailed on the specific Mercator-style two-layer frontier structureCross-referencing the general shape against the more crawler-specific sources above
Google's Mercator paper ("Mercator: A Scalable, Extensible Web Crawler")The actual, real-world published architecture the front-queue/back-queue frontier design in M is based onGround-truth confirmation this is a real, production-proven design, not a hypothetical interview constructAcademic paper framing, not an interview-oriented walkthrough — needs translation, which M providesThe original source of the politeness-via-per-host-queue idea used throughout this section

AE. Interview Cheat Sheet

ProblemWeb Crawler
DifficultyIntermediate → Advanced
Core requirementsSeed-based link-following crawl, content storage, robots.txt compliance, periodic recrawl; 1B pages/month, ~385 pages/sec, ~500TB/month storage
Central decisionA two-layer URL Frontier (priority front queues + per-host back queues) — a plain FIFO can't express both fairness and priority
Politenessrobots.txt compliance + a self-imposed per-host rate floor — a hard correctness constraint, not a performance tunable
Scale fixDistributed fetcher fleet parallelized via host diversity (not per-host speed) + shared DNS cache
DedupBloom filter for exact-URL dedup; content fingerprinting (SimHash-style) for near-duplicate content
FreshnessAdaptive, change-frequency-driven recrawl priority, fed back into the same Frontier structure
RobustnessPer-host depth/URL-count caps against crawler traps; redirect-chain length caps; pluggable parser interface for new content types
Common mistakesSingle undifferentiated queue; ignoring politeness; exact-hash-only dedup; treating the crawl as a one-time batch job; no trap defenses
What NOT to over-engineerA full headless-browser JS-rendering pipeline or ranking/indexing system — name them as pluggable extensions, don't design them in depth