BackendHard45 min

Design a Web Crawler

Fetch a billion pages a month without hammering any site. A frontier with a queue per host keeps it polite, and a Bloom filter keeps it from repeating itself.

Say it in 30 seconds
  • ~386 pages/sfor 1 billion pages a month
  • ~6 GBBloom filter for 5B URLs at 1%
  • 3,860 hostsin play at a 10 second gap
Show what to say

“I'd run a loop: a frontier hands out one ready URL, a fetcher downloads it, a parser extracts links, and only unseen links go back. The frontier keeps one queue per host so we stay polite, and a Bloom filter remembers billions of URLs in about 6 GB. Hosts are partitioned across nodes so politeness needs no locks.”

Trade-offs

  • Bloom filter vs exact set: about 6 GB instead of 40 GB, but roughly 1% of new URLs are wrongly skipped.
  • Partition by host vs by URL: politeness and state stay on one node, but one huge host cannot be split.
  • Simhash vs exact hash: near-duplicates are caught and stored once, but thresholds need tuning and templates can match.

Bring it up before they ask

Spider traps: a calendar with endless next links defeats both the URL filter and fingerprints, so cap URLs per host, depth and URL length.

0 of 0 sections ticked
30-second answer

Pull URLs from a frontier that keeps one queue per host, fetch and parse them, and push only unseen links back, checked with a . The hard parts are being polite to every site and never crawling the same page twice.

Functional

  • Start from seed URLs and follow links
  • Download each page and store it for the indexer
  • Obey robots.txt and a per-host request rate
  • Skip URLs and near-duplicate pages already seen
  • Re-crawl pages on a schedule based on how often they change

Non-functional

  • About 1 billion pages a month (roughly 400 a second)
  • Polite: at most one request in flight per host, with a delay
  • Survives slow servers, bad HTML and spider traps
  • Scales by adding workers; a crash loses no queued URLs
  • Extensible: new content types plug in after the fetch

Out of scope

  • Indexing and ranking the pages
  • Running JavaScript to render pages (a follow-up)
  • Images and video

Clarifying questions, scale and interface

Read more · 324 words
  • Why crawl? A search index needs the whole web; a price tracker needs ten sites, often.
  • Scale and freshness? Pages a month, and how stale may a page be?
  • Content? HTML only? Do we render JavaScript?

Capacity estimate

QuantityCalculationResult
Fetch rate1B pages ÷ (30 × 86,400 s)≈ 386/s, ~800/s at 2x peak
Raw HTML1B × 100 KB100 TB a month
Download bandwidth386/s × 100 KB≈ 39 MB/s ≈ 310 Mbps
Fetches in flight386/s × 0.5 s per fetch≈ 200 at a time
Hosts needed386/s × 10 s gap between hits on one host≥ 3,860 hosts in play
Distinct URLsassume 5B trackedto remember
Seen set, exact5B × 8 B hash40 GB
Seen set, Bloom at 1%5B × 9.6 bits ÷ 8≈ 6 GB

Bandwidth and CPU are modest. The seen set and the per-host delay drive the design. At a 10 s gap, one host gives only 0.1 page a second, so a fast crawl needs thousands of hosts active at once.

API

A crawler is mostly a pipeline, so the interfaces are internal. Operators see a small admin API.

InterfaceCallNotes
Frontieradd(url, priority)Rejects when the host's budget is full
Frontiernext(now)One ready URL, or how long to wait
Frontierdone(url, delayMs)Starts the host's delay; the URL may retry
Storeput(url, html)Returns a key from the content hash
AdminPOST /crawl/seedsurls[] with a priority
AdminGET /crawl/statsRate, queue depth, errors by host
AdminPOST /crawl/blocklistStop a host that complains

Data model

Pages and hosts live in a wide-column store (Bigtable or Cassandra style), keyed by host so one site's rows sit together. The HTML bodies go to .

