Blueprint
Infrastructure intermediate

Design a web crawler

A pipeline question in disguise: the fetching is easy, and the marks are in politeness, dedup, crawler traps and resuming after failure without losing ten billion pages of progress.

asked at Google · OpenAI · Amazon Sometimes asked ~35 min walkthrough ✎ Practice on canvas →
01

Scope the requirements

First question back to the interviewer: what is the crawl for — a search index or LLM training data? Both want extracted text at scale. Then I scope hard, because the un-scoped version of this problem is infinite.

Functional — what it must do

  • Start from seed URLs, fetch pages, and follow extracted links to discover the web.
  • Store raw HTML and extracted text for downstream consumers (indexer or training pipeline).
  • Respect robots.txt — disallow rules and crawl-delay — and stay polite per domain.
  • Deduplicate both URLs and page content, and survive crawler traps.
  • Explicitly cut: JavaScript rendering, images and video, authenticated pages, adversarial cloaking — each is its own project, and I'll say what it would take if asked.

Non-functional — the numbers that shape the design

  • Scale: 10B pages in ~5 days — a full-web-scale batch crawl.
  • Politeness ceiling: ~1 request/s per domain — throughput comes from breadth across millions of domains, never depth on one.
  • Fault tolerant: a crashed worker loses zero progress — days of crawl state must survive any single failure and be resumable.
  • Efficiency: don't fetch or store the same content twice; bandwidth is the dominant cost.
  • Freshness and re-crawl scheduling scoped out of v1 — I'll sketch it as a follow-up.
Say this out loud

The interesting constraint is that the two scale numbers point in opposite directions — 25 thousand pages a second overall, but at most one per second to any single domain. The whole architecture is about resolving that tension politely, and about making a multi-day job restartable.

02

Back-of-envelope estimates

This estimate decides the fleet size, which is rare — and it shows the system is network-bound, not CPU-bound, which decides where the money goes.

QuantityEstimateHow you got there
Crawl rate needed~25k pages/s10B pages ÷ 5 days (~430k s) ≈ 23k/s sustained — call it 25–40k with retries and overhead.
Data transferred~20 PB10B pages × ~2 MB average transfer (page + assets fetched). Bandwidth is the bill.
Per-machine throughput~4k pages/sA 200 Gbps node at ~30% practical utilisation moves ~2 MB × 4k/s. One machine alone would take a month.
Fetcher fleet~8 machines25k/s ÷ 4k/s per machine. Tiny fleet, huge pipes — the design is network-bound.
Domains in flightmillionsAt ≤1 req/s/domain, 25k pages/s requires ~25k+ domains actively being fetched at any instant — breadth is mandatory.
Storage~1 PB + 10 TB10B × ~100 KB stored HTML ≈ 1 PB in blob storage; 10B × ~1 KB metadata ≈ 10 TB in the URL table.
Say this out loud

Eight big machines can move the bytes — the hard part is that 25 thousand pages a second must be spread across tens of thousands of domains at once to stay polite. So the frontier and the per-domain scheduler are the real design, not the fetchers.

03

Sketch the API

There's no end-user API — the crawler's interface is seeds in, text in blob storage out. What I expose is an operator surface, and the interesting decision is that queues carry pointers, never page bodies.

POST/v1/seedsbody: { urls[], priority? } — inject seed URLs into the frontier. Idempotent per normalised URL.
GET/v1/crawl/stats→ { fetched, queued, failed, pagesPerSec } — progress against the 10B target; ops dashboards read this.
GET/v1/domains/{domain}→ { robotsRules, crawlDelay, lastCrawled, errorRate } — per-domain state, for debugging "why aren't we crawling X".
POST/v1/domains/{domain}/blockOperator kill-switch: stop crawling a domain immediately (takedown requests, abuse reports).
  • Queue messages carry URLs and S3 pointers, never HTML bodies — a 2 MB page through SQS or Kafka per hop would multiply the 20 PB across every stage. Fetchers write raw HTML to blob storage once and pass a reference downstream.
  • Seed submission normalises URLs (lowercase host, strip fragments, resolve relative paths, sort query params) before the dedup check — dedup on un-normalised URLs is dedup in name only.
04

Data model

One table holds the crawl's entire brain-state — which is exactly what makes the system resumable. Everything else is derived or cached.

url_metadata
url PK · status (pending/fetched/failed) · depth · content_hash · s3_raw · s3_text · fetched_at
The source of truth for progress. Partition-key access by URL — DynamoDB-shaped. depth is the crawler-trap fuse; content_hash feeds dedup.
domains
domain PK · robots_rules (parsed) · crawl_delay · last_crawled_at · error_count
robots.txt cached here with a ~1-day TTL so we don't re-fetch it for every page on the domain.
seen_content
Bloom filter over content hashes (RedisBloom)
"Have I stored this page under another URL?" — answers in memory across billions of entries; false positives skip a duplicate-looking page, which is acceptable.
The database call

