🕷️ System Design web crawler
🕷️ Part III · Classic Case Studies · chapter 12 / 24

Walk the whole web,
politely

A web crawler is one tiny loop — pull a URL, fetch the page, find its links, add them back — run a billion times across thousands of machines. The whole art is in the bookkeeping: not crawling the same page twice, not hammering any one site, and not falling into traps that never end.

Strip a web crawler down to its bones and you get a loop a first-year could write: take a URL off a list, download the page, pull out the links, put the new ones back on the list, repeat. The reason "design a web crawler" is a real interview question — and the reason Google's runs on warehouses of machines — is everything that loop has to avoid.

It must not download the same page a million times. It must not knock a small blog offline by requesting every page at once. It must obey each site's robots.txt rules. And it must not wander into a calendar that generates "next month" links forever, or a search box that spits out infinite URLs. None of that is hard math; all of it is careful bookkeeping at a scale where careless bookkeeping melts the internet.

This chapter builds the crawler the way the rest of the book builds systems: start with the one honest loop, then add exactly the machinery each new problem demands — a structured queue for politeness, two dedup layers, a robots cache, trap defenses, and finally the sharding that lets thousands of crawlers cooperate without tripping over each other.

1The crawl loop and its cast

Everything starts with a handful of seed URLs — the doors you walk through first, like a few big directories or your own homepage. From there the loop turns, and each turn passes a URL through the same relay of small parts:

  • The frontier is the to-do list of URLs waiting to be crawled. The loop pulls the next URL from here.
  • The fetcher downloads the page, after asking DNS to turn the hostname into an IP address (the same phone-book lookup from Chapter 1, and a surprising bottleneck — more in §8).
  • The parser reads the downloaded HTML and pulls out two things: the page's content and its outgoing links.
  • Content-seen? checks whether we've already stored a page with this exact content — many URLs serve identical pages, and there's no point keeping both.
  • The URL extractor turns each link into a full, normalized URL (/abouthttps://site.com/about).
  • The URL filter drops links we don't want — wrong file types, blocked domains, anything matching a trap pattern (§7).
  • URL-seen? checks whether this URL is already crawled or already queued, so we never enqueue it twice.
  • Survivors go back into the frontier, and the loop turns again.

That's the canonical web-crawler diagram, and it is genuinely the whole thing. Notice the two independent "have we seen this?" gates — one on the URL, one on the content. Keeping those straight is half of §5. The other structural fact: the loop is a closed circle. Links found while crawling become the seeds of the next crawl, which is why a crawler with good seeds eventually reaches almost everything reachable.

λ
The FP framing: the loop is a fold over a growing work-list. State is just two values — the frontier (a queue) and seen (a set). Each step dequeues one URL, fetches, and folds the new links back in. The messy part — the actual HTTP, the DNS — is pushed to the edges as effects; the core is a pure step. We write exactly that in §code.

2BFS, DFS, and why the frontier gets complicated

If the frontier is just a plain queue — first in, first out — the crawler naturally does breadth-first search: it crawls all the seeds, then everything one link away, then everything two links away, spreading outward in rings. Swap the queue for a stack (last in, first out) and you'd get depth-first search: follow one chain of links as deep as it goes before backing up. Crawlers almost always pick BFS, because depth-first tends to burrow into one site forever, and breadth-first naturally spreads attention across many sites.

But a plain FIFO queue has a fatal flaw the moment you care about manners. Pages on the same site link heavily to each other, so a simple BFS pulls a long run of URLs all from one host — and fires them off back-to-back, which is exactly the hammering we must avoid. Plain BFS is also blind to importance: the homepage of a major news site and a random forum reply sit in the queue as equals.

So real crawlers replace the single queue with a structured URL frontier built from two banks of queues:

  • Front queues handle priority. There are several, one per importance tier. A prioritizer scores each URL (§4) and drops it into the matching front queue — high-value pages into the high-priority bank.
  • Back queues handle politeness. There is exactly one back queue per host, so every URL for blog.com lands in the same queue. A worker pulls from a back queue and waits between requests, so one host is never hit too fast (§3).
  • A queue router (or selector) sits between them: it pulls from the front queues — favoring high priority — and routes each URL into the correct per-host back queue.

The frontier, then, is the crawler's brain. It answers one question — "which URL next?" — while honoring two constraints at once: crawl the important things first, and never be rude to any single host.

Interactive · the URL frontier Drop URLs in; the router sorts by priority, then per-host
in front queues
0
in back queues
0
crawled
0

3Politeness — don't melt the small sites

A crawler can open thousands of connections per second. A hobbyist's blog runs on one small server. Point the first at the second without restraint and you've effectively launched a denial-of-service attack — which is rude, often illegal, and a fast way to get your crawler banned everywhere. Politeness is the set of self-imposed limits that keep a crawler a guest rather than a wrecking ball.

Two rules do most of the work:

  • One connection per host at a time. Never open several parallel connections to the same site — that's the burst that hurts most.
  • A crawl-delay between requests to the same host. After fetching a page from blog.com, wait a beat — a second or two, or whatever the site's robots.txt requests — before fetching the next page from that same host. Meanwhile you're free to fetch from a thousand other hosts in parallel.

That last point is the key insight: politeness is per-host, not global. You stay busy by spreading across many hosts, while being gentle with each one. The frontier's back-queue design exists precisely to enforce this. Each host maps to exactly one back queue (a host → back-queue mapping), and each back queue is handed to exactly one worker thread (a queue → worker assignment). Because a host's URLs all funnel through one queue served by one worker that sleeps for the crawl-delay between pulls, the "one connection, polite spacing" guarantee falls out automatically — no global locking required.

⚠️
The classic bug: shard the frontier by URL hash instead of by host, and a single host's pages scatter across many workers — each one independently thinks it's being polite, but together they hammer the site. Politeness only works if all of a host's URLs live behind one gate.

4Prioritization and freshness

The web is far too big to crawl evenly, so the front queues exist to crawl the good stuff first. A prioritizer scores each URL and the score decides which front-queue tier it lands in. Common signals:

  • PageRank / link importance. Pages that many other pages link to are probably worth more — the same recursive "important things link to important things" idea that powers search ranking.
  • Traffic / popularity. Pages people actually visit are worth keeping fresh.
  • Update frequency. A news front page changes hourly; a 2009 forum post never changes again. Spend your budget where it buys new information.

That last signal drives the crawler's second job: not just discovering pages once, but keeping them current. This is freshness, and it's a scheduling problem. You don't recrawl everything on a fixed timer — that wastes most of your fetches on pages that haven't moved. Instead you track each page's historical update cadence: how often did it actually change last time you looked? A page that changes every hour gets recrawled hourly; one that's been identical for two years gets checked rarely. Recrawl scheduling tunes itself toward where change actually happens, so a fixed crawl budget captures the most new information.

🔁
Freshness is a feedback loop: every crawl is also a measurement of "did this change since last time?", and that measurement re-tunes when the page gets visited next. Pages teach the scheduler their own rhythm.

5Dedup at two levels

The web is enormously redundant. The same URL is linked from a thousand pages; the same article appears on a dozen mirror sites; the same page is served with and without a trailing slash, with and without tracking parameters. A crawler that doesn't dedup does the same work over and over and fills its storage with copies. So it dedups at two independent levels.

Level 1 — URL-seen (have we queued this address?)

Before adding a URL to the frontier, check whether we've already seen it. First normalize the URL so trivially-different spellings collapse to one: lowercase the host, drop the default port, sort query parameters, strip tracking junk, resolve /a/../b to /b. Then look it up in a seen-set.

At web scale that set has hundreds of billions of entries — too many to hold every full URL in memory. So crawlers reach for a Bloom filter: a compact bit array that answers "have I seen this?" using a fraction of the memory. The trade is a strange one — it can have false positives (claim you've seen a URL you haven't, so you skip a genuinely new page) but never false negatives (it never forgets something it really saw). For a crawler that's an acceptable bargain: occasionally missing a page is fine; the memory savings are enormous. Play with one below.

Interactive · Bloom filter Add URLs, watch bits flip, find a false positive
32 bits
3
URLs added
0
bits set
0 / 32
est. false-positive rate
0%

Level 2 — content-seen (have we stored this page?)

Two different URLs can serve byte-for-byte identical pages, and near-identical pages (same article, different ad) are everywhere. After fetching, compute a fingerprint of the content and check whether you've stored that fingerprint already. A plain hash catches exact duplicates. For near-duplicates you use SimHash: a fingerprint engineered so that similar documents get similar fingerprints, so "almost the same page" collides and gets dropped. URL-seen stops you from re-fetching; content-seen stops you from re-storing.

6robots.txt and the crawler's contract

Every well-behaved crawler honors an unwritten contract with the sites it visits, and the contract is written in a file. Before crawling any host, fetch https://host/robots.txt — a small text file where the site declares which paths crawlers may and may not touch. Respecting it isn't optional courtesy; ignoring it gets you blocked and can get you sued.

Three pieces matter:

  • Fetch, cache, and honor the rules. You don't re-download robots.txt for every page — that would itself be impolite. Fetch it once per host, cache it with a TTL, and check every candidate URL against the cached rules before queueing it.
  • Sitemaps. robots.txt often points to a sitemap — a list the site publishes of its own URLs. It's a gift: a curated set of seeds straight from the source, far better than guessing.
  • noindex / nofollow. Page-level signals (in a meta tag or HTTP header) say "don't index this page" or "don't follow these links." A crawler respects them as part of the same contract.

The practical shape: robots.txt is one more cache keyed by host, consulted in the URL filter step. New host? Fetch its rules first, then crawl within them.

7Spider traps and quality

Some pages generate links forever. A calendar widget has a "next month" link, and next month has a next month, and a naive crawler will happily request the year 9999 and keep going. A faceted shop produces a fresh URL for every combination of filters — color × size × sort × page — an effectively infinite space of near-identical pages. A search box turns any query into a URL. None of these are malicious; they're just spider traps: structures that emit unbounded streams of low-value URLs and can swallow a crawler whole.

You can't detect "infinite" directly, so you defend with cheap, blunt limits:

  • Cap crawl depth. Track how many hops each URL is from a seed; stop following past, say, depth 20. Real content is shallow; traps are deep.
  • Cap URL length. Trap URLs tend to grow as parameters pile up. A hard length limit kills the runaway ones.
  • Blocklist known trap patterns. Calendars, session IDs in URLs, infinite paginations — match and drop them in the URL filter.
  • Detect redirect loops. A → B → A chains, or a page that always redirects to a slightly different URL, are traps too; cap the redirect chain.

These same limits double as a quality filter: capping depth and length, and dropping near-duplicates (§5), keeps the crawler spending its finite budget on real, distinct content instead of machine-generated noise. Try escaping a trap below.

Interactive · spider-trap explorer Toggle the rules and watch the crawler escape or get stuck
pages crawled
0
deepest URL
status
idle

8Scale and storage

One machine crawls maybe a few hundred pages a second. The web has hundreds of billions of pages. So a real crawler is a fleet — many crawler servers running the same loop in parallel — and the design problems are all about coordination.

  • The frontier won't fit in memory. Hundreds of billions of pending URLs live on disk; only the hot tail of each queue is buffered in RAM. The frontier becomes a persistent, disk-backed structure with a memory buffer in front — and it must survive crashes, because losing the frontier means losing the entire crawl's progress.
  • DNS is a sneaky bottleneck. Every fetch needs a hostname→IP lookup, and DNS resolvers are slow and rate-limited. Without a big local DNS cache (and often your own resolver), DNS — not bandwidth — becomes the wall the crawler hits first.
  • Splitting work across servers. Which crawler owns which host? You can't reshuffle the whole assignment every time you add or lose a machine — that would scramble every back queue and break politeness mid-flight. So you map hosts to servers with consistent hashing (Chapter 6): add or remove a crawler and only a small slice of hosts move, while everything else stays put. Each host stays "sticky" to one server, which is exactly what politeness needs.

Here's the whole crawler as a checklist — the order in which each problem forces its piece of machinery into the design:

  1. 1The crawl loop — seeds → frontier → fetch → parse → extract → filter → seen-checks → back to frontier.
  2. 2A structured frontier — front queues for priority, back queues per host, a router between them.
  3. 3Politeness — one connection & a crawl-delay per host, enforced by one back queue per host, one worker per queue.
  4. 4Prioritize & refresh — score by importance; recrawl by each page's historical update cadence.
  5. 5Dedup twice — URL-seen (Bloom filter + normalization) and content-seen (fingerprint / SimHash).
  6. 6Honor robots.txt — fetch, cache, and obey per host; harvest sitemaps; respect noindex/nofollow.
  7. 7Defend against traps — cap depth & URL length, blocklist patterns, break redirect loops.
  8. 8Scale out — disk-backed persistent frontier, DNS cache, consistent hashing of hosts to servers.

9The crawl loop, as a pure step

Section 1 claimed the loop is "a fold over a growing work-list." Here it is in code. The state is exactly two values: a frontier (queue of URLs) and a seen set. The step function is pure — it dequeues one URL, and given the links that page contained, returns the next frontier, the next seen-set, and the URL it fetched. The actual HTTP fetch (the only effect) is passed in / lives at the edge, never inside step. Flip between languages; the shape is identical.

λ
The frontier is just a queue, the dedup is just a set, and step is a pure transition on the pair. All the messy real-world parts — HTTP, DNS, robots.txt, persistence — sit at the edges as effects feeding links into the pure core. That separation is what lets you test the crawl logic without touching the network, and run a thousand copies of the loop in parallel.