Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

System Design

Design a Web Crawler

Step 1 / 9

Learn system design by building a distributed web crawler step by step.

The numbers to beatbillionsURLs to trackO(1)seen check~bitsper URL

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is a web crawler?

A crawler downloads the web — start from a few URLs, fetch each page, find its links, fetch those too, forever. Trivial as a weekend script; brutal at billions of pages, where you must be fast, polite, dedup-aware, and never get stuck in a loop or a trap.

Seed URLsstarting set
New in this step: Seed URLs.

Model it as one big loop over a queue: pull a URL, fetch it, extract links, enqueue the new ones. Then make that loop scale, behave, and stay efficient. Everything we add defends that simple loop against the messy reality of the web.

What the new pieces do

Seed URLsinput
The initial list of URLs to crawl from — a few well-connected pages from which the rest of the reachable web unfolds, link by link.

Step 1 · The loop

Fetch, parse, enqueue

At its heart a crawl is a graph traversal: you have URLs to visit, you visit one, it reveals more URLs, repeat. You need somewhere to hold the “to-visit” list and workers to do the visiting.

URL Frontierwhat to crawl nextFetcher Workersdownload pagesParserextract links
New in this step: URL Frontier, Fetcher Workers, Parser.

A crawl reveals more URLs as it fetches. How do you structure the loop?

  1. Deep recursion blows the stack, can’t be distributed, and couples fetch to parse so neither scales. The to-visit set needs to be explicit state, not the call stack.

  2. A single sequential worker can’t approach billions of pages, and mixes network-bound fetching with CPU-bound parsing. You need a shared queue and many workers.

  3. The frontier holds what to crawl next; fetchers download, the parser extracts links and pushes new ones back. Decoupling fetch (network-bound) from parse (CPU-bound) lets each scale on its own.

A URL Frontier holds what to crawl next. Fetcher workers pull from it and download pages; the Parser extracts links and pushes the new ones back into the frontier. Seeds prime the loop and it runs itself from there.

Why this piece earns its place

The piece this step quietly assumes is that a URL leaves the frontier and comes back as links. Between those two moments it lives nowhere durable, and a fetcher can die in that gap — mid-download, or after downloading but before the parser has extracted anything. The URL is simply gone, and nothing notices: no error, no retry, just a page the crawl never visits. You cannot recover it by rediscovery either, because the seen-check added next marks a URL the moment it is enqueued, so the next link pointing at it is dropped as already-handled. So the frontier has to be a queue with leases and acknowledgements, not a pop: a worker takes a lease on a URL, and the URL only really leaves when the parser has committed its output. A crashed worker’s lease expires and the URL is redelivered. That makes delivery at-least-once rather than at-most-once, which is the right way round here — fetching a page twice costs one extra request, losing it costs it forever.

What the new pieces do

URL Frontierbackend
The brain of the crawler: the queue of URLs waiting to be fetched. Its ordering decides what gets crawled, how soon, and how often.
Fetcher Workersworker
Pull URLs from the frontier and download the HTML over HTTP. A large, mostly-waiting-on-the-network fleet — the crawler’s muscle.
Parserworker
Reads each downloaded page, pulls out the links, normalizes them, and feeds the new ones back into the frontier — that loop is the crawl.

Step 2 · Don’t crawl forever

Have I seen this URL?

The web is a cyclic graph — pages link back and forth endlessly. Without memory, the crawler re-queues the same URLs over and over and never makes progress, drowning in duplicates.

URL FrontierFetcher WorkersParserSeen Set
New in this step: Seen Set. · swipe to pan the diagram

The web is cyclic — pages link back and forth. How do you avoid re-crawling forever, at billions of URLs?

  1. An exact set of billions of URLs is hundreds of GB — too big for memory on every frontier node. You need a far more compact "have I seen this?" structure.

  2. A bloom filter answers "probably seen / definitely not" in O(1) using a few bits per URL. It may rarely skip a new URL but never re-crawls a seen one — "mostly right and tiny" beats "exact and enormous".

  3. Without an up-front seen-check the crawler loops on cycles and never makes forward progress — it drowns in duplicates before any dedup stage runs. Termination has to happen at enqueue time.