Metadata goes in DynamoDB — 10 TB of point lookups by URL and heavy write concurrency from thousands of workers is exactly a partitioned KV workload, and there is nothing relational here. Raw HTML and extracted text go straight to S3; a petabyte does not belong in any database. If the interviewer wants richer crawl analytics — link graphs, per-domain reporting — I'd stream metadata changes into a warehouse rather than querying the hot table.

05

High-level design

A staged pipeline glued together by queues: frontier → fetch → parse → back to frontier. Splitting fetch from parse is deliberate — they scale on different resources and fail for different reasons, and the queue between them is the checkpoint that makes the whole crawl resumable.

operator service data queues Operator seeds in Fetchers network-bound Parsers CPU-bound Rate limiter Redis, per-domain Metadata DB URL + domain state Blob store raw HTML + text Frontier queue URLs to fetch Parse queue S3 pointers DLQ after 5 tries text politeness dedup check S3 pointer poison seed URLs raw HTML new URLs
the loop is the system: parser feeds the frontier that feeds the fetchers. notice queues carry pointers, not pages — the 2 MB bodies only ever travel fetcher → S3.
  1. A URL is dequeued from the frontier; the fetcher checks cached robots.txt rules and takes the per-domain rate-limit token in Redis — if the domain is on cooldown, the message is deferred back to the queue with a delay, never held by a blocked worker.
  2. The fetcher resolves DNS through a local DNS cache, downloads the page, writes raw HTML to S3, and drops a message with the S3 pointer onto the parse queue.
  3. A parser pulls the pointer, extracts text to S3, hashes the content for dedup, and normalises every outbound link.
  4. Each new link is checked against the metadata DB (URL seen?) and the content Bloom filter, gets depth = parent + 1, and — if new and under the depth cap — is enqueued back to the frontier.
  5. The queue message is deleted only on success; a worker that dies mid-page lets the visibility timeout expire and the URL is re-claimed by another worker. After ~5 failed attempts the message lands in the DLQ for inspection.
06

Deep dives — where the interview is won

Phase 01

Politeness is architecture, not etiquette — and it's also self-preservation

Two mechanisms per domain. First, robots.txt: fetched once per domain, parsed, cached in the domains table with a daily TTL; every URL is checked against disallow rules before fetch, and a Crawl-delay directive overrides my default rate. Second, a per-domain rate limiter in Redis — a sliding window or a SET NX lock with TTL capping at ~1 req/s — with a little jitter so thousands of workers don't synchronise their retries against the same domain.

The scheduling subtlety is what to do when a domain is on cooldown: a worker must never sleep holding the message, or a handful of slow domains stalls the fleet. Instead it pushes the message back with a delay (SQS ChangeMessageVisibility) and moves on. This is Mercator's front-queue/back-queue idea in modern dress: priority decides what to crawl, per-domain queues decide when. And politeness is not altruism — an impolite crawler gets IP-banned across half the web, and a banned crawler fetches nothing.

queue gate worker Frontier Per-host bucket 1 req / 2s Fetch worker URL if allowed
per-host token bucket. one host = one worker at any time. politeness is architecture.
Trade-offStrict per-domain caps mean one huge site (millions of pages, 1 req/s) takes weeks no matter how big my fleet is — I accept that, and handle giant domains with explicitly negotiated rates or sitemaps rather than cheating the limit.
Phase 02

Fault tolerance comes free from the queue — if state lives in the right place

Splitting fetch and parse into separate stages with a queue between them means each stage checkpoints its work. A fetcher that crashes after writing HTML to S3 but before enqueueing loses one page's work, not a batch; the frontier message reappears after the visibility timeout and another worker re-claims it. Messages are deleted only on success — "a dead crawler's work is re-claimed" is the property that makes a 5-day job survivable.

Retries ride the same mechanism: the queue's receive count drives exponential backoff, and after ~5 attempts the URL parks in a dead-letter queue for humans, so one permanently broken page can't burn bandwidth forever. The anti-pattern is in-memory retry timers inside workers — a crash loses every pending retry silently. All durable crawl state lives in the metadata DB and the queues; workers hold nothing worth mourning, which also means I can autoscale them freely — parsers on queue depth, fetchers on bandwidth.

queue workers SQS visibility 5min Worker 1 crashed Worker 2 assigned no ack → re-drop re-assign
worker crashes → SQS visibility timeout returns URL to queue. no state on the worker.
Trade-offVisibility-timeout redelivery gives at-least-once processing, so a page can occasionally be fetched twice — I pay a duplicate fetch and let idempotent writes (same S3 key, same metadata row) absorb it, which is far cheaper than exactly-once machinery.
Phase 03

Dedup twice — URLs and content — and cap depth or the web never ends

URL dedup first: normalise (case, fragments, query-param order, trailing slashes), then check the metadata DB before enqueueing. That stops re-crawling known pages but misses the sneakier duplicate: the same content under different URLs — mirrors, www vs bare domain, session IDs in paths. So parsers also hash page content and check a Bloom filter: ~an order of magnitude less memory than an exact hash set across 10B entries, answering in microseconds. False positives mean we occasionally skip a genuinely new page that hash-collided in the filter — for a crawl, an acceptable loss; if it weren't, I'd confirm positives against an exact hash index in the metadata DB.

