HardProblem 25 of 36 in the system design series
Design a Web Crawler: Polite, Deduplicated Crawling at Scale

A web crawler begins with a handful of starting addresses, downloads each page, pulls out every hyperlink it contains, and then visits those links in turn. Repeat that cycle long enough and you have a copy of a large slice of the public web, which search teams, analytics groups and machine learning pipelines all want as raw material.
Interviewers like this question because the core idea fits on a sticky note, yet almost every naive choice collapses at scale. A loop that works on a laptop will hammer small websites, refetch the same article through dozens of different URLs, and silently lose its backlog the first time a machine reboots. The candidate has to turn a toy loop into a durable pipeline.
Three pressures pull against each other here. Throughput wants as many parallel requests as possible, politeness caps how often any single host can be contacted, and correctness demands that no queued link disappears when a worker dies. A strong answer shows how one structure, a frontier partitioned by domain, satisfies all three at once.
This is an interview practice design for a Web Crawler-like service, built from first principles. It is not a description of how Web Crawler is actually built.
Functional Requirements
The feature list is short on purpose. An operator supplies seed links, the system downloads the HTML behind each one, extracts outgoing links so the crawl keeps expanding, and persists both the raw markup and the cleaned text. Each of these maps to a distinct stage later: seeding feeds the frontier, fetching is the network-heavy stage, link extraction is CPU work, and storage is the hand-off point to consumers.
Drawing the boundary at storage matters. Indexing, ranking and query serving are entire systems on their own, and pulling them in would leave no time for the hard parts. Saying explicitly that the crawler's job ends once a page is safely written keeps the conversation focused on discovery, fetching and reliability.
Start from seed URLs
We give the crawler a list of starting links.
seed list
Fetch pages
Download the HTML of every page we discover.
HTTP GET
Follow links
Extract links from each page and crawl them too.
discover new URLs
Store the content
Save raw pages and text for search or ML teams.
raw HTML · text
- Out of scope: building the search index and ranking
Non-Functional Requirements
Politeness is listed first because breaking it ends the project: a crawler that floods a small blog gets its IP ranges blocked and earns complaints. Honouring robots.txt and keeping to roughly one request per second per host shapes the entire frontier design. Scale is the opposite force, since ten billion pages in about five days requires massive parallelism across many different sites.
Fault tolerance and efficiency round out the list. With hundreds of machines running for days, crashes are routine rather than exceptional, so a dead worker must never take queued URLs with it. Efficiency means not paying twice, in bandwidth or in storage, for content we already hold. Both requirements push us toward durable queues and aggressive deduplication.
Politeness
Obey robots.txt; never overload a website.
≈ 1 req / s / domain
Scale
Ten billion pages in about five days.
10B pages
Fault tolerance
A crashed worker loses no URLs; work resumes.
no lost URLs
Efficiency
Don't fetch or store the same page twice.
dedup
Scale Estimation
Assume ten billion pages over five days. Five days is roughly 432,000 seconds, so the system needs to sustain about 23,000 fetches every second, around the clock. If a typical HTML document weighs around 100 KB, the raw archive lands near one petabyte, which points straight at cheap object storage rather than any database.
Bandwidth follows from the same numbers: 23,000 pages per second at 100 KB each is close to 2.3 GB per second, or roughly 18 Gbps of inbound traffic. Because fetching is mostly waiting on remote servers, an async worker can keep hundreds of connections open; assume each handles about 300 pages per second. That gives around 80 fetch workers, and we provision extra headroom for slow sites, retries and machine failures.
10billion
Pages to crawl
in 5 days (432K seconds)
23K
Pages per second
10B ÷ 432K s
1PB
Raw storage
100 KB avg HTML
18Gbps
Download bandwidth
23K/s × 100 KB ≈ 2.3 GB/s
≈ 300 pages/s per worker → about 80 fetch workers, plus headroom
Core Entities & API
Three entities carry the state. A URL record tracks status and when it was last crawled. A Domain record stores the parsed robots rules plus the earliest moment we may contact that host again, which is the hook for politeness. A Page record points at the stored content along with its fetch time and content hash, the hash being what powers duplicate detection later.
The external surface is deliberately thin. POST /crawls starts a job from a list of seeds and an optional page cap, GET /crawls/{crawl_id} reports counts of fetched, queued and failed URLs, and GET /pages?url= lets downstream teams locate the stored copy of a page. Everything else is an internal pipeline driven by queues, so spending interview time on REST details would be wasted.
Core entities
URLDomainPage
API
- POST /crawls { seed_urls: [...], max_pages }→ { crawl_id }
- GET /crawls/{crawl_id}→ { fetched, queued, failed }
- GET /pages?url=→ { s3_key, fetched_at, hash }
High-Level Design
The architecture is a cycle. Seeds enter the URL frontier, which holds pending work grouped by domain. A fetcher pulls the next eligible URL, resolves the host and checks robots rules through a shared Redis cache, downloads the page, and writes the raw HTML to object storage. It then emits a lightweight event so the parsing stage knows new content is waiting.
Parsers read the stored page, extract text and outgoing links, and hand those links to the dedup service. Dedup consults the URL metadata store, records anything new, and pushes only never-seen URLs back into the frontier, closing the loop. Splitting fetching from parsing matters because one is network-bound and the other CPU-bound, so each tier can scale on its own curve.
Crucially, no worker holds state that cannot be rebuilt. The frontier and the stores own everything durable, so adding fetchers during a big crawl or replacing a crashed parser is just a matter of starting another process. That property is what makes the fault tolerance deep dive straightforward later.
Stateless workers in a loop around a durable frontier
Deep Dive 1 — Frontier & Politeness
Not every page deserves the same urgency. A breaking-news homepage changes by the minute, while an archived manual may not change for years, so the frontier first assigns a priority based on signals like link popularity and observed change rate. High-value URLs get fetched sooner and revisited more often.
Politeness comes from grouping. If the frontier is partitioned by hostname, for instance as a Kafka topic keyed by domain, every URL for a given site lands on the same partition and therefore with the same worker. Two machines can never race against one host. Before each request that worker checks its cached copy of robots.txt and drops any disallowed path.
Inside a worker, a min-heap orders the domains it owns by their next permitted fetch time. The worker pops whichever domain is ready, fetches one URL, and pushes the domain back with a new time of now plus its crawl delay. When a server answers with 429 or 503, the delay for that host doubles, so a struggling site gets breathing room automatically instead of being retried at full speed.
- 1PrioritiseImportant and frequently changing pages go first
- 2Group by domainOne queue per domain; one worker owns each domain
- 3Check robots.txtCached per domain; skip disallowed paths
- 4Wait your turnA heap of next allowed fetch time per domain
- 5Fetch & rescheduleNext time = now + delay; back off on errors
- Got 429 or 503? Double that domain's delay
Deep Dive 2 — Deduplication
Duplicates arrive at two levels, and the link rate is brutal: each page carries dozens of links, so the dedup tier must answer well over a million lookups per second. The first defence is canonicalisation. Lowercasing the hostname, stripping #fragment anchors and removing tracking parameters like utm_source collapses many spellings of one page into a single key before any lookup happens.
For the membership test itself, a Bloom filter is the natural fit. Sized for ten billion entries at a 1 percent false positive rate, it needs only about 12 GB of memory, small enough to keep in RAM. The failure mode is gentle: an occasional false positive means one genuinely new page is skipped, which is an acceptable loss for a crawl of this size.
URL checks cannot catch mirrors, syndicated articles or session-tagged copies that live at different addresses. Hashing the response body catches exact matches, so identical content is stored once. Near-duplicates, such as the same story wrapped in different ads or timestamps, need SimHash, whose fingerprints differ by only a few bits for similar documents. Finally, guard against traps like infinite calendar links by capping URL length, path depth and pages per domain.
Normalise URLs first
Lowercase the host, drop #fragments and tracking params — one page, one URL.
Seen-URL check with a Bloom filter
≈ 12 GB for 10B URLs at 1% false positives; a false positive skips one page.
Content hash for exact copies
Same body hash → mirror or copy; store it only once.
SimHash for near-duplicates
Pages that differ only in ads or dates get almost the same fingerprint.
Avoid crawler traps
Cap URL length, path depth, and pages per domain.
Deep Dive 3 — Fault Tolerance
Where the frontier lives decides what a crash costs. Keeping queues in process memory is the fastest option, but a restart wipes out every pending URL and RAM limits how big the backlog can grow. A relational table survives crashes and is easy to inspect, yet at a million new URLs per second the polling queries and row-level locking turn into the bottleneck.
A Kafka topic partitioned by domain is the chosen design. The log is replicated and replayable, and the partition-to-consumer mapping gives one owner per host, so politeness comes along for free. Workers commit their offset only after the page is safely in storage. If a worker dies mid-batch, its partitions are reassigned and the new owner simply refetches a few pages; nothing disappears.
Transient failures go to a chain of retry topics with growing waits, for example one minute, then ten, then an hour, before landing in a dead-letter topic for inspection. Every HTTP request also carries a strict timeout. Without it, a single server that accepts connections and then stalls could tie up a worker indefinitely and quietly drain throughput.
In-memory queues
- + Fastest
- − Crash = lost URLs
- − Limited by RAM
Database table
- + Durable
- + Easy to query
- − Polling, row locks at scale
Kafka by domainChosen
- + Durable and replayable
- + Domain → one consumer
- − Delays need retry topics
- Commit the offset only after the page is stored → crash = refetch, not loss
- Retry topics: 1 min → 10 min → 1 hour, then dead-letter
- Timeout on every fetch; one slow site never blocks a worker
Trade-offs
Each major choice gives something up. The Bloom filter trades a small, random loss of coverage for a memory footprint that fits on one machine instead of a cluster. Strict per-host limits mean very large sites take days to finish, but the alternative is getting blocked and finishing never. Using Kafka as the frontier buys durability and horizontal scale, at the cost of building priorities and delays from extra topics rather than getting them natively.
Accepting at-least-once fetching ties the design together. Exact-once delivery across fetch, store and offset commit would require heavy coordination, whereas occasionally refetching a page is cheap because the content hash catches the repeat before it is stored again. The guiding idea is to stay courteous to every host, filter repetition at every layer, and make sure any step can safely run twice.
Bloom filter vs exact set
Tiny memory — but we skip a few pages by mistake.
Politeness vs speed
Big sites crawl slower — but we don't get blocked.
Kafka as the frontier
Durable and scalable — but priorities and delays need extra topics.
At-least-once fetching
Simple recovery — some pages are fetched twice; dedup absorbs it.
Be polite. Dedup everything. Make every step safe to retry.
Key takeaways
- Partition the frontier by domain so one worker owns each host and politeness is enforced in one place.
- Assume 23K pages per second, about 1 PB of raw HTML and roughly 80 async fetchers plus headroom.
- Normalise URLs, then use a Bloom filter for seen links and hashes plus SimHash for duplicate content.
- Commit Kafka offsets only after a page is stored, so a crash causes a refetch instead of a lost URL.
- Use retry topics with growing delays, a dead-letter topic and strict timeouts on every fetch.
Interview questions
How does a web crawler avoid overloading a website?
It routes every URL for a host to one worker by partitioning the frontier on domain, then enforces a minimum delay between requests to that host, often around one per second. The worker tracks each domain's next allowed fetch time in a heap. Rules from robots.txt are cached and checked first, and rate-limit or overload responses such as 429 and 503 double the delay for that site.
How does a crawler detect duplicate URLs and pages?
Duplicate URLs are caught by normalising each link and testing it against a Bloom filter, which needs roughly 12 GB for ten billion entries. Duplicate content is caught separately: an exact hash of the page body finds identical copies, while SimHash fingerprints find near-identical pages that differ only in ads, dates or other boilerplate. Both layers are needed because many different addresses can serve the same content.
How should robots.txt be handled at crawler scale?
Download it once per domain, parse it, and keep the rules in a shared cache with a time-to-live of about a day. Every candidate URL is checked against the cached rules before it is fetched, and disallowed paths are dropped. This avoids an extra request for every page while still picking up changes a site owner makes to its rules reasonably quickly.
Why is DNS resolution a bottleneck for web crawlers?
Every fetch needs the server's IP address, and at tens of thousands of pages per second, sending each lookup to an upstream resolver adds latency and can overwhelm it. Because the same hosts are contacted repeatedly, a local DNS cache inside each fetcher, or a shared cache alongside the robots rules, answers most lookups instantly and removes this hidden limit on throughput.
How often should a crawler revisit pages that change?
Recrawl frequency should follow each page's observed change rate. Record whether the content hash changed on each visit and adjust the interval accordingly: a news front page might be refetched every hour, while a static documentation page might wait a month. Feeding this into frontier priority keeps the index fresh without wasting bandwidth on pages that rarely change.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.