Before enqueuing a link, check a Seen Set. At billions of URLs, an exact set is too big for memory, so use a bloom filter: O(1) “probably seen / definitely not seen” in a fraction of the space, backed by a durable set for certainty.

Why this piece earns its place

The filter comes with a dial — bits per URL, which sets the false-positive rate — and both directions of getting it wrong cost something specific. Too few bits and the rate climbs, and every false positive is a real page the crawler silently declines to visit. Nothing logs it; coverage just quietly erodes while every dashboard looks healthy. Too many bits and you are walking back toward the exact set you rejected for being too big to hold per node. What makes the choice harder is that a bloom filter has no delete and its error rate is a function of how full it is, so it degrades as the crawl succeeds: sized for the URLs you have today, it is wrong by the time you have billions. Size it for the count you intend to reach, not the one you are at, and plan to roll onto a fresh, larger generation. Where coverage matters more than memory, confirm a positive against the durable set behind it — that turns a false positive into an extra lookup rather than a lost page, at the cost of a disk read on every hit.

  • billionsURLs to track
  • O(1)seen check
  • ~bitsper URL

What the new pieces do

Seen Setstore
Remembers which URLs have already been queued so the crawler doesn’t loop forever. A bloom filter answers “seen this?” in O(1) using tiny memory.

Back of the envelope

billions of URLs, exact set ⇒ 100s of GB
too big to hold in memory per node
bloom filter ⇒ ~a few bits / URL
O(1) check in a fraction of the space
rare false positive
may skip a new URL, never re-crawls a seen one

Step 3 · Be a good citizen

Politeness & robots.txt

Thousands of fetchers all hammering one small site at once is indistinguishable from an attack — you’ll knock it over and get your crawler banned. And many sites declare what you may and may not crawl.

URL Frontierwhat to crawl nextFetcher Workersdownload pagesParserextract linksPolitenessrobots · rate/host
New in this step: Politeness.

Thousands of fetchers could hammer one small site at once. How do you not look like an attack?

  1. A global cap still lets all that traffic pile onto one unlucky host while big sites are under-crawled. Politeness is a per-domain promise, not a global throttle.

  2. Maximum speed per host is exactly the DDoS that knocks small servers over and gets your IPs banned — ending your crawl. Speed must be bounded per host.

  3. Obey each site’s rules and pace requests per domain, while crawling thousands of hosts simultaneously. Gentle on each, fast in aggregate — and the reason hosts become the partition unit later.

Add a Politeness layer: honor each host’s robots.txt, and enforce a per-host rate limit with a delay between requests to the same domain. Group the frontier by host so one site’s crawl is paced independently of others.

Why this piece earns its place

Politeness quietly changes what limits throughput. Before this step the crawl rate was a function of how many fetchers you ran; after it, it is a function of how many distinct hosts are ready to be fetched right now. If the frontier hands out URLs in discovery order, a worker that draws a URL from a host it just hit has to wait out that host’s delay, and the fleet ends up idling behind a handful of popular domains while millions of URLs on other hosts sit unfetched. So the frontier stops being one queue: keep a back-queue per host and a schedule keyed by each host’s next-allowed time, and only ever hand a worker a host whose delay has already elapsed. Fetchers should never block — they should only be given work that is legal to do. The other thing to check is that a host is not a machine. Plenty of hostnames resolve to one shared server, so a per-host promise kept perfectly can still land as one pile of traffic on it; pacing by resolved IP as well as by name is what makes the promise real.

  • robots.txtobeyed
  • 1/hostrate limited
  • parallelacross hosts

What the new pieces do

Politenessguard
Obeys each site’s robots.txt and caps requests per host, so the crawler is a good citizen and doesn’t accidentally DDoS a small server.

