Design a Web Crawler

Design a crawler that fetches a billion pages a month: the URL frontier, politeness, deduplication with hashes and Bloom filters, DNS and traps.

Case study32 minLesson 1 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions
  10. What to practise next

A search engine can only find pages it has seen. A web crawler is the program that sees them: it starts from a few known URLs, downloads each page, pulls out the links, and adds the new ones to its to-do list. Run that loop a billion times a month and you have the raw material for a search index.

Interviewers like it because the loop is easy and everything around it is not: be polite to other people's servers, avoid fetching a page twice, survive sites that generate endless URLs, and keep hundreds of downloads in flight. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design a web crawler.

You

What is it for: feeding a search index, archiving pages, or something narrower like price monitoring? And do we fetch only HTML, or images and PDFs too?

Interviewer

It feeds a general search index. HTML only.

You

How many pages, and how fresh must they be?

Interviewer

About a billion pages a month. Popular pages should be refreshed more often than obscure ones.

You

Then my priorities are: never overload a website, never waste fetches on duplicates, and spend the fetch budget on the pages that matter most. I'll store the raw HTML and hand it to the indexing team; ranking and search are theirs. I'll respect robots rules and skip pages behind logins.

  • Functional: start from seed URLs; fetch HTML pages; extract links and add new ones; store each page's content; recrawl pages, important ones more often.
  • Non-functional: polite (one request at a time per site, with a gap, robots rules obeyed); about 1 billion pages a month; each URL scheduled once per cycle (a rare lease retry may fetch it twice); survives slow and hostile sites; a crashed machine loses no frontier state.
  • Out of scope: indexing and ranking, JavaScript rendering, images and video, pages that need a login.

Estimate the scale

A month is 30 × 86,400 = 2,592,000 seconds, call it 2.6 million. The crawler sets its own pace, so there is no traffic peak to plan for, only a target rate plus headroom for retries and slow periods.

  • Fetch rate: 109 pages ÷ 2.6 × 106 s ≈ 385 a second. Call it 400, and plan for 800 so a bad day can be caught up.
  • Bandwidth: assume an average HTML page of 100 KB. 400 × 100 KB = 40 MB/s, about 320 Mbit/s. One data center link handles it.
  • Connections in flight: assume a fetch takes about 1 second end to end, because many sites are slow. By Little's law (in-flight = rate × time), 400 a second × 1 s = 400 downloads open at any moment, 800 with headroom.
  • The politeness ceiling: at most one request a second per site, so a site with 1 million pages takes 106 ÷ 86,400 ≈ 11.6 days. Large sites, not bandwidth, limit how fresh you can be.
Estimate: Raw page storage per month

How much storage does one month of raw HTML take, before compression?

You know
  • 1 billion pages fetched a month
  • About 100 KB of HTML per page

Show the working
  1. 109 × 100 × 103 B = 1014 B = 100 TB
  2. HTML compresses well; assume 4x, so about 25 TB stored per monthly snapshot

Estimate 100 TB (anything from 30 TB to 300 TB is a good answer)

This goes to object storage, not a database.

Estimate: Memory for the seen-URL set

The crawler knows about 10 billion distinct URLs. If you store an 8-byte hash of each one to answer "have I seen this URL?", how much memory is that?

You know
  • 10 billion URLs
  • 8 bytes per URL hash
  • No other overhead

Show the working
  1. 10 × 109 × 8 B = 8 × 1010 B = 80 GB

Estimate 80 GB (anything from 30 GB to 300 GB is a good answer)

Easy when split over ten machines. A Bloom filter needs about 12 GB at a 1% false-positive rate; the deep dive weighs the two.

Your turn: Define the API

A crawler has no public users. Write the messages between its parts, and what goes out to the indexing team.

Compare with a model answer

Three: a way to add URLs (seeds from operators and links from the parser, same call), a way for a fetcher to ask for the next URL it may fetch now, and an output record per fetched page. The interesting part is that "next URL" is not "next in line": the frontier decides, based on politeness and priority.

Define the API