-- logical schema; 5B rows, so this is not a single Postgres table
CREATE TABLE pages (
  host         text,
  url_hash     bytea,                 -- first 8 bytes of SHA-256 of the normalized URL
  url          text    NOT NULL,
  content_hash bytea,                 -- key of the body in object storage
  simhash      bigint,                -- near-duplicate fingerprint
  etag         text,                  -- for conditional GETs
  last_fetched timestamptz,
  change_score real,                  -- how often it changed on the last visits
  next_fetch   timestamptz,           -- when to visit again
  PRIMARY KEY (host, url_hash)
);
CREATE TABLE hosts (
  host           text PRIMARY KEY,
  robots_txt     text,
  robots_fetched timestamptz,         -- re-read after 24 h
  delay_ms       int  NOT NULL DEFAULT 1000,
  url_budget     int  NOT NULL DEFAULT 10000
);

Architecture

The loop is: pick a URL, fetch it, store it, parse it, and send new links back to the start. Everything else protects that loop. Play One page, end to end, Politeness, Duplicate page and Spider trap.

Press Play to watch it run · 10 steps

A URL frontier hands out one ready URL at a time, never two to the same host at once. Fetchers check robots.txt and DNS caches, download the page and write it to object storage. A parser extracts links and a fingerprint; a Bloom filter drops URLs already seen, and only new ones go back to the prioritizer and the frontier.

Web crawler architectureA URL frontier hands out one ready URL at a time, never two to the same host at once. Fetchers check robots.txt and DNS caches, download the page and write it to object storage. A parser extracts links and a fingerprint; a Bloom filter drops URLs already seen, and only new ones go back to the prioritizer and the frontier.start hereenqueuenext ready URLresolve, robotsGETpagenew pagefingerprintlinksunseen onlyPer-host queues, split by priority, plus a heap of 'host X may be fetched again at time T'. Persisted, so a restart loses nothing.URL frontierqueue per hostAsync I/O workers. Each takes one ready URL, checks robots.txt, downloads the page with a timeout and reports back so the host's delay starts.Fetchers~200 in flightObject storage. Pages are compressed and keyed by the hash of their content, so a duplicate body is stored once.HTML storeby content hashParserlinks + textExact hash for byte-identical pages, simhash for near-duplicates. A page already seen is kept once and its links are not followed again.Content dedupefingerprintsPrioritizerscore + scheduleA Bloom filter over every normalized URL ever queued: about 6 GB for 5 billion URLs at a 1% false-positive rate. A 'no' is always right; a 'yes' wrongly drops about 1 URL in 100.Seen filterBloom, ~6 GBSeed URLsDNS + robots cacheper hostThe webmillions of hosts

Tap any box to see what it is and what it talks to.

  • Queue
  • Worker
  • Object storage
  • Cache
  • Service
  • External API
Open in playground
Text description
  1. The frontier hands a fetcher https://news.example/a. Its host has not been fetched for 10 seconds, so it is ready.
  2. The fetcher resolves news.example from its DNS cache and checks the cached robots.txt: /a is allowed.
  3. GET /a with the crawler's user agent and a 10 second timeout.
  4. 200 OK, 120 KB of HTML, in 400 ms.
  5. The page is compressed and written to object storage under the hash of its content.
  6. A parse job is queued. The fetcher is already free, and the host's 10 second wait starts now.
  7. The parser fingerprints the text. It has not been seen, so it is kept for the indexer.
  8. It extracts 40 links and normalizes them. Each goes to the Bloom filter.
  9. 28 were queued before and are dropped. The 12 new ones go to the prioritizer, which scores them and sets their priority.
  10. The new URLs join their hosts' queues. The loop closes.

The URL frontier

A plain queue would hand one site's 5,000 URLs to 200 workers in the same second.

Read more · 85 words

So the frontier has two layers.

LayerJobHow
Front queuesWhich URLs matter mostA few priority levels, filled by the prioritizer
Back queuesBe polite to each hostOne queue per host, plus a heap of "ready at" times