Step 4 · The hidden bottleneck

DNS at scale

Every fetch needs a hostname resolved to an IP. DNS lookups are slow (tens of ms) and, at billions of requests, become the dominant cost — and can hammer DNS servers as hard as you hammer websites.

Fetcher Workersdownload pagesDNS Cachehost → IP
New in this step: DNS Cache.

Every fetch resolves a hostname to an IP, and DNS is slow (tens of ms). At billions of fetches, what breaks?

  1. At billions of requests, tens-of-ms lookups become the dominant cost and can hammer DNS servers as hard as you hammer sites. Ignoring DNS makes it the silent bottleneck.

  2. Most pages on a site share one IP, so the first lookup serves thousands of fetches; async resolution keeps fetchers from blocking. The unglamorous infra often decides throughput more than scheduling.

  3. IPs change and rotate (CDNs, load balancers), so hard-coding breaks constantly and silently fetches the wrong server. You need caching with a TTL, not static mappings.

Put a DNS Cache in front of resolution and resolve asynchronously so fetchers don’t block. Cache by host with a TTL; most pages on a site share one IP, so the first lookup serves thousands of fetches.

Why this piece earns its place

The TTL on this cache is the knob, and the honest answer is that you cannot simply honor the record’s own. Large sites sit behind CDNs and publish very short TTLs precisely so they can move traffic, and at this fetch volume that means re-resolving the hottest hosts constantly — the cache stops being a cache exactly where it would have paid best. Override it upward and you get the opposite failure: you keep connecting to an address the site has moved off, and that does not arrive as a clean error, it arrives as refused connections or as somebody else’s server answering on a recycled address. The workable setting is a floor and a ceiling of your own over whatever the record says, with a shorter ceiling for hosts that have already shown you they move. Two things are easy to leave out. Cache the failures too, or a host that does not resolve gets retried once for every URL of its queued behind it. And resolve a given hostname one request at a time — when a popular host’s entry expires, every fetcher holding one of its URLs misses in the same instant and they all ask at once.

What the new pieces do

DNS Cacheservice
Resolving hostnames is slow and would dominate latency at billions of fetches. A cache (and async resolver) keeps lookups from becoming the bottleneck.

Back of the envelope

lookup ≈ tens of ms
dominant per-fetch cost at billions of fetches
1 host ⇒ ~1 IP
first lookup serves thousands of fetches
cache + async resolve
keeps the fetch pipeline full, fetchers unblocked

Step 5 · Keep what you fetched

Store & dedup content

Downloaded pages must be stored for downstream indexing — but the web is full of duplicate content: mirrors, print versions, session-ID URLs. Different URLs, identical pages. Storing them all wastes enormous space and indexing effort.

Fetcher Workersdownload pagesParserextract linksSeen Setbloom filterDNS Cachehost → IPContent Storeraw HTMLContent Hashesnear-dup detect
New in this step: Content Store, Content Hashes.

Mirrors, print versions and session-ID URLs deliver identical pages under different URLs. How do you not store the web twice?

  1. The bloom filter dedups URLs, not content — a brand-new URL can still return a page you’ve stored a thousand times. URL-dedup and content-dedup are different jobs.

  2. Comparing against the entire corpus per page is impossibly expensive. You need a compact fingerprint to check, not a full-content comparison.

  3. A content hash (or near-dup signature like SimHash) checked against seen Content Hashes catches mirrors and boilerplate that are different URLs but the same page — saving storage and indexing effort.

Write raw pages to a Content Store (blob storage). Compute a content fingerprint (a hash, or a near-dup signature like SimHash) and check it against seen Content Hashes — skip pages whose content you already have under another URL.

Why this piece earns its place