Internal interfaces
frontier.add(urls: [ { url, priority, discovered_from } ])
  -> accepted count; URLs already seen are dropped silently
 
frontier.lease(fetcher_id) -> { url, host, lease_id, lease_until }
  -> or "nothing ready, retry in N ms" when every host is cooling down
 
frontier.complete(lease_id, result)
  result: fetched(status, content_hash) | not_modified | failed(reason, retry_after?)
  -> an expired lease is handed out again, so a dead fetcher loses no URL
 
event PageFetched { url, fetched_at, status, content_hash, blob_key }
  -> published to a stream that the indexer consumes

The decision worth arguing is lease, not pop. If a fetcher popped a URL and crashed, the URL would be lost for good, since it is already marked seen and will never be added again. A leased URL goes back out after lease_until. The cost: a slow (not dead) fetcher may download a page another fetcher also downloads. That wastes a fetch but breaks nothing.

Your turn: Model the data

What records does the crawler keep about URLs, about hosts and about page content? Which questions does it ask of each, and how often?

Compare with a model answer

A URL record keyed by a hash of the normalised URL (seen? when to fetch next?), a host record keyed by host name (robots rules, when the next request is allowed), and the page bytes in object storage. The hot question is "have I seen this URL?", asked about 50 times per page fetched, which is why it gets its own structure.

Model the data

Records
-- one row per known URL, partitioned by host hash
urls:   url_hash      BIGINT PRIMARY KEY  -- 8-byte hash of the normalised URL
        url           TEXT                -- up to ~2 KB
        host          TEXT
        priority      SMALLINT
        next_fetch_at TIMESTAMP
        last_status   SMALLINT
        content_hash  BIGINT NULL         -- fingerprint of the last body
 
-- one row per host
hosts:  host            TEXT PRIMARY KEY
        robots_rules    TEXT              -- parsed robots.txt, refreshed daily
        crawl_delay_ms  INT               -- 1,000 unless the site asks for more
        next_allowed_at TIMESTAMP
        ip_cache        TEXT, ip_expires TIMESTAMP
 
-- page bodies: object storage, key = url_hash + fetch date, gzip-compressed

The URL table gets lookups and inserts by hash and a scan for "what is due", with no joins: a wide-column or key-value store (such as Cassandra) partitioned by host fits, so the machine that schedules a host also owns its URLs. Page bodies are large, written once and read in bulk: that is blob storage.

Normalise URLs before hashing, or one page looks like many: lowercase the scheme and host, drop the default port and the #fragment, sort and prune query parameters. HTTP://Example.com:80/a?b=2&a=1#top and http://example.com/a?a=1&b=2 are one page.

Your turn: High-level design

Sketch the loop: where URLs wait, who downloads them, where bodies go, and how new links get back into the queue without duplicates.

Compare with a model answer

A frontier hands URLs to a fleet of fetchers. Fetchers resolve DNS, download, store the body and pass it to parsers. Parsers extract links, drop the ones already seen, and add the rest to the frontier.

High-level design

The crawl is a breadth-first search over the web graph: the frontier is the queue and the seen-URL set is the visited set. Here the queue is ordered by priority and politeness, and the visited set spans machines.

The frontier decides what to fetch next. Fetchers and parsers hold no state, so they scale by adding machines.Seed URLs to URL frontier (add); URL frontier to Fetchers (lease URL); Fetchers to DNS resolver (resolve); Fetchers to Websites (GET page); Fetchers to Page store (store body); Fetchers to Parsers (fetched), asynchronous; Parsers to Seen URLs (seen?); Seen URLs to URL frontier (new URLs).Seed URLsURL frontierDNS resolverFetchersWebsitesPage storeSeen URLsParsersaddlease URLresolveGET pagestore bodyfetchedseen?new URLs

request and replyasynchronous message

The frontier decides what to fetch next. Fetchers and parsers hold no state, so they scale by adding machines.

Follow one URL through the loop.