The heap says which host is ready, and that host's best URL comes out. The host leaves the heap until the fetch finishes, then returns after its delay. No two workers hit one host together, and a slow host never blocks a fast one.

A queue is a line at a ticket window: people wait in order and are served one at a time.

Seen URLs and duplicate pages

URLs. first, or one page arrives under five spellings.

Read more · 89 words

Then ask a : "definitely new" or "probably seen". At 1% error it needs 9.6 bits a URL: about 6 GB for 5 billion, against 40 GB exact. It wrongly drops about 1 new URL in 100.

Content. Mirrors repeat text at new URLs. An exact hash catches identical pages. A simhash catches near-copies: their fingerprints differ in only a few bits.

A Bloom filter is a bouncer with a bit array: he can say 'definitely not' or 'maybe', never a sure yes.

Politeness, robots and traps

Good manners are part of the spec, not polish.

Read more · 148 words
  • robots.txt: fetch it first for every host, it for 24 hours, obey Disallow and Crawl-delay.
  • Delay: wait the larger of Crawl-delay and ten times the last fetch time. On a 429 or 503, double it.
  • Identity: a clear user agent with a contact URL, so owners can reach you.
  • Traps: calendars, session ids in URLs and endless redirects. Cap URLs per host, cap depth, cap URL length and redirect chains.
  • : cache lookups; a blocking resolver is the usual hidden bottleneck.

The same fetch as a sequence. Pick a branch: the page loads, the host is overloaded, or robots.txt says no.

Press Play to watch it run · 8 steps

The worker asks the frontier for a ready URL, checks robots.txt, then fetches. A normal page is stored; an overloaded host makes the frontier slow down; a disallowed URL is dropped.

One fetch, three outcomesThe worker asks the frontier for a ready URL, checks robots.txt, then fetches. A normal page is stored; an overloaded host makes the frontier slow down; a disallowed URL is dropped.next()https://news.example/aallowed(/a)?alt [200 OK]yes, Crawl-delay 10 sGET /a200 + HTMLput(page)done(url, delay 10 s)The host is out of the heap for 10 seconds
  • Queue
  • Worker
  • Cache
  • External API
  • Object storage
Text description
  1. Fetcher → Frontier: next()
  2. Frontier replies to Fetcher: https://news.example/a
  3. Fetcher → Robots cache: allowed(/a)?
  4. Robots cache replies to Fetcher: yes, Crawl-delay 10 s
  5. Fetcher → The site: GET /a
  6. The site replies to Fetcher: 200 + HTML
  7. Fetcher → HTML store: put(page)
  8. Fetcher sends (async) to Frontier: done(url, delay 10 s)

How to approach it

  1. Clarify

    Why crawl, how fast, how fresh, which content.

  2. Estimate

    386 pages a second, 6 GB for the seen set, 3,860 hosts in play.

  3. Draw the loop

    Frontier, fetch, store, parse, filter, back to the frontier.

  4. Go deep on two parts

    The frontier's politeness, and at scale.

  5. Failure and scale

    Traps, slow hosts, crashes, partitioning by host.

Read more · 443 words

Step-by-step answer

  1. Name the two hard parts

    "Being polite to millions of different servers, and not repeating work across billions of URLs." Everything else is plumbing.

  2. Build the frontier from two layers

    Priority queues in front, one queue per host behind, and a heap of next-allowed times. This is the part to draw.

  3. Fetch defensively

    Resolve through a , check cached robots.txt, set a connect and read timeout, cap the body size, and treat redirects as new URLs that go back through the frontier.

  4. Store, then parse off the critical path

    Write the body to under its content hash and queue a parse job. A slow parser never holds a fetcher.

  5. Normalize and filter links

    Resolve relative links, lower-case the host, drop fragments and tracking parameters, sort the query. Then the . Only unseen URLs reach the prioritizer.

  6. Fingerprint the content

    Exact hash first, then simhash. A near-duplicate is stored once, and its outgoing links are not trusted to be new.

  7. Decide when to come back

    Use conditional GETs (ETag, If-Modified-Since): a 304 costs no body. If a page was unchanged on the last visits, double its interval; if it changed, halve it, within bounds like one hour to thirty days.

  8. Scale out by host

    Hash the host to pick the crawler node that owns it. Its queue, delay timer, robots cache and slice of the Bloom filter all live there, so politeness needs no cross-node lock. A link to another node's host goes through a topic partitioned by host. When a node dies, moves only its hosts.

  9. Say what you would watch

    Pages a second, queue depth by priority, error rate by host, the share of 429s, and the duplicate rate. A rising duplicate rate usually means a trap.