A plain hash of the bytes is weaker than it sounds. Rotate an ad, stamp a render time into the footer, bump a visitor counter, and a perfect mirror hashes to something new — the pages you most want to catch are the ones that differ by a handful of bytes. That is what the near-duplicate signature is for, and it brings a threshold with it: a similarity above which two pages count as the same. Push it too far and you lose real pages in a specific, damaging shape, because sites built from one template share nav, sidebar and footer. Two genuinely different product pages can look near-identical to a naive signature while the part that actually differs — a few lines of body text — is swamped by the boilerplate around it. So fingerprint the extracted main content rather than the raw document, and set the threshold against pages you already know are distinct instead of picking a number that sounds careful. And be precise about what this buys: the fetch has already happened. Content dedup saves storage and indexing work, not bandwidth.

What the new pieces do

Content Storestore
Durable storage (blob store) for the fetched pages — the actual payload downstream indexers and analyzers consume.
Content Hashesstore
Fingerprints of page content. Catches mirror sites and boilerplate that are different URLs but the same page, so you don’t store the web twice.

Step 6 · Not all pages are equal

Prioritize the frontier

Pure BFS treats a spam page like the homepage of a major news site, and never decides when to re-crawl a page that changes hourly versus one that never changes. Crawl budget is finite; spending it evenly is wrong.

Seed URLsURL FrontierFetcher WorkersParserSeen SetPolitenessDNS CacheContent StoreContent Hashes
The system as it stands at this step. · swipe to pan the diagram

Make the frontier a priority queue: rank URLs by importance (PageRank-ish signals, depth) and schedule re-crawls by how often a page actually changes. Important, fast-changing pages get crawled sooner and more often.

Why this piece earns its place

Priority introduces starvation, and a re-crawl schedule that learns from observation turns starvation into a loop it cannot climb out of. A page’s change-rate estimate only improves when you fetch it again; a page ranked low is never fetched again; so its estimate never moves, and the page stays low on evidence you stopped collecting. The same trap sits on the importance side, because link-based signals only see pages you have crawled — a poorly linked corner of the web is invisible because it is uncrawled, and uncrawled because it looks unimportant. Two defenses. Put age into the priority so anything waiting long enough eventually rises whatever its score, and reserve a slice of crawl budget for exploration rather than giving all of it to the top of the queue. Then make re-crawls cheap enough that you can afford to be wrong about them: a conditional request carrying the stored validator lets a server answer not modified without sending the body, so you can probe a page’s change rate far more often than you could afford to download it.

Step 7 · Scale & traps

Shard out, avoid traps

One machine can’t hold the frontier or fetch the web. And the web is hostile: crawler traps (infinite calendars, endless query-param permutations) can trap a naive crawler forever in one site.

URL FrontierFetcher WorkersParserSeen SetPolitenessContent HashesSharded Frontier
New in this step: Sharded Frontier. · swipe to pan the diagram

One machine can’t hold the frontier or fetch the whole web. How do you shard it while keeping politeness?

  1. Random sharding scatters one host’s URLs across many nodes, so no single node can enforce that host’s rate limit — politeness breaks. The shard key has to keep a host together.

  2. Hashing the host to a shard puts all of a site’s URLs — and its rate limit — on one node, so per-host politeness stays local and correct. Add nodes to crawl more hosts in parallel.

  3. Full replication means every node sees every URL and they’d duplicate work or need constant coordination to divide it. You want to partition the frontier, not copy it everywhere.

Shard the frontier by host across many machines so each shard owns some domains (keeping politeness local) and the fleet scales horizontally. Defend against traps with depth limits, URL-length caps, parameter normalization and per-host page budgets.

Why this piece earns its place

Sharding by host buys politeness; it does not buy balance, and it is worth saying so before someone measures it. Hosts are wildly unequal — a few sites hold an enormous share of the web’s pages while most hold a handful — so hashing hostnames spreads hosts evenly across nodes and URLs not at all. One shard draws a giant site and becomes the tail that decides when a crawl round ends, and it cannot work its way out, because the per-host rate limit from the politeness step caps exactly the queue that is backed up. Two things follow. Make shards far more numerous than machines and keep the shard-to-machine mapping a lookup table rather than a modulo, so a heavy shard can be moved without rehashing the world — its hosts’ back-queues and seen-set slice travel with it. And treat the per-host page budget introduced here as the real bound rather than a trap-only defense: it is what stops any one host, hostile or merely enormous, from owning a shard indefinitely.