One URL through the crawl loopUse the arrows or ← → keys
  1. One URL through the crawl loop, step 1Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierlease 60 sDNS resolverFetchersWebsitesPage storeSeen URLsParsers
    in flight
    1

    1The frontier leases bakery.example/menu to a fetcher for 60 seconds: its host's cool-down has passed and it is high priority.

  2. One URL through the crawl loop, step 2Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersrobots: allowedWebsitesPage storeSeen URLsParsers
    in flight
    1

    2The host's robots rules, cached this morning, allow /menu.

  3. One URL through the crawl loop, step 3Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolvercache hitFetchersWebsitesPage storeSeen URLsParsers
    in flight
    1

    3The IP is in the DNS cache, so no lookup. A miss would cost 10 to 200 ms.

  4. One URL through the crawl loop, step 4Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersWebsites200, 92 KBPage storeSeen URLsParsers
    in flight
    1
    bytes
    92 KB

    4It downloads the page: 200 OK, 92 KB, in about 1 second, without blocking a thread.

  5. One URL through the crawl loop, step 5Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersWebsitesPage storeput 23 KBSeen URLsParsers
    in flight
    1
    bytes
    92 KB

    5The body is compressed and stored with its fingerprint, so unchanged pages can be skipped later.

  6. One URL through the crawl loop, step 6Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersWebsitesPage storeSeen URLsParsers
    in flight
    0
    links found
    0

    6A message goes to the parsers. The fetcher completes its lease and moves on without waiting.

  7. One URL through the crawl loop, step 7Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersWebsitesPage storeSeen URLsParsers60 links
    links found
    60

    7A parser pulls 60 links from the HTML, normalises each and hashes it.

  8. One URL through the crawl loop, step 8Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierDNS resolverFetchersWebsitesPage storeSeen URLs48 knownParsers
    links found
    60
    new
    12

    8It sends all 60 hashes to the seen set in one batch, as insert-if-absent. 48 were already there: most links point to pages already found.

  9. One URL through the crawl loop, step 9Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontier+12 URLsDNS resolverFetchersWebsitesPage storeSeen URLsParsers
    links found
    60
    new
    12

    9The 12 new URLs go to their hosts' queues in the frontier. The shard keeps them as seen only once the frontier has them on disk.

  10. One URL through the crawl loop, step 10Seed URLs to URL frontier; URL frontier to Fetchers; Fetchers to DNS resolver; Fetchers to Websites; Fetchers to Page store; Fetchers to Parsers, asynchronous; Parsers to Seen URLs; Seen URLs to URL frontier.Seed URLsURL frontierdelay 4 sDNS resolverFetchersWebsites503Page storeSeen URLsParsers
    host delay
    4 s

    10Later the same host returns 503 twice. The frontier doubles its delay to 2 seconds, then 4: a struggling site gets less traffic from us, not more.

1 / 10

Deep dives

The URL frontier: politeness and priority

A plain first-in, first-out queue fails twice. Pages link mostly within their own site, so the queue fills with runs from one host and the fetchers hit it all at once. And it treats a news home page like a ten-year-old forum thread.

Split the frontier into two layers. The front layer is a handful of priority queues: high for pages that change often or rank well, low for the long tail. The back layer is one queue per host, and each host is in at most one fetcher's hands at a time. A min-heap holds each host keyed by the time it may next be fetched. When a host's queue runs dry, it is refilled from the front layer, picking high priority more often than low.

