Courses / System Design Interview / Chapter 32
Module 3 · Design Real Products · Chapter 32 of 39
Design a Web Crawler
Builds on Back-of-the-Envelope Estimates, DNS: How a Name Becomes an Address, Designing a Key-Value Store, Message Queues, Rate Limiters, Blob Stores and Distributed Search. Next up: Design Typeahead Suggestions.
Part 1 · Requirements and Scale
The brief
It is the last over of a World Cup final. A cricket news site, news.example.in, is serving millions of readers on servers already near their limit, and it posts a new match report every few minutes. Readers searching for the result want that report in search soon. Now picture a careless crawler that picks this moment to discover the site's 40,000 archive pages and fetches 300 of them a second. It is the push that takes the site down, and the owners block it for good. A good crawler finds the new report quickly and still visits the host gently. The part that strikes that balance, the frontier, is the heart of this chapter.
The product: the crawler behind a search engine. It starts from a list of seed URLs and follows links across the web. It fetches about 100 million pages a day, keeps important pages fresh and hands every page to the indexer, and no website owner should ever feel it as an attack. We follow news.example.in through the whole design.
What a crawler does
A web crawler, also called a spider or a bot, downloads web pages, finds the links inside them and downloads those pages too. Its seed URLs, such as the home pages of news sites, government portals and universities, are only the start: it keeps following links until it has fetched billions of pages. The whole job fits in a loop:
frontier = Frontier(seed_urls) while True: url = frontier.next_ready() # priority and politeness page = fetch(url) # DNS, robots.txt, HTTP store(page) # kept for the indexer for link in extract_links(page): link = normalise(link) if not seen(link): # de-duplication frontier.add(link)
Every hard part of this chapter hides inside one of these lines.
Run that loop a thousand times a second for months, on millions of other people's websites, and every line becomes a design problem. The frontier, the crawler's to-do list, sits at both ends: every URL leaves from it and every new link returns to it. next_ready must pick important URLs first without flooding any one site, fetch meets slow servers and pages that never end, and seen must answer for billions of URLs, tens of thousands of times a second.
Here is the scope for today:
| In scope today | Out of scope |
|---|---|
| Fetch HTML pages over HTTP and HTTPS | Images, video and other media files |
| Discover pages by following links and reading sitemaps | Pages behind logins, forms or paywalls |
| Obey robots.txt and never overload a site | Building the index and ranking results (the distributed search chapter) |
| Store pages for an indexer to read | Running JavaScript to render pages (an extension for later) |
| Re-fetch pages so stored copies stay fresh | Judging spam and page quality beyond simple limits |
Everything out of scope plugs in later as an extra stage after fetch; nothing in it changes the loop.
A search engine wants breadth and freshness, a price tracker wants a few thousand shops checked every hour, and an archive wants every version of every page. The purpose decides priorities and what gets stored. Here we build the crawler behind a general search engine.
How well it must work
The loop says what the crawler does; this page says how well. A crawler has nobody waiting at a screen, so steady throughput and good manners matter far more than latency:
- Polite. One request at a time per host, at least a second apart, and slower when the site is slow or asks; robots.txt is always obeyed. To a small website, a careless crawler looks like an attack.
- High throughput. 100 million pages a day now and ten times that later, by adding machines rather than redesigning.
- Fresh where it matters. The busiest news home pages and feeds are re-checked every few minutes; an average page about once a month.
- Resilient to a messy web. Broken HTML, endless link loops, huge files and servers that report errors as success must not stall or flood the crawl.
- Survives crashes. A dead machine's queues are restored from a checkpoint; at worst some pages are fetched twice.
To size it, assume a web like this:
| Assumption | Value |
|---|---|
| Pages kept in the index | 3 B |
| How often each is refreshed | About once a month on average |
| URLs known, fetched or not | 10 B |
| HTML per page | About 100 KB before compression |
| Links per page | About 50 |
| Hosts | About 50 M, very uneven in size |
Refreshing 3 B pages once a month means about 100 M fetches a day, and every estimate on the next page starts from that number. New pages compete with refreshes for the same budget, so deciding what not to fetch matters as much as fetching fast.
Estimates: rate, bandwidth and storage
Use the method from the estimates chapter. 100 M fetches a day is 100 M ÷ 86,400 s, about 1,160 pages a second. Crawl traffic has no evening peak: the crawler sets its own pace and keeps it all day, so the average is also the rate to plan for, plus headroom for retries.
Try it first: 100 M pages a day, about 100 KB of HTML each, compressed 4×, kept for a year, stored with 6 + 3 erasure coding from the blob store chapter. How much disk is that?
- 100 M × 100 KB = 10 TB of HTML a day; ÷ 4 = 2.5 TB stored a day.
- × 365 days ≈ 910 TB for one copy of a year.
- × 1.5 for erasure coding ≈ 1.4 PB, an upper bound, because a page that has not changed is not stored again.
About 1.4 PB a year fits on fewer than a hundred 16 TB disks, before spares, so storage is not the hard part. The other numbers show where the pressure is:
| Quantity | Working | Result |
|---|---|---|
| Fetch rate | 100 M ÷ 86,400 s | ≈ 1,160 pages/s |
| HTTP requests | Plus robots.txt, redirects and retries: about +25% | ≈ 1,500 /s |
| Fetches in flight | 1,500 /s × about 1 s each | ≈ 1,500 open connections |
| Download | 1,160 × 100 KB | ≈ 116 MB/s, about 1 Gbps |
| Link checks | 1,160 pages × 50 links | ≈ 58,000 seen checks /s |
| URL seen set | 10 B URLs × 8-byte hash | ≈ 80 GB |
The third row uses Little's law: requests in flight = rate × time each takes. Most of that second is spent waiting on a distant server, so 1,500 open connections need little CPU. The row that shapes the design is the fifth: 58,000 lookups a second against a set of 10 billion URLs. Ten crawler nodes are a comfortable start: each owns a tenth of the hosts, keeps about 150 connections open and holds 8 GB of the seen set in memory. One number is still missing, how many hosts must be in rotation at once; the frontier deep dive works it out.
Part 2 · API, Data and the Crawl Loop
The API
With the numbers set, the next question is how the rest of the company talks to the crawler. It has no end users, so its API is small and internal: operators add seeds and manage hosts, other systems ask for a page to be fetched again, and the indexer hears about new pages through events instead of calling in.
POST /v1/seeds {"urls": ["https://news.example.in/"], "priority": 0} -> 202 {"accepted": 1}
POST /v1/recrawl {"url": "https://shop.example.in/p/881", "reason": "sitemap"} -> 202
GET /v1/pages?url=https://news.example.in/scores -> 200 {"status": 200, "fetched_at": "...", "content_key": "..."}
GET /v1/hosts/news.example.in -> 200 {"gap_ms": 1000, "queued": 1240, "errors_1h": 2}
PUT /v1/hosts/news.example.in/policy {"max_per_sec": 0.2, "blocked": false} -> 204202 means queued, not fetched: the frontier decides when.
202 Accepted is the honest answer. A seed or a recrawl request joins the frontier, and the fetch may happen in seconds or in days, depending on its priority and the host's politeness gap. Nothing outside the crawler should wait on a fetch.
The host policy call is the emergency brake. If news.example.in's owners complain during the final, an operator slows the crawler for that host or blocks it, and every fetcher obeys within seconds, because the frontier reads the policy before handing out the host's next URL. The same call can raise the limit for large sites that are happy to be crawled faster.
Data model
Behind those calls sit a few tables. Everything is keyed by a URL hash, a 64-bit hash of the normalised URL: URLs average about 100 bytes and the hash is 8, so sets and tables keyed by it stay small. Hosts get their own table, because politeness, robots.txt rules and DNS answers belong to a host, not to a page.
host (pk)ip_addressdns_expires_atrobots_rulesrobots_fetched_atgap_msnext_allowed_aterror_counturl_hash (pk)urlhostprioritydepthnext_fetch_atrefresh_intervaletaglast_modifiedurl_hash (pk part 1)fetched_at (pk part 2)http_statuscontent_keycontent_hashsimhashcontent_hash (pk)first_url_hashcontent_keyfrom_url_hashto_url_hashanchor_text- urls host → hosts
- fetches url_hash → urls
- content first_url_hash → urls
- links from_url_hash → urls
Each kind of data goes where its access pattern is cheap:
| Data | Store | Why |
|---|---|---|
| Frontier queues | An embedded on-disk key-value store on each node, keys sorted by host | Billions of entries; one host's URLs sit together, in order |
| URL and host records | Wide-column store, partitioned by host | Updated after every fetch; read by URL hash or by host |
| URL seen set | Memory on each node: 8-byte hashes | Checked about 58,000 times a second |
| Pages | Blob store, about 2,600 compressed pages packed into each 64 MB object | Written once, read in bulk by the indexer |
| Links | Append-only files, read by a daily batch job | Used to score page importance offline |
The frontier and the seen set live on the crawler nodes themselves, because they are touched on every fetch; everything else can sit in shared stores.
Pages are packed because a 25 KB compressed page is a tiny object, and the blob store chapter warned that per-object overhead then outweighs the data; a content_key looks like pages/n07/f31@88123. The links table feeds an offline job that scores importance: a page linked from many different sites is probably worth fetching first, so that score becomes one input to the frontier's priority.
The crawl loop
Now put the pieces in motion. Split the web across crawler nodes by host: each host name is hashed onto a ring with virtual nodes, as in the key-value store chapter, and the node that owns it runs the whole loop for it. All the state for one host lives on one machine: its queue, its politeness timer, its robots.txt rules and its DNS answer, so two machines can never hit the same site at once by accident.
The scene follows one URL from news.example.in around a node's loop. The danger it shows is waste: most links on a page are ones the crawler already knows, and some pages are copies of others.
First visit to the host: look up its address once and fetch its robots.txt rules
Read it as steps
A page from news.example.in is fetched and parsed; 48 links go in, only 7 new URLs reach the frontier, and a duplicate report is caught.
The seen check is the frontier's gatekeeper: without it, every page would re-add dozens of known URLs and the frontier would grow without end. The fingerprint check does the same job for content, so the page store holds one copy of the report.
Every node runs this loop on its own slice of the web, sending links for hosts it does not own to their owners in batches every second or so. A URL handed to a fetcher is marked in flight with a deadline, like the visibility timeout in the message queue chapter; if the fetcher dies, the URL is handed out again, and fetching a page twice is harmless. The scene glossed over one question: which URL should the frontier hand out next, and when may a host be visited again?
Part 3 · Deep Dives
The frontier: what to fetch, and when
The frontier is where the crawler decides both what matters and how to stay welcome; every other part of the design feeds it or drains it.
Think of a teacher taking questions from a crowded class: the most urgent go first, but no single student may ask ten in a row. The frontier is not a first-in, first-out queue. A large share of the links on a news.example.in page point back into the same site: menus, related reports, footers. Queue them in a row and the fetchers either hit that one site in a burst or sit idle waiting for it while thousands of other sites are ready. So the frontier answers two separate questions: what to fetch first, and when each host may be visited again.
- 1Score the URLpriority 0 (highest) to 3, from importance, change rate and where the link was found
- 2Front queues, one per prioritythey decide what comes first and know nothing about hosts
- 3A selector drains them unevenlypriority 0 about half the time, then 30%, 15% and 5%, so low priorities still move
- 4Back queues, one per hosteach URL moves to its host's queue; this layer decides when
- 5A heap of hosts by next allowed timethe host at the top may be fetched soonest
- 6Fetch one URL, then reschedule the hostback into the heap at reply time plus its gap
Each host's gap follows one rule: the largest of 1 second, the site's Crawl-delay if it sets one, and 3 times its last response time. A server that slows down is probably busy, so the crawler backs off without being told. It is the rate limiter chapter's idea turned around: we limit ourselves, per host, before the site has to.
That rule fixes the missing number from the estimates. A typical 1 s fetch earns a 3 s gap, so each host yields about one page every 4 seconds, and 1,160 pages a second needs over 4,600 hosts in rotation at once; keep about 10,000 active. Speed comes from breadth, never from hitting one host harder. The cost is depth: even at one page a second, a site with 10 M pages takes about four months, so its home and section pages go before its archives.
The frontier under pressure
The rule is simple on a quiet day. The final is not quiet: news.example.in slows down, then starts refusing requests, while 1,240 of its URLs wait in its back queue and two fetchers want work.
Predict before you watch: news.example.in just answered slowly and must now wait 4 seconds. What do the fetchers do during those 4 seconds?
- They fetch from other hosts whose turn has come, and the news site gets no request until its gap ends.
Watch for news.example.in: it waits out its gap while the fetchers serve others
Read it as steps
news.example.in slows during the final and then returns a 503; its gap grows each time while the fetchers keep serving other hosts.
The heap turned one host's trouble into a longer wait for that host alone: the fetchers never idled, and the news site saw at most one request at a time, spaced further apart as it struggled. A 503 or 429 doubles the gap and honours any Retry-After the site sends; after repeated errors the host is paused and an operator alerted.
Billions of queued URLs fill hundreds of gigabytes, too much for memory, and one file per host would mean millions of tiny files. So each node keeps its back queues in an embedded key-value store, a library such as RocksDB or LevelDB that runs inside the crawler and writes to local disk. Keys are the host followed by a sequence number, so a host's next URL is the first key under its name. Every few minutes the node checkpoints the store and its host table to the blob store.
Two more limits guard the same promise. Thousands of small sites can share one server, so the frontier also limits requests per IP address. And a host that moves to another node after a crash is treated as just fetched, so a hand-over never gives a site two quick visits.
Duplicate URLs
The frontier only works if it holds each page once. Duplicates come in two kinds. URL duplicates are one page reached through different spellings of its address; content duplicates are the same text at different URLs. This page handles the first kind, caught before a URL enters the frontier.
found: HTTPS://News.Example.in:443/sports/../scores/?utm_source=app&b=2&a=1#live normalised: https://news.example.in/scores/?a=1&b=2
Lowercase the scheme and host, drop the default port, resolve . and .., drop the #fragment and tracking parameters, then sort what is left.
The parser first resolves each href against the page's own address and drops mailto: and javascript: links. Normalising each link then turns many spellings into one. Leave the path's letter case and trailing slash alone: many servers treat /Scores and /scores as different pages. A <link rel="canonical"> tag, when present, names the address the site prefers.
Then comes the seen set: every URL the crawler has ever queued, checked 58,000 times a second. Four ways to hold 10 B URLs:
| Seen set for 10 B URLs | Memory | What can go wrong |
|---|---|---|
| Full URLs in a hash set | ≈ 1 TB | Nothing, except the memory bill |
| 8-byte URL hashes | ≈ 80 GB | About 3 collisions (n² ÷ 2⁶⁵), so about 3 pages are never fetched |
| Bloom filter, 1% false positives | ≈ 12 GB, about 9.6 bits per URL | 1 in 100 new URLs wrongly judged as seen |
| Bloom filter, 0.1% false positives | ≈ 18 GB, about 14.4 bits per URL | 1 in 1,000 new URLs wrongly judged as seen |
Every option trades memory for pages silently skipped.
A Bloom filter, met in the key-value store chapter, is a bit array plus several hash functions; if a lookup finds any of a URL's bits unset, the URL is definitely new. A new URL whose bits all happen to be set is skipped until the filter is rebuilt. At our scale 8-byte hashes fit in memory across ten nodes, so we use them; at ten times the scale the filter starts to pay.
Duplicate pages
Normalising cannot tell that news.example.in/scores and news.example.in/print/scores hold the same report, or that a wire story appears on fifty sites. Those are content duplicates, caught after the fetch, by the parser. They waste disk and, worse, their links flood the frontier with more copies.
For exact copies, hash the page's extracted text, not its raw HTML, so a changed advert or timestamp in the markup does not matter. Equal hashes mean an exact copy: store a pointer to the first copy in the content table and skip its links. That is what happened to the print page in the crawl loop scene.
Near-copies defeat an ordinary hash. Change one character, such as an "updated 10:47" line under the match report, and the whole hash changes. A similarity fingerprint such as SimHash handles them. The intuition: let every short run of words vote, so a few changed words cannot outvote the rest. The mechanism: each run of words is hashed to 64 bits and votes +1 or −1 on each bit position; the fingerprint keeps the sign of each total. Similar pages get fingerprints that differ in only a few bits, and this design treats pages within 3 bits as duplicates.
Finding those among billions of fingerprints needs one more trick. Split each fingerprint into four 16-bit blocks. Two fingerprints within 3 bits must match exactly on at least one block, because 3 differing bits cannot touch all four. So keep four lookup tables, one per block, fetch only the fingerprints that share a block, and compare those bit by bit.
The threshold is the trade-off. At 3 bits, two different reports of the same match, sharing a scorecard and little else, stay separate; raise it to 10 and the crawler starts throwing away genuinely new pages. Copies are now caught. The next danger is pages that are not copies, yet never stop coming.
Traps and limits
Some sites generate URLs without end, by accident or on purpose. A crawler trap is any part of a site that keeps producing new URLs while adding little new content. Every one of those URLs passes the seen check, because it really is new, so without limits the frontier fills with one site's junk:
| Trap | Example | Defence |
|---|---|---|
| Endless calendar | /fixtures?month=2026-11 links to the next month, forever | Cap pages per host; spot URLs that differ by one number yet return near-identical pages |
| Endless subdomains | a1.example.in, a2.example.in and so on, each a new host | Cap pages per registered domain too |
| Session ids in URLs | /shop;jsessionid=8F2A... is a new URL on every visit | Strip known session parameters while normalising |
| Repeating paths | /a/b/a/b/a/b/ from a broken relative link | Limit URL length, path depth and repeated segments |
| Filter combinations | ?colour=red&size=9&sort=price turns one listing into thousands of URLs | Learn which parameters do not change the content; cap URLs per path pattern |
| Soft 404s | Every missing page returns 200 with a "not found" message | Fetch a made-up URL on the host; if it returns 200, compare fingerprints |
The defences share one idea: a site gets a budget, not a blank cheque.
The per-host budget is the frontier's last line of defence, and it is set by importance: news.example.in earns a large budget, a site nobody links to a small one. Traps waste the budget on new URLs. The other claim on it is old URLs that need fetching again.
Freshness: fetching again
A stored page goes stale the moment its site changes it. The frontier must decide when each page is fetched again, without wasting fetches on pages that never change. Each page keeps a refresh_interval. After every fetch, compare the content fingerprint with the last one: if the page changed, halve the interval, down to an hour; if not, double it, up to about 60 days. Important pages get a lower ceiling, so a top news site's pages are re-fetched at least daily. Here is where that rule settles for four kinds of page:
| Page | How often it changes | Where the interval settles |
|---|---|---|
| News home page | Every visit | 1 hour, the floor |
| Live scorecard during a final | Every visit until the match ends | 1 hour, then growing |
| Product page | Price changes about weekly | Between 4 and 8 days |
| "About us" page | Almost never | 1, 2, 4, 8, 16, 32 days, then 60 |
The rule spends fetches where pages change and starves pages that do not, with no list for anyone to maintain.
An hour is too slow for the final, so the busiest news sites get more: a floor of a few minutes for their home pages, and polling of their feeds and news sitemaps, which list new URLs with a time. That is how the new match report is found within minutes. At one request every 4 s or more, polling one host every few minutes is still gentle.
Two HTTP features make refreshing cheap. Send the ETag or Last-Modified value from the last fetch back as If-None-Match or If-Modified-Since, and an unchanged page comes back as a 304 Not Modified with no body. The deep dives are done; next, what fails when this runs for months.
Part 4 · Failures and Trade-offs
What fails, and how the design copes
A crawler runs for months, so everything on this page will happen sooner or later. The goal each time is the same: lose little work, and never take a problem out on someone else's website.
| What goes wrong | Effect | How the design copes |
|---|---|---|
| A crawler node dies | Its hosts stop being crawled; fetches in flight are lost | Its hosts move to the next nodes on the ring, which load its last checkpoint; newer links turn up again later |
| The page store is slow or down | Parsed pages pile up | Buffer them on local disk; if the buffer fills, pause fetching rather than drop pages |
| DNS lookups slow down | Every fetch waits | Local caches, asynchronous lookups and more than one upstream resolver |
| A site slows down or returns 503 | Our traffic could make it worse | The gap grows with response time and doubles on 503; after repeated errors, pause the host and alert |
| Thousands of hosts on one server | "Different" hosts overload one machine | Also limit per IP address, using a counter all nodes share |
| A zone or region fails | Hosts crawled there pause; nothing stored is lost | Nodes in a dead zone drop off the ring and their hosts move on, as for one node. A dead region's hosts go to the next-nearest region, which loads their checkpoints from a copy kept outside the region |
Almost every row ends the same way: the frontier is restored or slowed, and no site pays for our failure.
Growth shifts the balance. At ten times the scale, 1 B pages a day, the fetch rate is about 11,600 pages a second and the frontier needs more than 46,000 hosts in rotation at once. The 8-byte hash set grows to 800 GB for 100 B URLs, and a 1% Bloom filter at about 120 GB starts to look attractive.
Trade-offs, regions and cost
Three tensions run through every choice in this chapter. Each one is a dial on the frontier:
- Coverage against freshness. Every refresh is a fetch not spent on discovery. Set the split explicitly, such as 70% refreshes and 30% new pages, and watch both.
- Speed against politeness. Politeness does not grow with your hardware: extra speed must come from more hosts in parallel, never from hitting one harder.
- Plain HTML against rendering. A headless browser sees content that JavaScript builds, at many times the CPU per page, so only pages that need it go to a separate, smaller pool.
Fetchers in more than one region. A node in Mumbai fetching a site served from the US east coast waits about 200 ms per round trip, several round trips per page, and pulls every byte over long, costly links. So run crawler nodes in three or four regions and give each host to the region nearest its servers, judged from its IP address. A host still belongs to exactly one node, so politeness holds, and links for hosts owned elsewhere travel to their owners in batches, as before.
What dominates the bill: storage. It is not the hard part, but it grows every month: a year of pages, about a petabyte, costs on the order of $20,000 to $30,000 a month at object-storage prices (check current prices), while ten fetch nodes cost far less. Rendering JavaScript would change that, since a headless browser needs many times the CPU per page.
Part 5 · Going further
Fetching: DNS, robots.txt and responses
Optional: the fetch details behind the loop, for when you need to know exactly how robots.txt and HTTP replies are handled.
Every fetch needs the host's IP address. As the DNS chapter showed, a cold lookup often takes 50 to 150 ms, and a shared resolver may not welcome 1,500 queries a second from one client. So each node runs its own caching resolver that keeps answers for their TTL, caches "no such name" too, and looks a host up just before its turn.
Every host has its own robots.txt, a plain-text file at its root that says which paths crawlers may visit; fetch it before the host's first page and keep it for up to a day, as the standard published in 2022 as RFC 9309 recommends.
# https://shop.example.in/robots.txt User-agent: * Disallow: /cart/ Disallow: /search Allow: /search/help Crawl-delay: 2 Sitemap: https://shop.example.in/sitemap.xml
Crawl-delay is not part of the standard, but some crawlers honour it, and ours does.
Three details catch people out. The longest matching rule wins: /search?q=shoes is blocked by Disallow: /search, but /search/help is allowed because Allow: /search/help is the longer match. A group for your crawler's own name replaces the * group instead of adding to it. And a 4xx for robots.txt means no rules apply, while a 5xx or a timeout means treating the whole site as off limits for now. Each HTTP reply then tells the frontier what to do:
| Response | What the crawler does |
|---|---|
| 200 | Parse, store and extract links |
| 301 or 308 | Moved for good: send the new URL through the seen check and point the old one at it |
| 302, 303 or 307 | Moved for now: follow, but keep the old URL; at most 5 redirects in a row |
| 304 | Unchanged since the last fetch: nothing to store; lengthen the refresh interval |
| 404 or 410 | Gone: retry a 404 once or twice over a few days, drop a 410, tell the indexer |
| 429 or 503 | Slow down: double the host's gap and honour Retry-After |
| Other 5xx, timeouts | Retry later with backoff; after repeated failures, lower the host's priority |
Errors that blame the site slow the host down; errors that blame the page only reschedule the URL. Finally, send a user-agent such as ExampleBot/1.0 (+https://example.in/bot) linking to a page that explains the crawler, and crawl from published IP addresses, because many owners block crawlers they cannot identify.
Part 6 · Interview kit
The 45-minute answer
A 45-minute interview leaves about 40 minutes of design. Here is the crawler on that clock.
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | Purpose: a search engine's crawler. Scope: HTML only, follow links and sitemaps, obey robots.txt, refresh pages, store them for the indexer. | Politeness first: one request at a time per host, at least 1 s apart |
| 5–8 | Estimates that decide something: fetch rate, connections in flight, link checks and the seen set. Storage gets one line. | 1,160 pages/s; 58,000 seen checks/s; 80 GB of hashes |
| 8–13 | API: seeds, recrawl, host policy, and a page.fetched event for the indexer. Data: hosts, urls, fetches and content, keyed by a 64-bit URL hash. | 202 Accepted: the frontier decides when |
| 13–22 | Draw the loop around the frontier: fetchers with a DNS cache, parser, page store, seen check, back to the frontier. Split hosts across nodes on a hash ring. | All of one host's state on one node |
| 22–37 | The frontier: front queues for priority, back queues per host, a heap of next allowed times, a slow host and a 503. Then de-duplication: normalising, hashes, SimHash. | Gap = largest of 1 s, Crawl-delay, 3 × response time; 4,600+ hosts in rotation |
| 37–40 | Wrap-up: a node crash and its checkpoint, traps and limits, ten times the scale, fetchers in more regions. | At 10×: 11,600 pages/s and 46,000+ hosts in rotation |
Pick the frontier if they ask how you stay polite or what gets fetched first. Pick de-duplication if they ask how to check billions of URLs or spot the same article on many sites. If they ask about freshness, use the halve-or-double refresh rule.
- Say
- "Each host yields about one page every 4 seconds, so 1,160 pages a second needs over 4,600 hosts in rotation: speed comes from breadth, never from hitting one site harder."
- Say
- "Front queues decide what, back queues per host decide when, and each host's gap is the largest of 1 second, its Crawl-delay and 3 times its last response time."
- Say
- "The seen set is 10 B URLs × 8 bytes = 80 GB across ten nodes; a 1% Bloom filter would cut it to 12 GB but wrongly skip 1 in 100 new URLs."
- Avoid
- "One big FIFO queue of URLs." It ignores priority and sends bursts to whichever site has many links in a row.
Interview drill
Each question changes one thing about the crawl. Answer it before you open it.
The search team wants 1 B pages a day, ten times today. What changes?
- About 11,600 pages a second: roughly 100 nodes instead of 10, added to the hash ring a few at a time.
- Politeness does not scale with hardware, so the frontier must keep more than 46,000 hosts in rotation.
- The seen set grows to 800 GB of hashes for 100 B URLs; a Bloom filter of about 120 GB starts to pay off.
- Download nears 10 Gbps, another reason to spread fetchers over several regions.
Product wants an archive: every version of every page, readable as of any date. What changes in the data model?
- fetches is already keyed by (url_hash, fetched_at), so keep every row instead of only the latest.
- Store content once per content hash, so an unchanged fetch adds a row, not a copy.
- "The page as of a date" becomes one range read on (url_hash, fetched_at).
- Storage becomes the main cost: move old versions to an archive tier 20 to 50 times cheaper.
The budget is cut in half: 50 M fetches a day instead of 100 M. What do you give up, and what do you protect?
- Protect politeness and the important pages: home pages, news and popular sites keep their refresh rates.
- Raise the refresh ceiling for unimportant pages, say from 60 to 120 days, and slow discovery on low-value hosts.
- Lean on conditional requests and sitemaps, so an unchanged page costs a cheap 304.
- Common miss
- Speeding up by ignoring a host's gap or robots.txt. Extra speed must come from more hosts in parallel.
- Common miss
- Checking "seen" against a database on disk. 58,000 random lookups a second need the set in memory, split by host.
Wrap-up
Next, Design Typeahead Suggestions swings the other way: a person is waiting, and every answer must arrive within about 100 ms of a keystroke.
Part 7 · Check yourself
Quick check
Q1A crawler keeps one first-in, first-out queue. With politeness on, its fetchers sit idle; with it off, news.example.in complains of bursts. Explain both.
Think it through, then check.
- Most links on a page point back into the same site, so long runs of the queue belong to one host.
- Obeying its gap blocks the queue while other hosts are ready; ignoring it sends the run as a burst.
- Fix: front queues per priority, back queues per host, and a heap of hosts by next allowed time.
Q2Product now wants a price tracker: 50,000 product pages on one large shop, each checked every hour. What changes?
Think it through, then check.
- That is about 14 pages a second to one host, far beyond a one-second gap, so politeness, not hardware, is the limit.
- Ask the shop for a product feed or a higher rate through the host policy; otherwise check fast-changing products hourly, the rest daily.
- The frontier becomes a fixed list of URLs on schedules; discovery and the seen set barely matter.
Q3With a fixed 100 M fetches a day, one team wants new pages found sooner and another wants stored pages fresher. How do you split the budget?
Think it through, then check.
- Every refresh is a fetch not spent on discovery, so set an explicit split, say 70% refreshes and 30% new pages.
- Spend refreshes where change rate × importance is highest: a news home page hourly, an "About us" page every 60 days.
- Measure both (refreshes that found a change, waits for first fetches) and move budget to the worse.
Q4The node that owns news.example.in and 5 M other hosts dies. What happens to them, and what is lost?
Think it through, then check.
- Its hosts move to the next nodes on the ring, which load its last checkpoint.
- Links found since that checkpoint are lost, but most turn up again from other pages and sitemaps.
- In-flight URLs pass their deadline and are fetched again; new owners treat each host as just fetched, so no site gets a burst.
Q5Which building block holds each node's per-host queues, and why not a shared database or a message queue?
Think it through, then check.
- An embedded on-disk key-value store on each node, keys sorted by host: the key-value store chapter's write path inside one process.
- One host's URLs sit together, so its next URL is a short range read; a shared database would only add network calls.
- A message queue handles thousands of partitions well, not millions of tiny per-host queues.