What the new pieces do

Sharded Frontierbus
The frontier spread across many machines, partitioned by host so per-host politeness is local and the whole thing scales horizontally.

Back of the envelope

partition by host (hash)
a site’s URLs + rate limit live on one node
add nodes ⇒ more hosts in parallel
horizontal scale, politeness preserved
depth / param / length caps
defuse infinite calendars and query-param traps

You did it

You just designed a web crawler.

Seed URLsURL FrontierFetcher WorkersParserSeen SetPolitenessDNS CacheContent StoreContent HashesSharded Frontier
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A URL frontier feeding fetch → parse → enqueue: the core crawl loop.
  • A bloom-filter Seen Set stops infinite re-crawling of the cyclic web graph.
  • Politeness: obey robots.txt and rate-limit per host so you’re not an attack.
  • A DNS cache + async resolution removes the hidden per-fetch bottleneck.
  • A content store plus content-hash dedup avoids storing mirrors and boilerplate twice.
  • A priority frontier balances importance and re-crawl freshness, not blind FIFO.
  • Shard the frontier by host to scale out, with depth/param limits to dodge traps.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. How do you decide when to re-crawl a page?

    Estimate each page’s change rate from history (a news homepage changes hourly, an archived post never) and schedule re-crawls adaptively, often via a Poisson change model. The frontier priority blends importance and predicted freshness so crawl budget goes where it matters, not evenly.

  2. How do you handle JavaScript-rendered pages?

    A plain HTTP fetch only sees the initial HTML; SPA content needs a headless browser to render — 10–100× more expensive. So you don’t render everything: detect JS-dependent pages and route only those to a smaller headless-render fleet, keeping the cheap path for the static majority.

  3. What exactly is a crawler trap and how do you escape?

    A trap generates effectively infinite URLs — an endless calendar (?date=…), faceted-search permutations, or session ids minting a new URL each visit. Defenses: depth limits, URL-length and parameter-count caps, parameter normalization, per-host page budgets, and detecting low-value near-duplicate explosions.

  4. Bloom-filter false positives skip real pages — acceptable?

    Yes, by design: a false positive means a genuinely new URL is occasionally treated as "seen" and skipped — you miss a page. A false negative (re-crawling) never happens. Missing a tiny fraction of pages is a fine trade for the memory savings; back the filter with a durable exact set to confirm hits if needed.

  5. How do you keep fetchers busy under per-host rate limits?

    Breadth across hosts. Because politeness is per-host, the frontier interleaves URLs from thousands of domains so each host is fetched gently while the fleet stays saturated. Connection reuse (keep-alive) and async I/O let one worker handle many in-flight fetches instead of blocking on a slow host.

Check yourself — the answers, and why

Eight steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. The core of a crawler is…

    The frontier is the to-visit queue; decoupling fetch (network) from parse (CPU) lets each scale.

  2. A bloom-filter Seen Set exists to…

    O(1) "probably seen" in a few bits per URL — may skip a new URL, never re-crawls a seen one.

  3. Politeness (robots.txt + rate limits) is enforced…

    It’s a per-domain promise — crawl many hosts in parallel, gently on each.

  4. URL dedup and content dedup are…

    The bloom filter dedups URLs; content fingerprints catch mirrors/boilerplate under new URLs.

  5. The frontier is sharded by host so that…

    Partition-by-host keeps per-host politeness local while scaling out across machines.

Worked example: one discovered link, from parse to stored page