Leasing URLs from the host heapUse the arrows or ← → keys
host, ready_at = heap.peek()if ready_at > now: wait until ready_atheap.pop(); url = queue[host].pop()lease url to fetcheron complete: heap.push(host, now + delay[host])
  1. heap (host @ ready time)
    0a @ 9.8
    1b @ 10.3
    2c @ 11.0
    now
    10.0 s

    1Three hosts are waiting, each keyed by the time it may next be fetched. The clock says 10.0 s.

  2. heap (host @ ready time)
    0b @ 10.3
    1c @ 11.0
    now
    10.0 s
    busy
    a

    2Host a has been ready since 9.8 s, so its next URL is leased and a leaves the heap: no second fetcher can touch it.

  3. heap (host @ ready time)
    0b @ 10.3
    1c @ 11.0
    now
    10.0 s
    busy
    a

    3Another fetcher asks. The top is b at 10.3 s, still in the future, so it waits 0.3 s.

  4. heap (host @ ready time)
    0c @ 11.0
    now
    10.3 s
    busy
    a, b

    4At 10.3 s b is leased.

  5. heap (host @ ready time)
    0c @ 11.0
    1a @ 11.9
    now
    10.9 s
    busy
    b

    5The fetch from a ends at 10.9 s. With a 1 s delay, a goes back in at 11.9 s. The gap counts from the end of a fetch, so slow sites are fetched less often.

  6. heap (host @ ready time)
    0c @ 11.0
    1a @ 11.9
    hosts
    10 million
    log2
    ≈ 23

    6Every pop and push is O(log h) for h hosts. With 10 million hosts that is about 23 comparisons, so the heap is never the bottleneck.

1 / 6

To scale out, split the frontier by host with consistent hashing over, say, 10 machines. Each host lives on exactly one machine, so politeness needs no coordination. Queues live on disk with a small in-memory head.

For priority, score each page by how often it changed on past fetches and how many known pages link to it.

Deduplicating URLs

Each page adds about 50 links to check, so at 400 pages a second the seen set answers 20,000 lookups a second, nearly all "already seen".

Two details make it correct. The check is insert-if-absent, one atomic step on the shard, so two parsers finding the same link cannot both add it. And a URL counts as seen only once the frontier has stored it, so a crash never leaves a URL marked seen but never queued.

Ways to remember 10 billion seen URLs
OptionGood atBad atPick it when
Full URLs in a databaseExact, and you can list and inspect what you have seen.10 billion URLs at about 100 bytes is 1 TB. Every check is a network read.Small crawls, or as the cold record behind a faster check.
8-byte hashes, sharded in memory Usual default80 GB across 10 machines is 8 GB each. Effectively exact: with 10 billion 64-bit hashes you expect about 3 accidental matches in total.Needs that memory and a lookup per batch over the network.The seen set must not silently drop pages. My default here.
Bloom filterAbout 12 GB for 10 billion URLs at a 1% false-positive rate. Never says "new" for a URL it has seen.1% of truly new URLs are wrongly reported as seen and never crawled. You cannot delete entries.Memory is the hard limit and losing a small share of pages is fine.

A Bloom filter is a big bit array plus k hash functions. Adding a URL sets k bits; checking it looks at the same k bits. If any is 0, the URL is certainly new. If all are 1, it is probably seen, because other URLs may have set those bits. For a 1% false-positive rate you need about 9.6 bits per item and 7 hash functions: 1010 × 9.6 bits ≈ 12 GB.

The catch to say out loud: a false positive is sticky. The same new URL hits the same bits every time it is discovered, so it is skipped every time, for as long as that filter lives. If you use a Bloom filter, rebuild it each crawl cycle with fresh hash seeds so the unlucky URLs change. At this scale I would pick the hash set. At 100 billion URLs (800 GB against 120 GB) the trade flips.

Deduplicating content

Different URLs often serve the same page: mirrors, ?ref= variants, printer-friendly copies. Store a 64-bit fingerprint of each body (8 bytes × 1 billion pages = 8 GB) and skip parsing when it is already known. That catches exact copies only. Pages that differ by a timestamp or an ad need a similarity hash, where similar pages get fingerprints that differ in a few bits. Mention it; design it only if asked.

DNS and fetch throughput

A fetch is mostly waiting. With non-blocking I/O, ten fetcher machines holding 80 open connections each cover the 800 we planned for. The CPU goes to parsing, which is why parsers are a separate fleet.

DNS is the classic hidden bottleneck: many standard resolver calls block a thread, and an uncached lookup can take 10 to 200 ms. Run a caching resolver next to the fetchers, keep each host's IP until its DNS time to live runs out, and resolve a host just before its turn in the heap. If one lookup serves 20 fetches on average, 400 fetches a second need only 400 ÷ 20 = 20 lookups a second.