Then crawler traps: calendars that link to the next day forever, auto-generated URL spaces that are infinite by construction. No dedup catches URLs that are all technically new. The fuse is a max depth of ~15–20 from the seed, stored per URL and incremented on extraction — real pages are rarely that deep, and traps are cut off mechanically. Belt-and-braces: per-domain page budgets catch traps that stay shallow but sprawl wide.

url url dedup content dedup New URL URL bloom Content hash seen? if not
URL bloom filter blocks re-crawl. content hash catches duplicates from redirects. depth cap stops traps.
Trade-offBloom-filter dedup trades a tiny rate of wrongly-skipped pages for gigabytes-not-terabytes of memory; the depth cap trades a sliver of legitimately deep content for immunity to infinite URL spaces.
Phase 04

The bottleneck nobody names: DNS

At 25k pages/s across millions of distinct domains, every fetch starts with a DNS lookup, and a synchronous resolver round-trip can be 10–100 ms — measured crawls have spent up to 70% of fetch time on DNS. The fix is boring and essential: an aggressive local DNS cache on every fetcher (most crawl bursts hit the same domains repeatedly, so hit rates are high), asynchronous resolution so lookups don't block fetch threads, and multiple upstream providers round-robined — both for capacity and because a single provider will rate-limit a client doing millions of lookups an hour.

This is also where I'd volunteer the scaling asymmetry of the whole system: fetchers scale on bandwidth, parsers on CPU, and they autoscale independently on different signals — fetchers on frontier depth and network utilisation, parsers on parse-queue depth. Coupling them into one monolithic fetch-and-parse service forces you to scale both on the worst of either, which is precisely why the pipeline is staged.

worker dns cache Worker Recursive DNS Local DNS cache per host miss check
DNS resolves once per host; cache the result aggressively. cold DNS blocks whole workers.
Trade-offLocal DNS caching honours TTLs loosely in exchange for speed — a domain that moves IPs mid-crawl gets a few stale fetches, which retry through the normal backoff path; that is much cheaper than paying a resolver round trip per page.
07

Follow-ups you should expect

How would you prioritise which URLs to crawl first?
Make the frontier a priority system, Mercator-style: front queues partitioned by priority — PageRank-ish importance, domain authority, freshness signals — feeding back queues that enforce per-domain politeness. A prioritiser assigns each discovered URL a queue. BFS from seeds is the sensible default and naturally finds important pages early; pure DFS is wrong because it dives straight into traps.
How do you re-crawl for freshness?
Track a per-URL change history: each re-fetch compares content hashes, and an estimator adjusts the re-crawl interval — pages that change often get visited more, static pages decay toward monthly. News homepages might be hourly; a 2009 blog post, quarterly. That schedule feeds the same frontier with a freshness priority band, so the batch crawler becomes a continuous one without new architecture.
What about pages that need JavaScript to render?
A headless-browser fleet — and an order-of-magnitude cost jump: rendering takes seconds of CPU per page versus milliseconds for parsing HTML. I'd keep it out of the main pipeline, detect JS-dependent pages by comparing raw HTML text density against known patterns, and route only those to a small render farm. For most crawl purposes, server-rendered HTML plus sitemaps covers the bulk of valuable content.
How do you avoid getting IP-banned at this scale?
Politeness is the main defence — per-domain caps, robots.txt compliance, an honest User-Agent with a contact URL, and backing off sharply on 429s and 503s. Beyond that: spread fetches for a domain across time rather than bursting, and keep a domain-level circuit breaker that pauses crawling when error rates spike. If a legitimate crawl still gets blocked, the fix is a conversation with the site, not proxy rotation — rotating IPs to evade blocks is adversarial crawling, which we scoped out.
Where does the extracted text go — how do consumers use it?
S3 is the handoff: extracted text lands under a content-addressed key, and the metadata DB row links URL → text object. Downstream, a search indexer or an LLM data pipeline consumes via S3 inventory or a change stream from the metadata table. Keeping the crawler ignorant of its consumers is deliberate — the same crawl output feeds both use cases without the pipeline knowing which.

Where candidates lose the room

  • Treating robots.txt and politeness as trivia to mention in passing — it's a core requirement, it shapes the frontier design, and skipping it reads as "I would DDoS the internet".
  • One monolithic fetch-and-parse service — the stages scale on different resources (bandwidth vs CPU) and the queue between them is what makes the crawl resumable.
  • Pushing HTML bodies through the queues — 20 PB through SQS per stage; queues carry S3 pointers, always.
  • URL dedup only — mirrors and session-ID URLs mean the same content gets stored thousands of times; content hashing is the second, mandatory dedup layer.
  • No depth limit — the first calendar widget the crawler meets becomes an infinite loop; and in-memory retry timers that a crash silently erases instead of queue-based backoff.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.