The parser has pulled 120 links from a news homepage, in a crawl tracking 5 billion URLs (both illustrative, as are 1 req/s per host and 100-byte URLs). We follow one link, news.example/a/123.

StepWhereWhat happensNumbers
1ParserNormalize: lowercase the host, drop the #fragment, strip session parameters, cap length and parameter count.120 raw links → 120 canonical URLs
2Seen SetBloom-check each URL; /a/123 is definitely not seen, so it is marked and passed on.10 bits/URL × 5B = 6.25 GB, vs ~500 GB exact; FP ≈ 0.82% (k = 7)
3Sharded FrontierHash the host to the shard that owns news.example. The URL joins that host’s back-queue, ranked by importance and change rate.1 shard; host stays with its rate limit
4Politenessrobots.txt (cached) allows /a/. The scheduler hands it out only once the host’s next-allowed time has passed.1 req/s to this host
5Fetcher → DNS CacheA fetcher takes a lease on the URL. The host was resolved for an earlier page, so this is a cache hit.0 ms; a miss is tens of ms (~30 ms, illustrative), so 1,000 uncached pages would spend 30 s on lookups
6FetcherDownload with async I/O, one of many fetches in flight.network-bound, not CPU-bound
7Content HashesSimHash the extracted main content (not nav and footer) and compare it against stored fingerprints. No near-duplicate is found.saves storage, not the fetch
8Content StoreWrite the raw HTML blob. The parser extracts its links and commits, and only then is the lease acknowledged.at-least-once; a crash means redelivery
9Frontier (later)The re-crawl goes out as a conditional request carrying the stored validator; if the page is unchanged, the answer is a body-less “not modified”.cheap probes teach the change rate

No step here is expensive. Step 4 limits throughput: crawl rate is hosts ready now × each host’s limit. A host with 1,000,000 pages at 1 req/s needs 11.6 days, and its shard becomes the tail of every round. Step 7 handles that with many small shards behind a lookup table, plus a per-host page budget. At 10× the URLs the first break is silent: a filter sized for 5B holding 50B has ~1 bit per URL, its false-positive rate goes from 0.82% to 99.4%, and the crawler skips nearly every new page. Step 2 handles that by sizing for the target count and rolling onto a larger filter generation.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • The loop: from seed URLs, fetch a page, extract its links, enqueue the new ones — forever.
  • Don’t loop: never re-crawl a URL you’ve already queued on the cyclic web graph.
  • Be polite: obey each site’s robots.txt and cap requests per host.
  • Store pages: persist fetched HTML for downstream indexers, without keeping duplicates.
  • Prioritize: crawl important, fast-changing pages sooner and re-crawl by freshness.

The qualities that shape everything

Each one names the mechanism that buys it.

Scale to billions of pages
Shard the frontier by host across many machines and run a large fetcher fleet — fetch (network-bound) and parse (CPU-bound) decoupled so each scales on its own.
Never loop forever on the cyclic graph
A bloom-filter Seen Set answers “seen this?” in O(1) at a few bits per URL — it may rarely skip a new URL but never re-crawls a seen one.
Don’t look like an attack (and get banned)
Per-host rate limits and robots.txt, crawling thousands of hosts in parallel — gentle on each, fast in aggregate.
DNS is not the hidden bottleneck
A DNS cache keyed by host with a TTL, resolved asynchronously; most pages on a site share one IP, so the first lookup serves thousands of fetches.
Don’t store the web twice
Fingerprint page content (a hash or SimHash) and skip mirrors, print versions and session-ID URLs that differ only in the URL.
Survive hostile pages
Depth limits, URL-length and parameter caps, and per-host page budgets defuse crawler traps like infinite calendars and query-param explosions.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Bloom-filter Seen Set over an exact hash set

An exact set of billions of URLs is hundreds of GB — too big for memory per node. A bloom filter is “mostly right and tiny”: a rare false positive skips a new URL, but it never re-crawls a seen one.

Per-host politeness over a global request cap