Implementation

Tabs: URL normalizer, with sizing, the frontier, a robots.txt parser, simhash, and the fetch worker that ties them together.

Read more · 61 words
  • bloomSize(5e9, 0.01) gives the 6 GB figure; the test checks it, and the measured false-positive rate.
  • The frontier test proves one host is never handed out twice inside its delay.
  • simhash tests show a page with an ad added is within 3 bits, an unrelated page is far.
  • The worker is display-only: it needs the network and a store.
// Two spellings of one page must become one string, or the "seen" check lets the same page in twice.
// Rules: lower-case scheme and host, drop the fragment and the default port, resolve ../ segments,
// drop tracking parameters, sort the rest, and trim a trailing slash on non-root paths.
const TRACKING = /^(utm_|fbclid$|gclid$|ref$|sessionid$)/i;

export function normalize(raw, base) {
  let u;
  try {
    u = new URL(raw, base);
  } catch {
    return null;                                   // not a URL at all
  }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;   // mailto:, javascript:, tel:
  u.hash = '';
  // The URL class already lower-cases the host and drops :80 / :443 for the matching scheme.
  const kept = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k));
  kept.sort(([a, av], [b, bv]) => (a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0));
  u.search = '';
  for (const [k, v] of kept) u.searchParams.append(k, v);
  if (u.pathname.length > 1 && u.pathname.endsWith('/')) u.pathname = u.pathname.slice(0, -1);
  return u.toString();
}

// The host is the unit of politeness: all URLs of one host share one queue and one delay.
export const hostOf = (url) => new URL(url).host;

Failure modes and trade-offs

Read more · 213 words
What breaksEffectMitigation
A host answers slowly or hangsWorkers pile up on itTimeouts; one in flight per host; back off
A spider trapFrontier fills with junkPer-host budget; depth and URL length caps
A crawler node diesIts hosts stop being fetchedPersist the frontier; reassign hosts by consistent hashing
DNS resolver is slowThroughput collapsesLocal DNS cache; async resolver
Bloom filter fills upFalse-positive rate climbsSize for growth; rebuild by epoch from the page table
A site complainsLegal and reputation riskHonor blocklists; contact page in the user agent
The parser falls behindPages wait in storageQueue depth alert; scale parsers; fetchers keep going

Trade-offs

ChoiceGainCost
Bloom filter for seen URLs6x smaller than exact hashesDrops about 1% of new URLs
Partition by hostPoliteness and state stay localA hot host cannot be split
BFS-like orderFinds important pages earlyNeeds a prioritizer, not plain FIFO
Simhash dedupeCatches near-copiesTuning; false matches on templates

Follow-up questions

How do you crawl JavaScript-heavy pages?

Send them to a separate pool running a headless browser. It is far slower, so use it only for hosts that need it.

How do you keep the index fresh?

Estimate each page's change rate from past visits and schedule the next visit from it. Conditional GETs make an unchanged revisit cheap.

What if the Bloom filter says seen for a page that is new?

That is the 1% false positive: the page is skipped. Another inbound link usually brings it in later. Seeds bypass the filter.

How do you shard the frontier?

By host, with , so queues and timers stay on one node. Links to other hosts travel on a topic keyed by host (sharding strategies).

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. Why does the frontier keep one queue per host instead of one big FIFO queue?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.