Robots rules and traps

Before the first fetch from a host, download its /robots.txt and cache the parsed rules for about a day. Obey Disallow paths and any longer delay the file asks for. If the file returns a server error, treat the site as off limits for now; a 404 means no rules.

Some sites generate URLs without end: a calendar with a "next month" link forever, session IDs in every link, filters that combine into millions of pages. Defend in layers: cap URL length (say 2,000 characters) and path depth, cap pages per host per cycle, strip session parameters, and flag hosts where most fetched pages have a fingerprint you have already seen.

Bottlenecks and failures

At 10x (10 billion pages a month, about 4,000 fetches a second), fetchers and parsers scale by adding machines, and bandwidth becomes 400 MB/s, about 3.2 Gbit/s. The seen set heads toward 100 billion URLs, where a Bloom filter starts to earn its loss rate. The real limit is politeness: large sites cannot be fetched faster, so their freshness comes from smarter priority and conditional requests (If-Modified-Since), where a 304 Not Modified costs almost nothing.

When this diesWhat happensWhat you do
A fetcherIts leased URLs are stuck until their leases expire.After 60 seconds the frontier leases them to others. At most a minute of that machine's work is redone.
A frontier machineIts hosts are not crawled; nothing else stops.Queues are on disk and replicated; a standby takes over that host range and replays from the last checkpoint.
A seen-set shardLinks for its hash range cannot be checked.A replica takes over, or the shard is rebuilt from the URL table; parsers buffer and retry meanwhile. Never treat "unknown" as "new".
The DNS resolverCached hosts keep working; new hosts wait.Run two resolvers; the frontier skips hosts whose lookup fails and tries them later.
A websiteTimeouts and 5xx errors.Back off that host exponentially, and after several days of failure drop it to low priority.

What a strong answer sounds like

Everyone draws the loop. The difference is whether politeness and deduplication shape the design.

LevelWhat the answer does
JuniorDraws a queue, fetchers and a database; uses a set for visited URLs; adds a rate limit when asked about overloading sites.
MidEstimates fetch rate and storage, uses per-host queues for politeness, hashes URLs for the seen set, and respects robots rules.
SeniorBuilds the frontier from priority plus per-host scheduling, partitions it by host so politeness needs no coordination, weighs an exact hash set against a Bloom filter with the sticky false-positive cost, leases instead of pops, and names politeness as the limit on freshness.
Defending the seen setStrong answer
Interviewer

Why not just use a Bloom filter? It's much smaller.

You

It is: about 12 GB against 80 GB for 10 billion URLs. But at 1% false positives, one in a hundred genuinely new URLs is reported as seen, and because the same URL hits the same bits every time, it is never crawled this cycle. 80 GB split over ten machines is cheap, so I would rather be exact. If we grew to 100 billion URLs I would switch, and rebuild the filter each cycle so the lost URLs are not always the same ones.

Follow-up questions

How do you decide when to recrawl a page?

Track how often its fingerprint changed over past fetches and schedule around that interval, shorter for important pages. Conditional requests make an unchanged page cost a 304, not a download.

How would you crawl pages that need JavaScript to render?

Send only the pages that need it to a separate pool of headless browsers. Rendering costs far more CPU than parsing HTML, so keep it as a per-site exception.

A site asks you to stop crawling it. What changes?

Add the host to a blocklist the frontier checks before leasing, and purge its queue. Each host lives on one frontier machine, so this takes effect at the next lease.

How would you crawl from several regions?

Give each host to the region nearest its servers, so fetches are short and each host still has one owner. Only new URLs cross regions, to the seen set.

How do you know the crawler is healthy?

Watch fetches per second, the duplicate-content share, error rates per host, and the age of the oldest due URL in the high-priority queue, which tells you whether important pages are going stale.

Self-review: web crawler0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure

What to practise next

Next, Design Video Streaming moves huge files instead of small pages. For the queue ideas here, see Message Queues and Streams.