A global cap still lets all that traffic pile onto one unlucky small host. Politeness is a per-domain promise — pace each host, run many hosts in parallel.

Content fingerprint over URL dedup alone

The Seen Set dedups URLs, not content — a brand-new URL can still return a page you’ve stored a thousand times. A content hash catches mirrors and boilerplate the URL check can’t.

Priority frontier over blind FIFO / BFS

Pure BFS treats a spam page like a major homepage and never decides when to re-crawl. Encoding importance and change-rate into frontier priority is what separates a toy from a search-engine crawler.

Shard by host over shard by URL

Random URL sharding scatters one host’s URLs across nodes, so no node can enforce that host’s rate limit — politeness breaks. Hashing the host keeps a site’s URLs and its rate limit together on one node.

The answer, out loud

What a strong answer to “Design a Web Crawler” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    What we’re actually building

    Let me make sure I have the problem. We’re building something that downloads the web: from some seed URLs, fetch a page, pull the links out, fetch those, keep going. The weekend-script version is about twenty lines. What makes it a design question is the scale — billions of pages — and that the web is hostile to the naive version. It’s a cyclic graph, so a simple traversal never terminates. And the sites I’m crawling never asked to be crawled, so if I get that wrong I get banned and the rest of the design is worth nothing. I’ll build the loop first, then spend most of the time defending it.

  2. 3–9 min

    The loop itself

    The core is a queue I’ll call the URL frontier — the list of what to crawl next. Fetcher workers pull from it and download pages; a parser reads each page, extracts and normalizes the links, and pushes the new ones back in. Seeds prime it and after that it feeds itself. I’m deliberately not doing this recursively: the to-visit set has to be explicit state I can distribute, not a call stack. And I split fetching from parsing because they’re different work — fetching waits on the network, parsing is CPU — so I can size each stage for what it needs.

    Built in step 1: Fetch, parse, enqueue
  3. 9–15 min

    Making it terminate

    The first thing that breaks is termination. The web graph has cycles, so links keep pointing back at pages I’ve already queued and the crawler goes round and round making no progress. So before I enqueue a link I ask whether I’ve seen that URL. The obvious answer is a hash set of every URL, and at billions of URLs that’s hundreds of gigabytes — I can’t hold that in memory on every frontier node. So I’d use a bloom filter: probably-seen or definitely-not, in constant time, at a few bits per URL. It can occasionally tell me I’ve seen something I haven’t, and I’ll take that, because it never lets me re-crawl something I already did.

    Built in step 2: Have I seen this URL?
  4. 15–21 min

    Not looking like an attack

    Now the part I’d raise before I’m asked, because it’s the one that ends crawls. If thousands of my fetchers hit one small site at once, that is a denial-of-service attack from the site’s point of view. I knock them over, my IP range gets banned, and that costs me the web, not one site. So I read each host’s robots.txt and honor it, and I delay between requests to the same domain. The shape of the promise matters: per host, not a global cap, because a global cap still lets it all pile onto one unlucky server.

    Built in step 3: Politeness & robots.txt
  5. 21–26 min

    The boring bottleneck

    There’s an unglamorous thing that decides throughput more than any of the clever scheduling, and that’s DNS. Every fetch needs a hostname turned into an address, and a lookup is tens of milliseconds. At this volume that’s not a rounding error, it’s the dominant per-fetch cost, and I’d be hammering DNS servers as hard as I’m hammering websites. So I cache resolutions by host and resolve asynchronously so workers aren’t blocked. It works unreasonably well because a site’s pages nearly all share one address, so the first lookup serves thousands of fetches.

    Built in step 4: DNS at scale
  6. 26–32 min

    Storing it without storing it twice

    Pages go into blob storage, since downstream indexers are why we’re fetching anything. But I don’t want to store the web twice, and I will if I’m careless: mirrors, print versions, the same article under a session id. My seen set doesn’t help here, and the reason is worth saying out loud: it dedups URLs, and this is a content problem. A brand-new URL can hand me a page I already have a thousand copies of. So I fingerprint the content, a straight hash or a near-duplicate signature, and check that before I store.

    Built in step 5: Store & dedup content
  7. 32–38 min

    Deciding what deserves the budget

    Up to here the frontier is a plain queue, so the crawl is breadth-first, which means it treats a spam page exactly like the front page of a major newspaper. It also says nothing about coming back: when do I re-crawl a page that changes hourly versus one that hasn’t moved in a decade? Crawl budget is finite and spending it evenly is wrong. So the frontier becomes a priority queue, ranking by importance signals and by how often a page has actually been changing, so new pages and refreshes compete for one budget.

    Built in step 6: Prioritize the frontier
  8. 38–43 min

    Scaling out, and the hostile web

    Finally, none of this fits on one machine. I shard the frontier, and I’d hash on the host rather than the URL — the decision here I’d defend hardest. Sharding randomly by URL scatters one site’s URLs across every node, so no single node can enforce that site’s rate limit and politeness silently stops working while everything still looks fine. Hashing the host keeps a site’s URLs and its rate limit together. And I’d add trap defenses now, because the web will happily generate infinite URLs at me — endless calendars, query-parameter permutations. Depth limits, parameter normalization, URL-length caps, a page budget per host.

    Built in step 7: Shard out, avoid traps
  9. 43–45 min

    Where the trade-off sits

    So, in one breath: a frontier feeding fetch, parse and enqueue; a bloom-filter seen set so the crawl terminates; politeness so I’m allowed to keep crawling; a DNS cache so the boring hop isn’t the ceiling; content fingerprints so I store the web once; priority so budget follows importance and freshness; host sharding to scale out. The trade-off running through all of it is coverage against politeness and against budget — nearly every defense costs me pages on purpose. With more time I’d work through JavaScript-rendered pages, since a plain fetch only sees the initial HTML and rendering is far more expensive, so the interesting part is routing only the pages that need it.

What this teaches

Learn system design by building a distributed web crawler step by step. An interactive guide covering the URL frontier, the fetch–parse–enqueue loop, dedup with bloom filters, politeness and robots.txt, DNS caching, content storage and dedup, and sharding the frontier.

Key takeaways

  • A URL frontier feeding fetch → parse → enqueue: the core crawl loop.
  • A bloom-filter Seen Set stops infinite re-crawling of the cyclic web graph.
  • Politeness: obey robots.txt and rate-limit per host so you’re not an attack.
  • A DNS cache + async resolution removes the hidden per-fetch bottleneck.
  • A content store plus content-hash dedup avoids storing mirrors and boilerplate twice.
  • A priority frontier balances importance and re-crawl freshness, not blind FIFO.
  • Shard the frontier by host to scale out, with depth/param limits to dodge traps.

Concepts covered

  • What is a web crawler?
  • Fetch, parse, enqueue
  • Have I seen this URL?
  • Politeness & robots.txt
  • DNS at scale
  • Store & dedup content
  • Prioritize the frontier
  • Shard out, avoid traps
RUN IT YOURSELF

The "seen URLs" Bloom filter

A crawler must skip URLs it has already visited, but a billion URLs is a lot of memory. A Bloom filter answers "seen?" in a tiny bit-array. Here it is in both languages, running live. Switch tabs, read the comments, and hit Run.

HOW TO READ THE CODE — 4 IDEAS
  1. A Bloom filter is a bit-array plus k hash functions — no URLs stored.
  2. To add a URL, set the k bits its hashes point to (step 2).
  3. "Seen?" is true only if all k bits are already set (step 3).
  4. It has no false negatives (a real "seen" is never missed) but a tunable false-positive rate.
CPython · WebAssembly
built to be crawled, not memorized — make the calls, drop the seen-set, run the gauntlet.
Finished this one? 0 / 65 System Designs done
NextDesign a Key-Value Store Closest system design to this one

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs