Design a Web Crawler at Scale
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A price-and-news aggregator crawls a fixed list of sites every day | A search-engine-style crawler that follows links out onto the open web | A web-scale crawl for a search or research index, run worldwide |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Volume | 1M pages/day from 10K sites (23 pages/s during a 12-hour window) | 1B pages/month: 386 pages/s average, 1,200/s planned peak; 5B URLs tracked | 20B fetches/month: 7,716/s average; ~400M known hosts, 200B known URLs |
| Storage | 40 GB/day of compressed archives in S3 | 40 TB/month compressed; 480 TB/year | 476 TB/month archived; 5.7 PB/year in three storage tiers |
| Footprint | 1 region | 1 region, 3 AZs, Spot fleet | Fetchers in 3 regions, near the hosts |
| Targets | Never more than 1 request/s per site; finish in 12 hours | Zero politeness incidents; no work lost to Spot interruptions | Freshness per page class (news within 15 min); complaints handled; cost per million pages |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Web Crawler?
You Already Know One: a Librarian Following References
A librarian reads a book. At the back is a list of references: other books. She writes each one on her reading list, takes the next book from the list, reads it, and adds its references. Over time the list grows faster than she can read.
A web crawler does the same with web pages. It fetches a page, pulls out every link on it, and adds those links to a list of pages to fetch next. That list is called the frontier: the set of URLs we know about but haven't fetched yet (or need to fetch again). Search engines (Googlebot, Bingbot), web archives (the Internet Archive) and open datasets (Common Crawl) all run crawlers.
| What a crawler feeds | Example |
|---|---|
| A search index | Every page's words, so a search can find it |
| A price or news aggregator | Today's price of a product on 10,000 shops |
| A web archive | A copy of the page as it looked on a given day |
| A research dataset | Billions of pages of text for analysis |
What Makes It Hard
- The list never ends. Every page adds dozens of new links. There are far more URLs than we could ever fetch.
- Much of it is repeated. The same page appears under many URLs, and many pages are near-copies of each other (the same product with a different footer).
- We are guests. Every fetch uses someone else's server. A crawler that sends too many requests to a small site is, from the site's point of view, an attack.
- Some of the web is hostile or broken. Pages that generate infinite links, responses that decompress to gigabytes, servers that redirect us to our own internal network.
The Question the Whole Loop Answers
What do we fetch next, when are we allowed to, and is it worth fetching at all?
The answer gets sharper every round:
- Round 1: a durable queue, a fleet of workers, one request at a time per site, robots.txt rules, and pages stored in archive files.
- Round 2: the open web: a probabilistic "seen" filter, near-duplicate detection, a two-tier frontier that separates importance from politeness, traps, bombs, cheap machines that vanish, and recrawls.
- Round 3: a fixed budget spent where it buys the most freshness, fetchers near the hosts, politeness that listens to the server, and controls for the people who run the websites.
Round 1 · Mid-level · "Crawl 10,000 News and Shop Sites Daily"
~35 min · SDE II (L5) · 1 region · 10K sites, 1M pages/day · 23 pages/s in a 12-hour window · ≤ 1 request/s per site
R1.1 Establish Design Scope
The interviewer says: "We run a price-and-news aggregator. Every day we need a fresh copy of the pages on about 10,000 news and shopping sites. Design the crawler." Before drawing anything, we ask questions and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Which sites? | A fixed list of about 10,000, managed by our ops team. | No open-web discovery. The seed list is known, and an admin API manages it (R1.4). |
| How deep do we go? | Every page we can reach by following links within each site. Links to other sites are ignored. | We extract links but keep only those on the same host. The number of pages per site is bounded by the site itself. |
| How often? | Once a day. Fresh copies should be ready by the morning. | A daily crawl with a deadline. We pick a 12-hour window (R1.7), which leaves room for retries. |
| What do we keep? | The raw HTML. Our price and headline extractors run on it later, and we want to re-run them when they improve. | We store the original bytes, not parsed fields, in a format that can be re-read years later (step 1.5). |
| JavaScript-only sites? | Not yet. If a page needs JavaScript to show content, we skip it for now. | Plain HTTP fetches only. No browser rendering. |
| How gentle must we be? | Obey robots.txt, and never hurt a site. Some of these are small shops on one server. | At most one request at a time to a site, and at most one request per second (step 1.3). robots.txt is checked before every fetch (step 1.4). |
A robots.txt file is a plain-text file a website publishes at /robots.txt that tells crawlers which paths they may fetch. It is standardized as the Robots Exclusion Protocol, RFC 9309 (September 2022).
Out of scope for this round:
- The open web. Following links to sites not on our list.
- Near-duplicates. Pages that are almost, but not exactly, the same.
- Smart recrawl timing. Every page is simply fetched once a day.
In a multi-round loop, the interviewer brings parts of the out-of-scope list back later. Write it where you can see it.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into something the system does:
| Phrase from the problem | What the system does |
|---|---|
| "10,000 sites, managed by ops" | Store the site list; an admin API adds, pauses and removes sites |
| "Every day" | Each morning, put each site's home page (the seed) into the frontier |
| "Every page we can reach within the site" | Fetch a page, parse the HTML, extract links, keep those on the same host, add new ones to the frontier; repeat until the frontier is empty |
| "Keep the raw HTML" | Store every fetched response, with its headers, durably |
| "Obey robots.txt" | Fetch each site's robots.txt and check every URL against it before fetching |
| "Never hurt a site" | Limit requests per host |
| "Ready by morning" | Track progress per site; report what finished and what didn't |
Not yet: links to other sites, near-duplicate detection, recrawl scheduling, JavaScript rendering.
R1.3 Non-Functional Requirements: the Questions
We state each quality in words first, in the order we'd defend it. The numbers come in R1.7.
- Politeness first. A crawler that knocks over a small shop's server has failed, however fast it is. No more than one request at a time and one request per second to any host, and robots.txt always obeyed.
- Then throughput: finish the day's crawl. A million pages inside a 12-hour window, with time left for retries.
- Then robustness to bad pages and bad sites. Slow servers, dead servers, huge responses and broken HTML must not stall the crawl or crash a worker.
- Durability of the work. A worker crash must not lose URLs from the frontier or pages we already fetched.
R1.4 The API
Everything in this round is internal. There are three contracts.
1. The crawl job message (one per URL, sitting in the frontier queue)
json{ "crawl_date": "2026-09-27", "url": "https://shop.example.com/products/red-shoes?color=red&size=42", "host": "shop.example.com", "depth": 2, "discovered_from": "https://shop.example.com/products", "attempt": 0 }
depth is the number of links followed from the home page. It helps us notice a site that goes unexpectedly deep.
2. The crawl result event (published after the page is safely stored)
json{ "crawl_date": "2026-09-27", "url": "https://shop.example.com/products/red-shoes?color=red&size=42", "http_status": 200, "content_type": "text/html; charset=utf-8", "fetched_at": "2026-09-27T03:14:07Z", "fetch_ms": 840, "body_bytes": 201344, "content_sha256": "3f9a1c...e07b", "links_found": 61, "links_new": 4, "warc": { "s3_uri": "s3://agg-crawl-archive/2026/09/27/worker-b-0007.warc.gz", "offset": 81234567, "length": 40211 } }
The price and news extractors subscribe to these events. The warc pointer tells them exactly which bytes to read (step 1.5).
3. The admin API (ops manages the site list)
httpPOST /admin/sites HTTP/1.1 Content-Type: application/json { "host": "shop.example.com", "seed_url": "https://shop.example.com/", "max_pages_per_day": 25000, "notes": "partner since 2024" }
httpHTTP/1.1 201 Created Content-Type: application/json { "host": "shop.example.com", "status": "ACTIVE" }
PATCH /admin/sites/shop.example.com with {"status": "PAUSED"} stops a site at once; GET /admin/sites/shop.example.com/runs/2026-09-27 shows the day's progress: pages fetched, errors, and whether the site finished.
Recap
- Three contracts: a job per URL, a result event per page, and an admin API for the site list.
- About 1M pages a day from 10K known sites; one region.
- Politeness first; finish within a 12-hour window; survive crashes and bad sites.
Let's build it, starting with the simplest version that works.
R1.5 Design Evolution: From One Script to a Polite Fleet
Every step follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
One script on one machine. It holds a list of URLs in memory, starting with the 10,000 home pages. It takes the first URL, downloads it, finds the links, appends the same-site ones to the list, saves the HTML to a local disk, and loops.
Synthesizing vector architecture diagram...
The whole crawler is one loop. It works for a few hundred pages.
What's good about it: it's an afternoon of work, and it shows the core loop every crawler has. What's wrong: one download at a time, taking about 1.5 seconds each, gets through 86,400 ÷ 1.5 ≈ 57,600 pages a day, not a million. And if the machine restarts, the list is gone.
Step 1.1: One Machine Is Too Slow, and a Crash Loses the List
The problem: we need about 23 pages a second (R1.7), and each download takes about 1.5 seconds, mostly waiting on the network. When the script's machine reboots, the list of URLs still to fetch is lost, and so is the day's crawl. What would you do?
Primitive: Message Queues vs Event Streams · Drill: Message queue order pipeline
Step 1.2: We Fetched the Same Page 50 Times
The problem: a shop's product page is linked from its category page, its search results, 30 other product pages and the footer. Every worker that parses one of those pages queues it again. We fetched it 50 times today. The same page also shows up as HTTPS://Shop.Example.com:443/products/red-shoes#reviews and https://shop.example.com/products/red-shoes.
What would you do?
This table does more than dedup. Its row is where the URL's state lives for the day: QUEUED when inserted, FETCHED with the archive pointer once stored (R1.6). That makes it the source of truth for "did we get this page today?"
Step 1.3: Ten Workers Hit the Same Small Site at Once
The problem: a small shop has 3,000 pages, all queued in the morning. Ten workers each receive a few of its URLs and fetch them at the same moment: 40 requests in one second to a server built for a few visitors. The shop's owner calls to complain that we took the site down. What would you do? We need at most one request at a time to each host, and at least one second between them.
The alternative is host-affine workers: each worker owns a fixed set of hosts, keeps a local queue per host, and enforces the delay with a timer. It gives each worker warm DNS and connection caches for its hosts, but a crash leaves its hosts orphaned until someone reassigns them. We compare the two in R1.8, and Round 3 comes back to host affinity at a much larger scale.
Primitive: Distributed Rate Limiting
Step 1.4: We Crawled Pages the Site Forbids
The problem: a news site's robots.txt says Disallow: /search. We fetched 40,000 of its search-result pages anyway, because nobody checked. The site's lawyer writes to us.
What would you do? And how often should we read robots.txt?
Step 1.5: Where Do a Million Pages a Day Go?
The problem: we fetch 1M pages a day, averaging about 200 KB of HTML each (R1.7). The extractors read them once today, and again whenever a new extractor version needs a re-run over last month. What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One script, list in memory | Everything below |
| 1.1 | Too slow; a crash loses the list | SQS queue + stateless workers; visibility timeout; DLQ | Workers can't see each other's work |
| 1.2 | The same page 50 times | URL normalization; per-day seen table with conditional puts | A table and two calls per page |
| 1.3 | Many workers hit one small site | FIFO message group per host; delete after the delay | The biggest site sets the crawl length |
| 1.4 | Crawled forbidden pages | robots.txt per host, parsed, cached 24 h | Up to 24 h to see a new rule |
| 1.5 | Where do pages go | WARC files in S3, per-record compression, offsets in the table | Up to 10 min before a page is durable |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Every morning the control service seeds each site's home page into the FIFO queue. Workers pull one URL per host at a time, check and record URLs in the pages table, store pages as WARC files in S3, and announce each stored page to the extractors.
Why these pieces:
- ECS Fargate runs the workers as containers with no servers to manage. At 23 pages a second, three small tasks are plenty (R1.7), one per AZ so that losing an AZ costs a third of our capacity, not all of it.
- Public subnets, no NAT gateway. Workers reach the internet directly through public IPs, with a security group that allows no inbound traffic at all. A NAT gateway would charge $0.045 for every GB it carries, and we pull about 6 TB a month through it (R1.7). S3 and DynamoDB are reached through free VPC gateway endpoints.
- SQS FIFO gives us the per-host lanes of step 1.3. Its basic (non-high-throughput) mode handles 300 API calls a second per action, or 3,000 messages a second with batching; we need about 23.
- DynamoDB holds the site list and the per-day URL table. Both are key lookups with conditional writes, which is what DynamoDB does well.
- S3 holds WARC files, with a lifecycle rule (R1.7).
- SNS fans out result events to the extractors, each through its own SQS queue.
The data model
sites: one row per site, partition key host
| Attribute | Type | Notes |
|---|---|---|
host | String | shop.example.com |
seed_url | String | Where the daily crawl starts |
status | String | ACTIVE, PAUSED |
max_pages_per_day | Number | A safety cap; a site that suddenly has 10× more pages is stopped and flagged |
robots_rules | Binary | The parsed rules for our user agent, compressed |
robots_fetched_at | Number | Epoch seconds; refetch after 24 h |
crawl_delay_s | Number | From robots.txt, 1 to 60 |
down_until | Number | Set when the site fails repeatedly (R1.9) |
pages: one row per URL per day. Partition key site_day, sort key url_hash
| Attribute | Type | Notes |
|---|---|---|
site_day | String | shop.example.com#2026-09-27: all of a site's pages for one day, readable with one Query |
url_hash | String | Hex SHA-256 of the normalized URL (fixed length, so it sorts safely as a string) |
url | String | The normalized URL |
state | String | QUEUED, FETCHING, FETCHED, FAILED, SKIPPED |
state_at | Number | When the state last changed; the sweeper re-queues FETCHING rows older than an hour |
http_status, content_sha256 | Number, String | From the fetch |
warc_uri, warc_offset, warc_length | String, Number, Number | The pointer into S3 |
expires_at | Number | DynamoDB TTL, 40 days out: clean-up only |
TTL here is only housekeeping. DynamoDB deletes expired items eventually, typically within a few days, not at the second. That is fine because nothing depends on the delete happening on time: every key includes its date, so an old row can never be mistaken for today's.
Tracing one page
Synthesizing vector architecture diagram...
The host's lane stays closed from receive to delete, so the next shop.example.com request starts at 03:14:07.2 at the earliest. The row becomes FETCHED only once the WARC file is in S3.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Politeness | ≤ 1 request in flight per host; ≥ 1 s between request starts; robots.txt always obeyed | From R1.1 |
| Crawl window | All sites done within 12 hours of the seed | Leaves 12 hours for retries and for the extractors |
| Completeness | ≥ 99% of reachable pages fetched each day | Some sites are down on any given day |
| Durability | No fetched page lost after its row says FETCHED | WARC in S3 first, then the row |
Traffic
| Item | Math | Result |
|---|---|---|
| Pages per site | 1,000,000 ÷ 10,000 | 100 on average |
| Rate in a 12-hour window | 1,000,000 ÷ 43,200 s | 23.1 pages/s |
| Concurrent fetches | 23.1 pages/s × 1.5 s average fetch time | ~35 in flight |
| Worker capacity | 3 Fargate tasks × 50 concurrent fetches | 150 in flight ≈ 100 pages/s: 4.3× headroom |
The 1.5-second fetch time (DNS, TCP and TLS setup, server think time and transfer) is an assumption for mixed small sites; we measure it on day one.
The biggest site sets the crawl length. One lane per host means a host's pages go one at a time, and each takes max(fetch time, 1 s). Suppose the largest shop has 20,000 pages (an assumption about our list): 20,000 × 1.5 s = 30,000 s ≈ 8.3 hours. That fits in 12 hours. A site with 40,000 pages would need 16.7 hours and could not finish; that's the follow-up in R1.11.
Page size. We fetch HTML only: no images, scripts or stylesheets. Common Crawl's August 2025 crawl is a good public reference: 2.44 billion pages were 424 TiB uncompressed and 88.24 TiB as compressed WARC files. That is 424 × 1,099.5 GB ÷ 2.44B ≈ 191 KB per page, and a compression ratio of 88.24 ÷ 424 ≈ 0.21. We plan with 200 KB per page and a ratio of 0.2, so 40 KB per page compressed. (A figure like 500 KB is closer to a whole page with its images and scripts, which a crawler that stores HTML doesn't download.)
Bytes
| Item | Math | Result |
|---|---|---|
| Downloaded per day | 1M × 200 KB | 200 GB (6 TB a month) |
| Bandwidth in the window | 23.1 × 200 KB | 4.6 MB/s ≈ 37 Mbps |
| Stored per day | 200 GB × 0.2 | 40 GB compressed WARC |
| WARC output per worker | 40 GB ÷ 3 workers ÷ 12 h | 1.1 GB/hour ≈ 18.5 MB/min |
| WARC files | The 10-minute rule wins: ~185 MB files, 72 per worker in 12 hours | ~216 PUTs a day: negligible |
DynamoDB calls
| Item | Math | Result |
|---|---|---|
| Same-site links per page (assumed) | 60 | |
| Seen-set reads | 60M links/day, eventually consistent, 0.5 read units each | 30M read units/day, 900M/month |
| New-URL conditional puts | ~1M rows/day | 30M write units/month |
Mark FETCHING, then FETCHED | 2M/day | 60M write units/month |
Rough monthly cost (us-east-1 list prices; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Workers | 3 Fargate tasks × (1 vCPU × $0.04048 + 2 GB × $0.004445) × 730 h | $110 |
| Control service | one small Fargate task | $20 |
| Public IPv4 addresses | 3 × $0.005/h × 730 h | $11 |
| SQS FIFO | ~3 calls per page with batching ≈ 90M calls × $0.50 per million | $45 |
| DynamoDB on-demand | 900M read units × $0.125/M + 90M write units × $0.625/M | $170 |
| S3 | After a year: 30 days in Standard (1.2 TB × $0.023) + 335 days in Standard-IA (13.4 TB × $0.0125) | $195 |
| SNS | 30M publishes × $0.50/M | $15 |
| CloudWatch logs and metrics | estimate | $50 |
| Total | ≈ $620 |
Two lessons hide in this table. A NAT gateway would have added 3 × $0.045 × 730 ≈ $99 in hourly charges plus 6,000 GB × $0.045 = $270 in data processing: about $370, more than the workers and the queue together. And the seen-set reads are the biggest line, because links outnumber pages 60 to 1. Round 2 has about 33 times the pages (1B a month against ~30M) but on the open web, where links point anywhere and billions of URLs are already known, so checking links, not fetching pages, is what decides its design.
R1.8 Trade-Offs
One shared queue vs a lane per host
| One standard SQS queue | FIFO with a message group per host (chosen) | |
|---|---|---|
| Politeness | None: any number of workers can hit one host | One request at a time per host, enforced by SQS |
| Throughput | Nearly unlimited | 300 calls/s per action (3,000 messages/s batched) in basic mode; enough here |
| Order | Best-effort | In order within a host's lane (we don't need order, only exclusivity) |
| Failure behavior | A retry goes to any worker at once | A failing message keeps its host's lane closed, which is what we want for a struggling host |
Stateless workers vs host-affine workers
| Stateless, SQS assigns hosts (chosen) | Host-affine: each worker owns a set of hosts | |
|---|---|---|
| Crash handling | Visibility timeout returns the URL to anyone | The dead worker's hosts wait until reassigned |
| Caches | A host's pages spread over all workers: colder DNS and connection caches | One worker per host: warm DNS, reused TLS connections, robots rules in memory |
| Scaling | Add workers any time | Adding a worker means moving hosts |
| Where politeness lives | The queue | The worker's per-host timers |
At 10K hosts and 23 pages a second, the stateless design is simpler and plenty fast. Host affinity wins when per-host state gets expensive to share, which is Round 3.
Pages in S3 vs in a database
| WARC files in S3 (chosen) | A row per page in a database | |
|---|---|---|
| Cost per GB-month | $0.023 Standard, $0.0125 Standard-IA | $0.25 in DynamoDB, 10–20× more |
| Bulk re-reads | Scan whole files at full speed | One read per page |
| Single-page read | A ranged GET using the offset | One key lookup |
| Standard format | ISO 28500; many existing tools read it | Our own schema |
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A worker crashes | Its in-flight messages stop; those hosts' lanes stay closed. | After the 120-second visibility timeout the messages reappear and other workers take them. Pages it fetched but hadn't uploaded are lost; their rows are stuck at FETCHING, and the sweeper re-queues them after an hour. |
| A site is down | Connection refused or 503 on its home page. | The message is returned with a growing delay (ChangeMessageVisibility to 1 min, then 10 min, then 1 hour), which keeps the host's lane closed meanwhile. After 3 failures the site is marked down_until tomorrow; its queued URLs are dropped as SKIPPED and it shows up in the morning report. |
| A site is very slow | Fetches that take tens of seconds. | Timeouts: 5 s to connect, 10 s to the first byte, 30 s in total. A timeout counts as a failure for that URL. If a site's median fetch time doubles, we add its extra delay to the spacing, so we slow down with it. |
| A page is huge or broken | A 300 MB "page", or HTML that sends the parser into a loop. | We stop reading after 5 MB and mark the record truncated (WARC has a WARC-Truncated header for this); the parser has a time limit; a page that crashes a worker three times goes to the DLQ. |
| A site grows 10× overnight | A shop's pages jump from 3,000 to 30,000. | max_pages_per_day stops it at 25,000 and flags it for ops. The cause is often a new filter or sort parameter that multiplies URLs (Round 2 calls these traps). |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | A durable queue decouples discovery from fetching; visibility timeouts and a DLQ; the pages table as the source of truth, with a sweeper; timeouts and backoff per site; workers spread over 3 AZs. REL 4 · REL 5 · REL 11 |
| Performance Efficiency | Many concurrent fetches per worker, because fetching is waiting; pages in bulk files, read by offset. PERF 2 · PERF 3 |
| Security | Workers fetch untrusted content, so they get no inbound access, run in their own tasks with a narrow IAM role (write to one bucket and two tables, send to one queue and one topic), and parse with size and time limits. SEC 3 · SEC 5 · SEC 6 |
| Cost Optimization | ≈ $620/month derived; no NAT gateway on the fetch path; batched reads before conditional writes; lifecycle to Standard-IA. COST 4 · COST 8 |
| Operational Excellence | Light this round: a morning report per site (pages, errors, finished or not); alarms on queue age, DLQ depth and pages per second. OPS 8 |
| Sustainability | Light this round: HTML only, no images or scripts; archives expire after a year. SUS 4 |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks which sites, how deep, how often and what to keep, and says what each answer changes.
- Makes the frontier durable and the workers stateless, and explains what happens when a worker dies mid-fetch.
- Normalizes URLs before deduplicating, and makes "queue once" correct with a conditional write.
- Treats politeness as a per-host property, not a global rate.
- Knows what robots.txt allows and forbids, and that
Crawl-delayisn't in the standard. - Stores pages in archive files with offsets, and finds the NAT gateway line in the bill.
Follow-up questions
-
"A site on the list has 40,000 pages. What happens?" Answer: at 1.5 s a page, one lane takes 16.7 hours, which misses our 12-hour window. Options, in order: ask the site for a higher rate (many large sites allow a few requests a second, and robots.txt may already say so); crawl only the pages that change daily (their listing and category pages) and the rest on a rotation; or accept a two-day cycle for that site. We never solve it by opening a second lane to the same host.
-
"Why not a standard SQS queue plus a lock per host?" Answer: it can work, but every worker that receives a locked host's URL must put it back, and at scale most receives would be wasted on hosts someone else holds. The FIFO group does the "one at a time per host" check inside SQS, for free.
-
"Two workers discover the same new link at the same moment. Walk me through it." Answer: both batch-read the pages table and see no row. Both try a conditional put with
attribute_not_exists. DynamoDB applies one; the other getsConditionalCheckFailedExceptionand drops the link. Only the winner sends it to the queue.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Random sleeps make us polite" | They lower the average load but allow any number of simultaneous requests to one host. |
| "Check robots.txt before every page" | Doubles our requests to every site; RFC 9309 lets us cache for 24 hours. |
| "Crawl-delay is part of the robots.txt standard" | RFC 9309 doesn't define it; crawlers treat it differently. |
| "Dedup on the raw URL string" | The same page has many spellings; normalize first. |
| "Store each page as a database row" | 10–20× the storage price, and no bulk reads. |
| "Put the workers behind a NAT gateway" | $0.045 per GB downloaded, on a system whose whole job is downloading. |
Round 2 · Senior · "A Billion Pages a Month, Politely"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 1B pages/month: 386/s average, 1,200/s planned peak · 5B URLs tracked · Spot fleet · zero politeness incidents
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We crawl 10,000 known news and shop sites every day: a million pages in a 12-hour window, about 23 pages a second. The frontier is an SQS FIFO queue where each host is its own message group, so only one worker at a time works on a host, and the worker deletes the message only one second after the fetch started, which spaces requests to every host at least a second apart. Workers are three stateless Fargate tasks in public subnets with no inbound access and no NAT gateway. URLs are normalized, then deduplicated through a per-day DynamoDB table with conditional puts, so each URL is queued once. robots.txt is parsed per host and cached for 24 hours, as RFC 9309 allows. Pages go into WARC files in S3, compressed per record, with offsets in the table, and a row becomes FETCHED only after its file is uploaded. About $620 a month. Open costs: the seen set costs a database call per link, every page is treated as equally important, and we only know how to crawl a list of friendly sites once a day."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: the FIFO queue enforces politeness, the pages table enforces "queue once" and records what's durable, and S3 holds the archive.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Too slow; a crash loses the list | SQS + stateless workers | Workers can't see each other's work |
| 1.2 | Same page 50 times | Normalization; seen table with conditional puts | Two calls per page |
| 1.3 | Workers gang up on a small site | FIFO group per host; delete after the delay | Biggest site sets crawl length |
| 1.4 | Forbidden pages crawled | robots.txt cached 24 h | Up to 24 h to see new rules |
| 1.5 | Where pages go | WARC in S3 with offsets | Up to 10 min before a page is durable |
Open costs: a database call per discovered link; no idea which pages matter; nothing that handles an infinite site, a hostile server, or a page that changed.
R2.1 The Scope Raise
Interviewer: "The aggregator worked. Now we're building a search engine. Start from a seed list and follow links anywhere on the web. We want a billion pages a month. You'll discover billions of URLs, and a lot of the web is duplicated. Some sites are effectively infinite, and some servers are hostile. Pages change, so we need to refetch them, but we can't afford to refetch everything. Compute must be cheap: use Spot. And a few percent of the sites we care about show nothing without JavaScript."
A scope raise is not the end of scoping. We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many distinct URLs will we be tracking? | About 5 billion discovered in any 30-day window. | 80 billion links a month to check against 5 billion known URLs. A database call per link costs about $50K a month (R2.6), so we need an in-memory "probably seen" filter (step 2.1). |
| How much of the web is duplicated? | A lot: printer versions, session IDs in URLs, the same article on many sites, the same product under many categories. | Exact hashes miss "same page, different footer". We need near-duplicate detection (step 2.2). |
| Is every page equally important? | No. A national news homepage matters far more than page 400 of an old forum thread. | FIFO order wastes the budget. We need priorities, without breaking politeness (step 2.3). |
| What have you seen go wrong? | A calendar that generated a million URLs; a shop with a session ID in every link; a response that decompressed to 10 GB and crashed a worker. | Trap defenses (step 2.4) and hard limits on every response (step 2.7). |
| Spot instances can vanish with two minutes' notice. Is it OK to fetch a page twice? | Yes, occasionally. Losing pages or URLs is not OK. | Work must be idempotent by URL, with checkpointing on the interruption notice (step 2.5). |
| How fresh must pages be? | News hubs within an hour or two; most pages within a month. Unchanged pages shouldn't cost us much. | Adaptive recrawl intervals and conditional requests that return 304 Not Modified (step 2.6). |
| JavaScript sites: how many, and which? | A few percent of the pages we care about; we'll give you the list of domains. | A small separate pool of headless browsers, only for those domains (R2.5). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Sites | 10K, fixed list | The open web, from seeds |
| Pages | 1M/day (~30M/month) | 1B/month: 386/s average, 1,200/s planned peak |
| URLs tracked | ~1M/day | 5B in a 30-day window |
| Duplicates | Exact URL only | + near-duplicate content |
| Order | Whatever the queue gives | By importance, never breaking politeness |
| Freshness | Everything daily | Adaptive, 1 hour to 30 days per page |
| Compute | 3 Fargate tasks | Spot EC2 fleet; interruptions lose nothing |
| Politeness | 1 request/s per host | + a cap per registered domain; zero incidents |
| Storage | 40 GB/day | 40 TB/month compressed, 480 TB/year |
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| A DynamoDB read per discovered link | 1B pages × 80 links = 80B lookups a month, 96K a second at peak. It's slow and costs tens of thousands of dollars a month. |
| Exact-URL and exact-content dedup | Near-copies (same article, different ads or footer) have different bytes and different hashes. We'd store and index each one. |
| One FIFO order | Important pages wait behind millions of unimportant ones. |
| "Follow every same-site link" | A calendar or a session-ID site generates URLs forever. One host eats the budget. |
| DNS through the OS resolver | 1,200 lookups a second with no cache all go to the VPC resolver. Spread over 16 instances that's ~75 lookups (~150 packets) a second each, fine today; but the resolver accepts at most 1,024 packets a second per network interface (a hard limit), so packing the fleet onto a few big instances, or retries during an outage, gets close. A local cache removes most lookups. |
| Fargate on-demand workers | Fine, but Spot EC2 is the cheap option asked for, and Spot instances vanish. Anything buffered only in memory is lost. |
| "Seen today" means "don't fetch" | On the open web, a URL is seen once and then refetched forever. A seen filter that says "seen" blocks every recrawl. |
| No response limits | One hostile response can take a worker down. |
We fix them in this order: the seen filter (2.1), near-duplicates (2.2), priority (2.3), traps (2.4), Spot (2.5), recrawls (2.6) and bombs (2.7).
R2.3 New Requirements and API Additions
The job message gains fields (sent to the per-host FIFO queue):
json{ "url": "https://news.example.org/world/2026/09/27/storm-update", "url_hash": "9c1e0f4b7a2d3e58", "host": "news.example.org", "registered_domain": "example.org", "priority_class": 1, "depth": 3, "is_recrawl": true, "due_version": 1790503200, "conditional": { "etag": "\"a7f3-5e2b\"", "last_modified": "Sat, 26 Sep 2026 22:10:04 GMT" } }
priority_classis 1 (high), 2 or 3 (low), from step 2.3.is_recrawlsays we've fetched it before, so the worker sends a conditional request (step 2.6).due_versionis the scheduling time the message was created for. It lets a worker spot a stale copy of a message (step 2.3).registered_domainis the part of the host a person can register (example.orgfornews.example.org), found with the Public Suffix List, the public list of suffixes like.co.ukunder which anyone can register names. It's the key for the domain-level politeness cap (step 2.3).
The result event gains a content fingerprint and a verdict, and moves to a Kinesis data stream the indexer reads:
json{ "url": "https://news.example.org/world/2026/09/27/storm-update", "http_status": 200, "fetched_at": "2026-09-27T08:00:04Z", "changed": true, "simhash64": "e3a1c07f5b2d9410", "near_duplicate_of": null, "links_found": 84, "links_new": 9, "rendered": false, "truncated": null, "warc": { "s3_uri": "s3://crawl-archive/2026/09/27/use1-b-w07-000213.warc.gz", "offset": 41943040, "length": 38112 } }
A 304 recrawl produces "http_status": 304, "changed": false and no warc. A near-duplicate produces near_duplicate_of with the URL hash of the page it copies, and no warc.
A per-host policy record in a DynamoDB hosts table holds everything about how we treat one host:
| Attribute | Example | Notes |
|---|---|---|
host (partition key) | news.example.org | |
robots_rules, robots_fetched_at | compressed rules, epoch | 24-hour cache, as in Round 1 |
crawl_delay_s | 1 | Minimum spacing; Crawl-delay from robots.txt, capped at 60 |
backoff_until | epoch | Set on 429 and 503 (step 2.4, R2.8) |
pages_this_window | 18,204 | Pages fetched in the current 30-day window, for the per-host budget |
budget | 50,000 | Per-host cap per 30 days; raised for allowlisted large sites |
trap_rules | list of blocked path prefixes | Added by the trap detector (step 2.4) |
needs_render | false | Routes the host to the headless pool |
last_ips | ["203.0.113.40"] | For monitoring only; we always resolve fresh (R2.8) |
R2.4 Design Evolution: The Open Web Fights Back
Step 2.1: Checking 5 Billion URLs in a Database Is Too Slow and Costly
The problem: every fetched page gives about 80 links, so at peak we check about 96,000 links a second against the 5 billion URLs we already know. Most of them are already known. A DynamoDB conditional put per link would cost about $50,000 a month (R2.6). What would you do? A few wrong "already seen" answers are acceptable. Wrong "new" answers are not, because they'd queue duplicates.
Primitive: Bloom Filters & Counting Filters · Drill: Bloom filter crawler dedup
Step 2.2: Thousands of Pages Are the Same Page With a Different Footer
The problem: a shop shows the same product at /p/123, /category/shoes/p/123 and /p/123?ref=home, each with a different "recently viewed" box. A news article is syndicated to 200 sites with different headers. Their bytes differ, so their SHA-256 hashes differ, and we store and index every copy.
What would you do? We need "almost the same", cheaply, for a billion pages a month.
A tiny worked example with 8-bit fingerprints. Page A has four tokens:
| Token | Weight | Hash (8 bits) |
|---|---|---|
| cheap | 2 | 1 0 1 1 0 0 1 0 |
| red | 1 | 0 1 1 0 1 1 0 0 |
| shoes | 3 | 1 1 0 0 0 1 1 1 |
| sale | 1 | 0 0 1 1 1 0 1 0 |
Counter for bit position 1 (leftmost): cheap has 1 (+2), red has 0 (−1), shoes has 1 (+3), sale has 0 (−1): total +3. Doing all eight positions:
| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Counter, page A | +3 | +1 | +1 | −1 | −3 | +1 | +5 | −1 |
| Fingerprint A | 1 | 1 | 1 | 0 | 0 | 1 | 1 | 0 |
Page B is the same page with a footer token 2026 (weight 1, hash 0 1 0 1 0 1 0 1) added. Each counter moves by exactly 1:
| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Counter, page B | +2 | +2 | 0 | 0 | −4 | +2 | +4 | 0 |
| Fingerprint B | 1 | 1 | 0 | 0 | 0 | 1 | 1 | 0 |
Only position 3 flipped (its counter was +1 and dropped to 0, which counts as "not above 0"). The Hamming distance, the number of differing bits, is 1: a near-duplicate. Two unrelated pages differ in about half their bits, about 32 of 64.
Finding near matches: bit-block tables. Split each 64-bit fingerprint into 4 blocks of 16 bits. If two fingerprints differ in at most 3 bits, those 3 bits fall in at most 3 blocks, so at least one block is identical (the pigeonhole principle). So we keep 4 tables; table i maps "the value of block i" to the fingerprints that have it. To check a new fingerprint:
- Look up each of its 4 blocks in its table.
- For every candidate returned, compute the full distance: XOR the two fingerprints and count the 1 bits.
- Any candidate at distance ≤ 3 is a near-duplicate.
A table keyed by 16 bits spreads fingerprints over 65,536 buckets, so a host with 50,000 pages gives about 50,000 ÷ 65,536 ≈ 0.8 candidates per table, 3 in all. Our choice: we check near-duplicates within the same host, where most copies come from (session parameters, sort orders, printer versions). Cross-site copies (syndicated articles) are found by a daily batch job over all fingerprints and marked for the indexer.
Go deeper: for a global index, 16-bit keys give too many candidates (1B fingerprints ÷ 65,536 ≈ 15,000 per bucket). The Manku paper uses more tables with longer keys: split 64 bits into 6 blocks and build one table for each choice of 3 blocks, C(6,3) = 20 tables. Three differing bits spoil at most 3 blocks, so some table's 3 key blocks all match. Each key is about 32 bits, so a bucket holds about 10^9 ÷ 2^32 ≈ 0.2 fingerprints. The price is 20 copies of the index.
Step 2.3: We Spend Our Budget on Junk While Important Pages Wait
The problem: the frontier is a FIFO queue. A spam site's 2 million links arrived yesterday, so today's crawl is spent on it, while a national news homepage, discovered this morning, waits behind them. What would you do? Important pages first, but politeness must still hold, and unimportant pages must not starve forever.
Synthesizing vector architecture diagram...
The front tier picks which URL is worth fetching next; the back tier holds each host to one request at a time. The router fills the back tier only as fast as workers drain it, so priority applies whenever we're short of capacity.
Primitive: Distributed Locks & Leases (the domain slots are short leases that expire on their own)
Step 2.4: An Infinite Calendar Ate a Million Fetches
The problem: an events site has a calendar with a "next month" link on every page. It goes on forever: 2027, 2028, … 3026. Another site adds ?sid=8f3a… with a new session ID to every link, so every visit makes every page "new". Together they took a million fetches before anyone noticed.
What would you do? A spider trap is any site structure, accidental or deliberate, that generates unlimited URLs.
Step 2.5: Spot Instances Vanish Mid-Crawl
The problem: the fleet runs on Spot instances: spare EC2 capacity at a steep discount, which AWS can take back. When it does, the instance gets a two-minute warning and is then stopped. Each worker holds a WARC buffer of up to 10 minutes of pages, and about 110 fetches in flight. What would you do?
Synthesizing vector architecture diagram...
Everything the worker must save is saved in under a minute, well inside the two-minute notice. If the notice never comes, leases and visibility timeouts recover the same work, just later.
Step 2.6: Pages Change, but the Filter Says "Seen"
The problem: a news homepage changes every hour. The Bloom filter says we've seen its URL, so it's never fetched again. Meanwhile we'd waste fetches refetching a company's "About us" page, which hasn't changed in three years. What would you do?
Synthesizing vector architecture diagram...
A URL cycles between QUEUED and FETCHED for as long as it lives. The 6-hour lease brings back any URL whose message was lost; nothing depends on the queue remembering it.
Step 2.7: A Response Decompressed to 10 GB and Killed a Worker
The problem: a server sends a 10 MB response with Content-Encoding: gzip. It decompresses to 10 GB: a decompression bomb. The worker's memory fills and the kernel kills it, taking 110 in-flight fetches with it. Another server sends one byte every 20 seconds, holding a connection open for hours.
What would you do?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Checking 5B URLs per link | Bloom filter: 64 × 108 MB in Valkey, k = 8, p = 0.5%; rebuilt monthly | 0.5% of new URLs skipped |
| 2.2 | Near-copies | 64-bit SimHash, ≤ 3 bits; 4 block tables per host | Fingerprint storage and lookup |
| 2.3 | Junk before important pages | Mercator front and back queues; urls table + due index as the frontier; domain slots | A scheduler and a crude score |
| 2.4 | Infinite sites | Depth, per-host budget, pattern rules, near-duplicate stop | Some deep pages missed |
| 2.5 | Spot interruptions | Idempotent by URL; checkpoint on the notice; leases as backstop | Some duplicate fetches |
| 2.6 | Recrawls blocked by the filter | Scheduler recrawls; halve/double 1 h–30 d; conditional GET | State and index writes per URL |
| 2.7 | Bombs and slow drips | Streaming limits; isolated parser | Huge pages truncated |
R2.5 Architecture v2
The design splits into two paths: the frontier path decides what to fetch, and the fetch path fetches it.
Synthesizing vector architecture diagram...
The frontier path: links become rows in the urls table only once, and rows become messages only when they're due. The queues hold minutes of work; the table holds the frontier.
Synthesizing vector architecture diagram...
The fetch path: a worker checks the host's rules and takes a domain slot, resolves through a local cache, checks the IP before connecting, fetches with limits, parses in a separate process, and only then records the page as fetched.
What's new, and why:
- Spot workers on EC2. At 1,200 pages a second, a worker instance runs up to 150 fetches at once (R2.6). Each worker runs Unbound, a caching DNS resolver, on the instance itself: a host fetched once a second resolves from memory for the life of its DNS record, and the VPC resolver's hard limit of 1,024 packets a second per network interface stays far away.
- SSRF guard in the fetch path. SSRF (server-side request forgery) is getting our own server to make a request to somewhere it shouldn't, like our internal network. Every resolved address is checked before connecting (R2.8).
- High-throughput FIFO. Basic FIFO allows 300 calls a second per action (3,000 messages with batching); we need about 1,200 sends, receives and deletes a second at peak. High-throughput mode allows up to 70,000 calls a second per action in the largest regions. Workers keep Round 1's rule for batched receives: keep one message per host and return the other same-host messages at once with the host's delay, so no message's visibility timeout can run out while it waits behind another.
- Headless pool. A headless browser is a real browser engine with no screen, which runs a page's JavaScript and gives us the resulting HTML. Only hosts flagged
needs_rendergo there (R2.7). - SimHash service. A few memory-optimized instances holding the block tables, sharded by host. Its state is rebuildable: each fingerprint is also stored in its URL's row.
- ElastiCache for Valkey with
noeviction: the Bloom filters must never be evicted, and the slot keys expire on their own. Nothing in this cluster is a cache that can be refilled on a miss, so eviction would only lose data.
Trace 1: a new URL, end to end
Synthesizing vector architecture diagram...
The URL is stored once (the conditional put), queued when due (the scheduler's lease), fetched under both politeness levels, and recorded only after its bytes are in S3.
Trace 2: a recrawl that returns 304
Synthesizing vector architecture diagram...
An unchanged page costs about 200 bytes and one row update instead of 200 KB and a WARC record, and its next visit moves further out.
Trace 3: a calendar trap being cut off
events.example.com/calendar?month=2026-10is fetched; its "next" link leads to?month=2026-11, and so on.- From about 2027 onward, the months have no events, and every page renders the same empty grid. Pages 5 to 9 under
/calendarare each within 3 bits of the one before. - After the 5th near-duplicate in a row under the prefix
/calendar, the parser adds/calendarto the host'strap_rules. - Already-queued
/calendarURLs are dropped when received (BLOCKED); new links under it are dropped at normalization. - The host's growth alarm had not fired yet; the near-duplicate stop caught it after a few dozen fetches, not a million. If it hadn't, the 50,000-page budget would have.
R2.6 Numbers and Cost
Targets
| Quality | Target | Why this number |
|---|---|---|
| Throughput | 1B fetches/month; capacity for 1,200/s | From the scope raise; 3× headroom |
| Politeness | ≤ 1 in flight per host, ≥ 1 s apart; ≤ 5 starts/s per registered domain; 0 incidents | Being a guest |
| Lost work | 0 URLs lost; 0 pages lost after FETCHED | Spot must be free of data loss |
| Freshness | Each URL refetched within 1 h of its due_at | The scheduler's job |
| New-URL loss | ≤ 0.5% (Bloom false positives) | Chosen when sizing the filter |
Rates
| Item | Math | Result |
|---|---|---|
| Average | 10^9 ÷ (30 × 86,400 s) = 10^9 ÷ 2,592,000 | 385.8 ≈ 386 pages/s |
| Planned peak | 386 × 3 = 1,158 | 1,200 pages/s (catch-up after an outage, recrawl waves) |
| Links checked | 1,200 × 80 links/page | 96,000/s at peak; 80B a month |
| New URLs | from the scope raise | 5B per 30 days ≈ 1,930/s average |
Page size, verified and recomputed. The fact source for this track used 500 KB a page and a 0.35 compression ratio. We fetch HTML only, and Common Crawl's August 2025 numbers (R1.7) give about 191 KB per page and a 0.21 ratio. We use 200 KB and 0.2:
| Item | Math | With 500 KB × 0.35 | With 200 KB × 0.2 |
|---|---|---|---|
| Peak bandwidth | 1,200 × page size | 600 MB/s = 4.8 Gbps | 240 MB/s = 1.92 Gbps |
| Average bandwidth | 386 × page size | 1.54 Gbps | 77 MB/s = 0.62 Gbps |
| Raw per month | 10^9 × page size | 500 TB | 200 TB |
| Stored per month | raw × ratio | 175 TB | 40 TB |
| Stored per year | × 12 | 2.1 PB | 480 TB |
These are upper bounds: every 304 and every near-duplicate stores nothing, and servers that compress their responses send fewer bytes on the wire.
The Bloom filter (from step 2.1): m = 55.14 × 10^9 bits = 6.9 GB (6.42 GiB), k = 8, as 64 filters of 108 MB. During a monthly rebuild two sets exist: 12.84 GiB. On 3 shards of cache.r7g.large (13.07 GiB each), that's 4.3 GiB per shard at worst. Commands: a page's 80 links fall into about 50 of the 64 filters, so about 60,000 pipelined Bloom commands a second at peak, 20,000 per shard (an estimate we'd load-test).
Why the filter pays for itself: without it, every link is a DynamoDB conditional put: 80 × 10^9 × $0.625 per million writes = $50,000 a month. With it, only the ~5B new URLs reach DynamoDB.
Fetch fleet
| Item | Math | Result |
|---|---|---|
| Fetch time | DNS (cached), TCP and TLS setup, server time, transfer | 1.5 s average (assumed; measured in production) |
| Concurrent fetches at peak | 1,200 × 1.5 s | 1,800 |
| Per instance | 150 concurrent fetches: worst case 150 × (10 MB decompressed + parse) fits in 16 GiB; CPU ≈ 100 pages/s × ~20 ms = 2 of 8 vCPUs (assumed) | a conservative start, raised after load tests |
| Instances | 1,800 ÷ 150 = 12 | 16, spread 6/5/5 over 3 AZs; losing an AZ leaves 10–11, about 1,000 pages/s, still 2.6× the average |
| In-flight FIFO messages | 1,200/s × ~1.5 s held | ~1,800, far below the 120,000 limit for FIFO queues |
| DNS per instance | 1,200 ÷ 16 = 75 fetches/s; worst case 2 queries each | 150 packets/s, under 1,024 even with no cache hits |
SimHash service: fingerprints of the last 30 days, about 1B pages. Each fingerprint appears in 4 tables at 12 bytes (8-byte fingerprint + 4-byte page reference): 10^9 × 4 × 12 B = 48 GB. We use 4 shards by host, each with a replica, on r7g.xlarge (32 GiB) instances.
Rough monthly cost (us-east-1 list prices; Spot prices change constantly, so check the Spot price history before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Fetch fleet | 16 × c6i.2xlarge on Spot at an assumed $0.16/h (on-demand $0.34/h) × 730 h | $1,870 |
| Headless pool | 6 instances of the same class on Spot | $700 |
| ElastiCache for Valkey | 6 × cache.r7g.large (3 shards, primary + replica) at ~$0.175/h | $770 |
DynamoDB urls writes | New URLs 5B × 2 write units (item + due index) + fetch results 1B × 3 + scheduler leases 1B × 3 = 16B/month ≈ 6,200 write units/s average; provisioned with auto scaling ~8,000 × $0.00065/h × 730 h (on-demand would be ~$10,000) | $3,800 |
| DynamoDB storage | 5B rows × ~250 B + due index ~100 B ≈ 1.75 TB × $0.25/GB | $440 |
| DynamoDB reads | hosts table and stale-message checks: ~2B × 0.5 read units × $0.125/M | $125 |
| SQS | Front queues and lanes: ~7 calls per page before batching, ~2 after | $1,000 |
| Kinesis Data Streams | 3 provisioned shards + 1B records | $50 |
| SimHash service | 8 × r7g.xlarge × $0.2142/h × 730 h | $1,250 |
| Scheduler, router, control | Fargate | $300 |
| S3 after one year | Standard 30 days (40 TB × $0.023 = $920) + Standard-IA to day 90 (80 TB × $0.0125 = $1,000) + Deep Archive after (360 TB × $0.00099 = $356) | $2,300 |
| Outbound bytes | Requests, TLS handshakes and TCP acknowledgements, estimated at ~5 KB per fetch: 5 TB × $0.09/GB | $450 |
| Public IPv4 addresses | 22 instances × $0.005/h × 730 h | $80 |
| CloudWatch | Metrics, logs, alarms | $500 |
| Total | ≈ $13,600 = $13.6 per million pages |
Three lessons:
- The frontier table is the biggest line, bigger than all the compute. Every URL we know costs writes, not just every URL we fetch. Round 3 attacks this.
- Spot makes compute cheap. The fetch fleet on-demand would be
16 × $0.34 × 730 ≈ $3,970; Spot saves about $2,100 a month here, roughly half off at current prices (our assumed $0.16/h; Spot prices move). - Storage grows forever. Deep Archive adds about 480 TB a year, ~$475 a month more each year, and retrieving it takes hours (up to 12 hours standard, 48 hours bulk), which is fine for archives we rarely reread.
R2.7 Trade-Offs
How to build the per-host back queues
| SQS FIFO, a message group per host (chosen) | Kafka, partitioned by host | One shared queue | |
|---|---|---|---|
| Politeness | One message in flight per group, enforced by SQS; plus our delete-after-delay | One consumer per partition, but a partition holds many hosts | None: several workers can hit one host |
| Scale in hosts | Millions of groups in one queue | Partitions are a fixed, cluster-wide resource, thousands per broker; millions of hosts hash into them, and one slow host blocks every host in its partition | Unlimited |
| Priority | In the front tier, before the lanes | Hard: consumers read in offset order | None |
| Worker failure | Visibility timeout, per message | Consumer-group rebalance pauses the partitions it owned | Visibility timeout |
| Limits to watch | 120,000 in-flight messages; 14-day retention; 70,000 calls/s per action in high-throughput mode | Broker sizing and partition counts | – |
| Operations | Managed | A cluster to run | Managed |
The in-flight limit is worth a sentence in an interview: with one message in flight per active host, 120,000 in flight caps how many hosts we can crawl at the same moment. We use about 1,800. At 20 times the rate (Round 3), fetches alone would hold about 23,000/s × 1.5 s ≈ 34,500 messages, 29% of the limit; the pressure comes from messages parked in flight during backoffs and domain-slot waits (R2.8), which can grow without bound when many hosts slow down at once. That, the 14-day retention and the per-call bill are why Round 3 builds its own frontier.
Bloom filter vs an exact set
| Bloom filter (chosen) | Exact set of 64-bit URL hashes | |
|---|---|---|
| Memory for 5B | 6.9 GB | 40 GB packed; several times that in a Valkey set |
| Wrong answers | 0.5% of new URLs called "seen" | Practically none: with 5B 64-bit hashes, we expect under one collision |
| Deletes | Not possible: rebuild monthly | Possible |
| When it degrades | Silently, past its sized capacity (non-scaling filters) | Never; it just grows |
The Bloom filter wins because a false positive costs little (a page we skip, with the table as the backstop) and the exact set costs 6× or more memory. If losing any new URL were unacceptable, we'd keep the exact set on disk-backed storage and accept slower checks.
Headless rendering: when
| Plain HTTP fetch | Headless browser | |
|---|---|---|
| Cost per page | ~20 ms of CPU | Seconds of CPU and hundreds of MB of memory (roughly 20× a plain fetch, by the fact source's estimate) |
| Requests to the site | 1 | Many: scripts, styles, API calls, each counted against the host's politeness |
| Output | The HTML the server sent | The page after its JavaScript ran |
We render only hosts on the list, and only when a plain fetch returns an empty shell. The renderer blocks images, video and fonts, caches script files between pages, and every request it makes goes through the same host lanes and domain slots.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| A spider trap | One host's new-URL count grows 10× in a day; its pages are near-identical. | The near-duplicate stop and pattern rules cut it; the 50,000-page budget bounds it; the growth alarm puts it in front of a person. | – |
| DNS throttling | Lookups time out; fetch errors rise on every host at once. | Each worker's Unbound cache answers most lookups; misses stay under the 1,024 packets/s per interface limit (R2.6). If failures pass 5%, we check whether a worker's cache restarted empty, and spread load over more instances. | – |
| SSRF and DNS rebinding | A link like http://metadata.attacker.example/ that resolves to 169.254.169.254, or a host that resolves to a public IP on the first lookup and a private one on the second. | Resolve once; reject private and reserved ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0/8, and IPv6 ::1, fc00::/7, fe80::/10, plus IPv4 addresses embedded in IPv6); connect to that exact IP (pinning it for this connection), sending the host name in SNI and the Host header. Every redirect is checked the same way. Workers require IMDSv2, whose token needs a PUT with a special header our fetcher never sends. We can't rely on the network for this: security groups don't filter traffic to the instance metadata service. | – |
| A Spot wave | A third of the fleet gets notices within minutes. | Each worker checkpoints (step 2.5); Auto Scaling launches other instance types; the queue backs up and drains. Throughput dips, nothing is lost. An alarm fires if more than 30% of the fleet is interrupted in an hour, and we add instance types. | – |
A host returns 429 or 503 | The host is overloaded or asking us to slow down. | Per-host backoff: honor Retry-After if present; otherwise set backoff_until to 1 minute, doubling to 1 hour on repeats. Messages for that host are returned with the delay, which keeps its lane closed. Its spacing doubles for the next day. | – |
| A stalled lane or slot | A host gets no fetches for hours although URLs are due. | Lanes and slots can't stall forever: a hung worker's message reappears after the 120 s visibility timeout, and slot keys expire after 1 s. An alarm on "oldest message age per class" catches a lane held by a bug that keeps extending visibility. One risk remains: Valkey replicates asynchronously, so a failover can lose a just-taken slot, allowing one extra request to a domain within a second. We accept that; it's rare and bounded. | – |
| The Bloom filter is lost | Valkey loses a shard and its replica. | New links are checked against the urls table directly (conditional puts only; expensive but correct) while the filters are rebuilt from the table, about 4.6 hours. | Bloom filter crawler dedup |
R2.9 Production Gotchas
1. A visited set only in a database
- Symptom: the database bill and latency climb with the number of links, not pages.
- Cause: a lookup per discovered link, 80 per page.
- Fix: a Bloom filter in front (step 2.1); the database stays the source of truth.
2. Rendering JavaScript everywhere
- Symptom: the fleet costs many times more, and sites complain about request volume.
- Cause: a headless browser for every page, fetching every script, style and API call.
- Fix: plain fetches by default; render only listed hosts, with the same politeness applied to every sub-request.
3. No robots.txt caching
- Symptom: twice the request volume to every host; some block us.
- Cause: fetching
/robots.txtbefore each page. - Fix: parse once, cache for up to 24 hours (RFC 9309), and treat a
5xxrobots.txt as "disallow everything for now".
4. No content-size limits
- Symptom: workers die from out-of-memory errors at random.
- Cause: reading and decompressing whole responses into memory.
- Fix: streaming limits on wire bytes, decompressed bytes, ratio, time and speed (step 2.7).
5. Letting the crawler reach internal addresses
- Symptom: a security review finds fetched pages containing instance credentials or internal dashboards.
- Cause: fetching whatever a link resolves to.
- Fix: check every resolved IP, pin it for the connection, re-check redirects, require IMDSv2 (R2.8).
6. Clearing the seen filter to force recrawls
- Symptom: the queues flood with billions of known URLs after each reset.
- Cause: using one structure for "is it new?" and "is it due?".
- Fix: the filter answers only "new?"; the scheduler decides "due?" from the table (step 2.6).
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | The urls table as the frontier's source of truth with leases; idempotent fetches; checkpointing on Spot notices; per-host backoff honoring Retry-After; a Bloom filter rebuildable from the table. REL 5 · REL 9 · REL 10 · REL 11 |
| Performance Efficiency | Bloom checks in memory instead of 96K database calls a second; per-host SimHash block tables; local DNS caches; rendering only where needed. PERF 2 · PERF 3 |
| Security | SSRF and rebinding defenses; IMDSv2 only; isolated parsing with limits; least-privilege roles per component (below). SEC 3 · SEC 5 · SEC 6 |
| Cost Optimization | Spot fleet with diversified types; the Bloom filter saves ~$50K/month of writes; 304s and near-duplicates store nothing; S3 tiers; the frontier table named as the biggest line. COST 6 · COST 7 |
| Operational Excellence | Alarms with first actions (below); trap review list; allowlist workflow. OPS 8 · OPS 10 |
| Sustainability | Light this round: adaptive recrawl stops refetching pages that don't change, and conditional requests move ~200 bytes instead of 200 KB. SUS 2 · SUS 4 |
Security in detail. Workers have one IAM role: send and receive on the crawler queues, read and write the urls and hosts tables, put objects under one S3 prefix, put records to one stream. They can't read other prefixes or change any queue or table settings. The parser process runs as a separate, unprivileged user with memory and CPU limits and no network access; it gets bytes from the fetcher and returns links and a fingerprint. Worker instances accept no inbound traffic, and are replaced (not patched in place) from a new image every week.
Operations in detail.
| Alarm | Threshold | Severity | First action |
|---|---|---|---|
| Pages fetched per second | < 250 for 15 min | P2 | Check Spot capacity, queue depth, scheduler health |
| Politeness violations (two fetches to one host less than 1 s apart, from access logs) | > 0 | P1 | Find the worker and the path; pause the host |
| Oldest message age, class 1 | > 1 h | P2 | Router or lanes stuck? Are workers busy with class 3? |
| Bloom filter fill | > 80% of capacity | P3 | Start a rebuild early |
| DNS failure rate | > 5% | P1 | Check Unbound on the workers; check the VPC resolver limits |
| Spot interruptions | > 30% of fleet in 1 h | P2 | Add instance types or AZs |
| New URLs per host per day | 10× the host's normal | P3 | Review for a trap |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer adds over L5
- Sizes the Bloom filter with the formula, explains what a false positive costs, and knows how it degrades past capacity.
- Explains SimHash well enough to compute a tiny one, and how to find near matches without comparing against everything.
- Separates priority from politeness (the Mercator two tiers), and knows the frontier can't live only in a queue with a 14-day retention.
- Defends against traps, bombs and SSRF with specific limits.
- Designs for Spot so that correctness never depends on the interruption notice.
- Recrawls from the table, with adaptive intervals and conditional requests.
- Checks the fact source's numbers (the 500 KB page) instead of copying them.
Follow-up questions
-
"Your Bloom filter was sized for 5B. The crawl grows to 15B URLs in the window. What happens?" Answer: at 15B in a filter sized for 5B,
(1 − e^(−8 × 15 ÷ 55.14))^8 = (1 − 0.114)^8 ≈ 38%of new URLs would be skipped, and nothing would error: fewer new pages would just quietly appear. The 80% fill alarm fires long before. We rebuild at the new size,mscales linearly withn, so 15B at 0.5% is 20.7 GB, 192 filters of 108 MB. Valkey's scaling filters add layers as they fill, but every layer counts toward the 128 MB per-object limit, so with ~108 MB filters we'd rather split into more non-scaling filters than rely on scaling. -
"Why not check near-duplicates before fetching, to save the download?" Answer: SimHash needs the content, so it can't. What we can do before fetching is avoid URLs that lead to duplicates: stripped session parameters, sorted query strings, and trap rules learned from earlier near-duplicates. And
rel="canonical"in a fetched page tells us which URL the site considers the original, so we can lower the priority of the others. -
"A worker received a message, fetched the page, then Spot took it before the upload. Walk me through recovery." Answer: the page's row still has the lease the scheduler set,
due_at6 hours ahead, and was never markedFETCHED. The message itself was deleted after the politeness delay, so SQS won't return it. In at most 6 hours the row comes due again, the scheduler re-queues it, and it's fetched once more. Nothing is lost; the cost is a delay of up to 6 hours and one extra fetch.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "A Bloom filter can have false negatives" | It can't; it only says "seen" wrongly. The risk is skipping new URLs. |
| "Reset the Bloom filter to recrawl" | It re-queues billions of known URLs. Recrawl comes from the table. |
| "One Bloom filter key of 6.9 GB" | ElastiCache limits one Bloom object to 128 MB; split it. |
| "FIFO groups space out requests" | They only stop parallel requests. The delay is ours. |
| "Keep the frontier in SQS" | Messages expire after at most 14 days, and the in-flight limit caps active hosts. |
| "An exact hash catches near-duplicates" | One changed byte gives a different hash. |
| "Security groups block the metadata service" | Traffic to the instance metadata service isn't filtered by security groups. |
Round 3 · Architect · "Web Scale, Fresh, and a Good Citizen"
~45 min · Principal (L7) · 3 regions · 20B fetches/month: 7,716/s average, ~23,000/s peak · ~400M hosts, 200B known URLs · news fresh within 15 min · under $5 per million pages
R3.0 Where We Left Off
What the candidate says in the first 60 seconds of Round 3, and everything you need if you start here.
Round 2 in 60 seconds. "We crawl the open web from seeds: a billion pages a month, 386 a second on average, planned for 1,200, in one region on a Spot fleet. New links are checked against 64 Bloom filters in Valkey, 6.9 GB in all, sized for 5 billion URLs at 0.5% false positives, and only new ones get a conditional put into the
urlsDynamoDB table, which is the real frontier. A scheduler reads due URLs from an index on that table, leases them for 6 hours, and feeds three priority queues; a router picks from them 60/30/10 and moves URLs into SQS FIFO lanes, one per host, while 5 slots per registered domain in Valkey cap subdomain farms. Workers resolve DNS through a local cache, block private addresses, stream responses through hard limits, fingerprint pages with 64-bit SimHash and treat 3 bits or fewer as duplicates. Recrawls come from the table: intervals halve on change and double otherwise, between an hour and 30 days, with conditional requests that often return 304. Spot interruptions lose nothing: we checkpoint on the notice, and leases catch what's left. About $13.6 per million pages; the frontier table is the biggest line. Open costs: one region far from most hosts, a crude priority, a frontier that costs writes per URL known, a fixed politeness rate, and nothing for webmasters."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: the table decides what's due, the queues decide order and politeness, and the workers fetch safely and record results.
Rounds 1–2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Slow; crash loses the list | Queue + stateless workers | Workers can't see each other's work |
| 1.2 | Same page many times | Normalization; seen table | Calls per link |
| 1.3 | Workers gang up on a site | FIFO lane per host; delete after delay | Biggest site sets crawl length |
| 1.4 | Forbidden pages | robots.txt cached 24 h | Stale rules up to 24 h |
| 1.5 | Where pages go | WARC in S3 with offsets | Durable only after upload |
| 2.1 | 5B URLs to check | Bloom filters in Valkey | 0.5% of new URLs skipped |
| 2.2 | Near-copies | SimHash ≤ 3 of 64 bits | Fingerprint index |
| 2.3 | Junk first | Mercator two tiers; table as frontier | Crude score |
| 2.4 | Traps | Depth, budgets, patterns, near-duplicate stop | Some deep pages missed |
| 2.5 | Spot | Idempotent; checkpoint on notice | Some duplicate fetches |
| 2.6 | Recrawl | Halve/double 1 h–30 d; conditional GET | Per-URL state |
| 2.7 | Bombs | Streaming limits; isolated parser | Truncated pages |
Open costs: one region; a priority by host type; a frontier that pays per known URL and a queue whose in-flight limit caps active hosts; politeness that doesn't listen to the server; no identity or controls for webmasters.
R3.1 The Scope Raise
Interviewer: "We're now a major search and research index. We want 20 billion fetches a month across hundreds of millions of hosts. Breaking news must be in the index within minutes, while old archives barely change. Most hosts are far from our one region. Webmasters are complaining: some say we're too aggressive, some want to set our rate, and some want to stay in search but opt out of their content being used for AI training. The indexing team wants a clean stream of what's new. And the budget is fixed."
We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many URLs and hosts do we know? | About 200 billion URLs on about 400 million hosts. About 100 million hosts get at least one fetch a month. | A row per URL in DynamoDB would cost about $17,500 a month in storage alone. The frontier moves to our own disk-backed service (step 3.2). |
| Where are the hosts? | By where they're served from: roughly 50% North America, 30% Europe, 20% Asia-Pacific. | Fetchers in three regions, each crawling the hosts near it (step 3.1). |
| What does "fresh" mean, per kind of page? | Breaking news: in the index within 15 minutes of publication. Popular pages: within a day of changing. The long tail: within about three months. | A fixed budget spent by value and by how likely a page is to have changed (step 3.3). |
| What exactly are webmasters complaining about? | Our rate stays the same when their server is struggling. Some want to raise it, some to lower it. | Politeness that reacts to server health, with a webmaster-set ceiling (step 3.4). |
| The AI opt-out: fetching, or use? | Use. They still want to be in search. Legal says it must be honored for new and already-crawled content. | Opt-outs per purpose, enforced at fetch time and at use time (step 3.5). |
| What does the indexer want? | Only new or changed, non-duplicate pages, with a versioned contract, and the extracted text. | A handoff stream with a schema, published only after the archive write (step 3.6). |
| What's the budget? | Under $5 per million pages fetched, all-in. And tell us if we should buy data instead of crawling. | A cost-per-million view, storage tiers, and a build-versus-buy answer (step 3.6, R3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Fetches | 1B/month | 20B/month: 7,716/s average, ~23,000/s peak |
| Known URLs / hosts | 5B URLs | 200B URLs, ~400M hosts (~100M active a month) |
| Footprint | 1 region | 3 regions, each fetching the hosts near it |
| Priority | 3 classes by host type | Value per fetch: importance × chance of change ÷ cost |
| Freshness | Halve or double, 1 h to 30 d | Per class: news within 15 min, popular within a day, tail within ~3 months |
| Politeness | Fixed 1/s per host, 5/s per domain | Adaptive to server health, with a webmaster ceiling |
| Webmasters | robots.txt only | Verified identity, console, rate control, per-purpose opt-outs |
| Output | Every fetch as an event | Only new or changed non-duplicates, with text, under a versioned schema |
| Cost | $13.6 per million | Under $5 per million |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| Fetchers in one region | Most hosts are a long round trip away. A fetch with DNS, TCP and TLS setup takes several round trips, so an intercontinental host costs hundreds of extra milliseconds per page and more connections held open. |
| Three priority classes by host type | A class says nothing about this page: whether it changed, or how many pages point to it. With 20B fetches and 200B URLs, a crude score wastes most of the budget. |
The urls table as the frontier | 200B rows × ~350 B (row plus index) = 70 TB, about $17,500 a month in storage alone, plus writes for every URL discovered and every fetch: well over our whole budget. |
| SQS lanes | 120,000 in-flight messages caps the hosts we can work on at once. Fetches alone would hold ~34,500 (29%), but messages parked in flight during backoffs and domain-slot waits push it up whenever many hosts slow down together. Messages expire after 14 days. And SQS calls alone would be tens of thousands a month. |
| Fixed politeness | No feedback: we keep sending 1 request a second to a server whose latency has tripled, and send only 1 a second to a CDN that could take 20. |
| No webmaster controls | No way to verify that a request is really ours, no way to ask us to slow down except blocking us, no per-purpose opt-out. |
| Every fetch sent downstream | The indexer re-processes unchanged pages and duplicates. |
We fix them in this order: geography (3.1), the frontier (3.2), what to fetch (3.3), how fast (3.4), webmasters (3.5), and the handoff and budget (3.6).
R3.3 New Requirements and API Additions
Webmaster verification and rate control. A site owner proves they control a host, then sets our rate.
httpPOST /webmaster/v1/sites HTTP/1.1 Authorization: Bearer <webmaster account token> Content-Type: application/json { "host": "shop.example.com" }
httpHTTP/1.1 201 Created Content-Type: application/json { "host": "shop.example.com", "verification": { "method": "dns_txt", "record": "example-search-verify=3f9a1c07e5b2" }, "status": "PENDING" }
The owner adds that TXT record to their DNS and calls POST /webmaster/v1/sites/shop.example.com/verify. We look up the record; if it matches, the site is verified for that account.
httpPUT /webmaster/v1/sites/shop.example.com/crawl-rate HTTP/1.1 Content-Type: application/json { "max_requests_per_second": 2, "until": "2026-10-31T00:00:00Z" }
The rate is a ceiling our adaptive controller never exceeds (step 3.4). Raising it above our default needs verification; lowering it only needs verification too, but a site can always lower us without an account through robots.txt or by returning 429.
The opt-out registry holds one record per subject and purpose:
json{ "subject": "example.org", "scope": "registered_domain", "purposes": { "crawl": "allow", "search_index": "allow", "ai_training": "deny" }, "source": "webmaster_console", "verified": true, "effective_at": "2026-09-27T10:00:00Z", "registry_version": 48211 }
registry_version increases by one with every change, so every component can report which version it has applied.
The index-handoff event is a versioned contract:
json{ "schema": "crawl.handoff.v3", "event_id": "b41c9e0a7d2f5e63", "url": "https://news.example.org/world/2026/09/27/storm-update", "canonical_url": "https://news.example.org/world/2026/09/27/storm-update", "fetched_at": "2026-09-27T08:00:04Z", "change": "CHANGED", "simhash64": "e3a1c07f5b2d9410", "language": "en", "importance": 0.73, "usage": { "search_index": true, "ai_training": false }, "usage_registry_version": 48211, "warc": { "s3_uri": "s3://crawl-archive-use1/2026/09/27/use1-f031-000877.warc.gz", "offset": 41943040, "length": 38112 }, "text": { "s3_uri": "s3://crawl-text-use1/2026/09/27/use1-f031-000877.txt.gz", "offset": 1048576, "length": 9214 } }
event_id is a hash of the URL and fetched_at, so a consumer that sees the same event twice can drop the copy. change is NEW or CHANGED only; unchanged pages and duplicates never appear.
R3.4 Design Evolution: Spending a Fixed Budget Well
Step 3.1: Hosts Are Everywhere; Our Fetchers Are in One Region
The problem: half our hosts are served from Europe and Asia, and a fetch from Virginia to Singapore pays a ~200 ms round trip for DNS, TCP, TLS and the request, several times per page. Fetches are slower, each holds a connection longer, and we pay for it in fleet size. What would you do? And where does a host's politeness state live when fetchers are on three continents?
One more wrinkle: hosts are not the same size. Politeness caps each host's fetch load (a host can't take more than its rate), but not its frontier size: one encyclopedia host may know 100M URLs, a small blog 50. With plain hashing, a node that draws a few giant hosts holds far more data than its neighbors. We use bounded-load consistent hashing: a host goes to its ring owner unless that node already holds more than 1.25× the average, in which case it goes to the next node clockwise. The same bound handles traffic skew, not just data size: we measure a node's load as leases handed out per second plus stored URLs, so a node that drew several hot hosts (big news sites at their full adaptive rate) also sheds new hosts to its neighbor. The cost is that placement now depends on current loads, so it needs the controller's view of every node's load, and a load change can move hosts.
Primitive: Consistent Hashing · Drill: Consistent hashing vnode skew
Step 3.2: The Frontier Has 200 Billion Entries
The problem: we know 200B URLs. In Round 2 each lived as a DynamoDB row with an index entry, and every discovery and fetch was a write. At this size that's about $17,500 a month in storage before a single write, and SQS lanes cap how many hosts we can work at once. What would you do?
Synthesizing vector architecture diagram...
The frontier costs disk, not writes: all URLs sit sorted on local disk, and only the next few URLs per host and a heap of "who's next" live in memory. The change log and snapshots rebuild a lost node.
Primitive: Database Sharding & Partition Keys · Write-Ahead Log & LSM Trees
Step 3.3: We Have Budget for 20B Fetches: Which Ones?
The problem: 200B known URLs, 20B fetches. A breaking-news article must be indexed within 15 minutes; a popular page within a day of changing; the tail within about three months. Priority by host type spends the budget on pages that didn't change and misses pages that did. What would you do?
Step 3.4: A Site's Server Is Struggling Because of Us
The problem: a mid-size shop's median response time goes from 300 ms to 2 s at 1 request a second, and its owner writes to complain. Meanwhile a huge site behind a CDN, which could take 20 requests a second without noticing, is crawled at 1. What would you do?
Primitive: Distributed Rate Limiting · Circuit Breaker, Bulkhead & Fault Tolerance (pausing a failing host is a circuit breaker per host)
Step 3.5: Webmasters Want Control, and to Opt Out of Some Uses
The problem: a site owner sees heavy traffic claiming to be our crawler and can't tell if it's really us. Another wants to stay in our search results, but not have their pages used to train AI models. Today all they have is robots.txt, which only says "fetch" or "don't fetch". What would you do?
We keep legal claims general here: which purposes need consent and how quickly opt-outs must apply depend on jurisdiction, and legal decides the policy. The design's job is to make any policy they choose enforceable and auditable.
Step 3.6: The Indexer Needs Clean Input, and the Budget Is Fixed
The problem: the indexer receives 20B events a month, most of them unchanged pages and duplicates, and re-processes each. Storage grows by hundreds of terabytes a month. Finance asks what a million pages costs us, and whether we should crawl at all when public crawls exist. What would you do?
Primitive: Change Data Capture & the Outbox Pattern
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Hosts far from our region | Regional fleets; one owner per host; consistent hashing with vnodes and bounded load; epochs and self-fencing | A placement controller |
| 3.2 | 200B-entry frontier | Disk-backed frontier nodes: sorted store, heads, host heap; change log + snapshots | A custom stateful service |
| 3.3 | Which 20B fetches | Value = importance × P(changed) ÷ cost; feeds for news; class budgets | Stale tail by design |
| 3.4 | We overload a server | AIMD rate per host with a webmaster ceiling | Throughput varies |
| 3.5 | Control and opt-outs | Verifiable identity; per-purpose tokens; registry; enforce before fetch and before use | Coverage |
| 3.6 | Clean input, fixed budget | Outbox handoff of new/changed pages; S3 tiers; cost per million | A contract to maintain |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region fetches the hosts assigned to it, from frontier nodes that own those hosts' politeness. The control plane writes host placement, opt-outs and blocks from one region, and every frontier node reads replicated copies. Handoff events from all regions reach one indexer.
Inside one region
Synthesizing vector architecture diagram...
The fetcher is a pair of hands; the frontier node is the brain for its hosts. Every check that protects a site happens before the lease, and every signal the site sends back changes the next lease.
Trace 1: a news article recrawled within minutes
Synthesizing vector architecture diagram...
The feed, not the crawl order, finds the article. From discovery to handoff takes seconds; the 10-minute feed poll sets the worst case.
Trace 2: a struggling host slows us down
Synthesizing vector architecture diagram...
We cut our load by 75% within two windows and pause entirely on a 503, then climb back slowly. The owner never had to contact us.
Trace 3: an opt-out applied
- 10:00: the owner of
example.org, verified, setsai_training: denyin the console. The registry, written in the control region, goes to version 48,211. - 10:00–10:01: the global table replicates to all regions (typically within seconds); every frontier node picks up version 48,211 on its next 60-second refresh and reports it.
- From then on, every handoff event for
example.orgcarries"ai_training": falseandusage_registry_version: 48211. Search indexing is unchanged. - The usage-restrictions table now lists
example.orgfor AI training. The next training-data selection job, reading pages fetched last month, checks it at read time and excludes them. - An alarm would fire if any frontier node were still on an older version 5 minutes later.
R3.6 Numbers and Cost
What 20B fetches a month are (a plan built from the class budgets in step 3.3, with assumed outcomes)
| Item | Math | Result |
|---|---|---|
| Average rate | 20 × 10^9 ÷ 2,592,000 s | 7,716/s |
| Peak | 7,716 × 3 | ~23,000/s |
| First fetches of new URLs | from the class budget | 8B |
| Recrawls | 0.43B + 6.0B + 5.57B | 12B |
Recrawls answered 304 (assumed 50%) | 12B × 0.5 | 6B, a few hundred bytes each |
| Full bodies downloaded | 8B + 6B | 14B |
| Near-duplicates, not archived (assumed 15%) | 14B × 0.15 | 2.1B |
| Archived and handed off | 14B − 2.1B | 11.9B |
Fetches and bandwidth by region (bandwidth from full bodies: 14B × 200 KB ÷ 2,592,000 s = 1.08 GB/s = 8.64 Gbps in total)
| Region | Share of hosts | Fetches/s, avg / peak | Bandwidth, avg / peak | Fetch instances at peak (200 pages/s each) |
|---|---|---|---|---|
| us-east-1 | 50% | 3,858 / 11,574 | 4.3 / 13.0 Gbps | 58 |
| eu-west-1 | 30% | 2,315 / 6,944 | 2.6 / 7.8 Gbps | 35 |
| ap-southeast-1 | 20% | 1,543 / 4,630 | 1.7 / 5.2 Gbps | 24 |
| Total | 7,716 / 23,148 | 8.6 / 25.9 Gbps | 117 |
Each instance now handles 200 pages a second, up from Round 2's cautious 100: load tests showed 300 concurrent fetches fit comfortably, 300 ÷ 1.5 s = 200/s, using about half the CPU at our assumed ~20 ms per page. Fetches also got faster where hosts are close, but we keep 1.5 s for sizing. With host-affine leases, DNS lookups mostly hit the fetcher's cache; even with none, 200 × 2 = 400 packets a second per instance stays under the 1,024 limit.
The frontier on disk
| Item | Math | Result |
|---|---|---|
| Size | 200B URLs × ~100 B | 20 TB |
| Per region | 50 / 30 / 20% | 10 / 6 / 4 TB |
| Node | i4i.4xlarge: 3,750 GB NVMe, 128 GiB memory; filled to 50% | 1,875 GB of frontier each |
| Nodes | 10,000 ÷ 1,875 = 5.3; 6,000 ÷ 1,875 = 3.2; 4,000 ÷ 1,875 = 2.1 | 6 + 4 + 3 = 13 |
| Hosts per node | ~100M active ÷ 13 | ~7.7M; heads of ~10 URLs × 100 B ≈ 7.7 GB of memory |
| Recovery | a 1.5 TB snapshot from S3 at ~1 GB/s, then replay | ~25 minutes, plus replay |
Storage per tier (11.9B pages × 40 KB = 476 TB a month, 5.71 PB a year)
| Tier | Holds | At the end of year 1 | ≈ Monthly |
|---|---|---|---|
| S3 Standard | 30 days | 476 TB: first 50 TB × $0.023 + 426 TB × $0.022 | $10,500 |
| Glacier Instant Retrieval | days 30–120 | 1,428 TB × $0.004 | $5,700 |
| Glacier Deep Archive | after day 120 | 8 months × 476 TB = 3,808 TB × $0.00099 | $3,800 |
| Total | 5.71 PB | $20,000 |
Each further year adds 5.71 PB to Deep Archive, about $5,650 a month more. Objects are ~1 GB WARC files, so per-object charges (transition requests, Deep Archive's ~40 KB of overhead per object) are negligible.
Rough monthly cost (list prices; Spot and commitment discounts are assumptions to check)
| Line | Math | ≈ Monthly |
|---|---|---|
| Fetch fleets | Auto-scaled, ~50 instances on average (7,716 ÷ 200 × 1.3) × 730 h × ~$0.16 Spot (assumed) | $5,840 |
| Headless pools | 2% of fetches = 400M renders ≈ 154/s × 3 s × 0.5 vCPU ≈ 231 vCPUs → ~38 instances × 730 h × ~$0.16 Spot | $4,440 |
| Frontier nodes | 13 × i4i.4xlarge × $1.373/h × 730 h = $13,000 on-demand; ~35% off with a 1-year commitment | $8,500 |
| SimHash service | 30 days of 14B fingerprints × 4 tables × 12 B = 672 GB → 14 × r7g.2xlarge × $0.4284/h | $4,400 |
| Link graph and importance job | Weekly batch on Spot (estimate) | $5,000 |
| S3 archive and text | From the tier table | $20,000 |
| Outbound bytes | ~5 KB per fetch (requests, TLS, TCP acks; estimate) × 20B = 100 TB, tiered: 10 TB × $0.09 + 40 TB × $0.085 + 50 TB × $0.07 at US and EU rates; internet egress from Asia-Pacific costs more per GB, so this line is a floor | $7,800 |
| Cross-region handoff | Text for the 50% fetched outside the indexer's region, 5.95B × ~10 KB = 59.5 TB: from Europe 35.7 TB × $0.02, from Singapore 23.8 TB × ~$0.09 (Asia-Pacific inter-region rates are higher) | $2,850 |
| Kinesis | Change logs and handoff streams, 3 regions | $1,500 |
| DynamoDB | Host directory, registry, blocks, console data, as global tables | $1,000 |
| Elastic IPs | ~200 public addresses × $3.65 | $700 |
| CloudWatch and misc. | $3,000 | |
| Total | ≈ $65,000 = $3.25 per million fetches |
Synthesizing vector architecture diagram...
Storage is a third of the bill and grows every year; the frontier, which was Round 2's biggest line, is now a fixed fleet.
Round 2 cost $13.6 per million; Round 3 costs $3.25. The frontier moved from per-row writes to disks we own, the fleet scales with load instead of sitting at peak, each fetcher does twice the work, and the cheaper Glacier Instant Retrieval tier replaced Standard-IA for months 2–4.
R3.7 Trade-Offs
Freshness vs coverage
| Spend more on freshness | Spend more on coverage | |
|---|---|---|
| What we get | News and popular pages current within minutes to hours | More of the 200B URLs fetched at least once |
| What we lose | The tail goes stale; new sites are found later | Important pages lag their changes |
| Our split | 12B recrawls, 8B first fetches (step 3.3) | Moved by the value model, not by hand |
The value formula makes this a single dial: a new URL's value is its importance, a known URL's value is importance times the chance it changed. They compete in the same ranking, so the split falls out of the data.
Adaptive vs fixed politeness
| Adaptive AIMD (chosen) | Fixed 1 request/s | |
|---|---|---|
| Struggling server | We halve within minutes | We keep going until someone complains |
| Large server | Up to 10/s if healthy and allowed | Stuck at 1/s: a 10M-page site takes 116 days |
| Predictability | Throughput varies; harder to plan | Simple and easy to explain |
| Risk | A bug that raises rates is dangerous, so ceilings and a kill switch are mandatory | Low |
Build a crawler vs buy data (COST 11)
| Build (chosen, for freshness and control) | Use public crawls | |
|---|---|---|
| Freshness | Minutes for news | A new snapshot about monthly |
| Control | Our politeness, our opt-outs, our choice of pages | Someone else's crawl policy and selection |
| Cost | ~$65K a month plus a team | Mostly the cost of reading and processing |
| Best for | A live index | Breadth, research, bootstrapping a new index |
Closing the loop. Round 1's question was "how do we fetch politely?" and the answer was a queue with one lane per host. Round 2 asked "what's worth fetching?" and split priority from politeness. Round 3 asks "what's worth fetching with this budget, and are we welcome?", and the answers are the value model, adaptive politeness and webmaster controls. The one-lane-per-host idea from Round 1 never went away; it moved from SQS into the heap of a frontier node.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| A region outage | eu-west-1 stops reporting; its hosts get no fetches. REL 10 | The controller (active-passive across two regions, with a takeover target under 10 minutes so healthy regions never self-fence) marks the region down. After the 15-minute fencing wait (the region stops itself after 10 minutes without contact), it moves the region's hosts, with new epochs, to the other two. Their frontier nodes load the hosts' last hourly snapshots, replicated to other regions by S3 Replication. The failed region's change log is unreachable, so up to an hour of discoveries and results are lost: discoveries are found again, and some pages are fetched twice. Surviving fleets scale up, and the scheduler pauses the tail class so class 1 keeps its freshness. | – |
| A scheduler bug crawls a site too hard | A host's request rate jumps; its latency rises; its owner complains. | Kill switch per host: an operator adds it to the host-blocks table; every frontier node stops leasing it within ~60 s. The AIMD ceiling (10/s) bounds the damage even before that. A global "rate multiplier" (default 1.0) can cut every host's rate at once during a bad deploy. | – |
| An opt-out not propagated | A frontier node stuck on an old registry version keeps stamping ai_training: true. | Each node reports its applied version; an alarm fires at 5 minutes behind. The use-time check (the usage-restrictions table) is the second gate, so a stale stamp doesn't reach training data. Events stamped with an old version can be re-checked by version. | – |
| Archive corruption | A WARC file fails to decompress, or a record's digest doesn't match. | Each record carries WARC-Block-Digest; the writer re-reads and verifies every sealed file before its pages are marked archived and handed off. S3 stores a checksum per object and we verify it on read (--checksum-mode). A corrupt file's pages are marked for refetch. | – |
| A frontier node dies | Its ~7.7M hosts get no leases. | A replacement loads the node's snapshot (~25 minutes for 1.5 TB) and replays the change log. Its hosts pause meanwhile, which is safe for them. | – |
| A feed poller falls behind | News articles arrive late. | The poller's lag is a class-1 alarm; hubs are sharded across pollers, and a lagging shard is split. | – |
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active (our regions are active-active for fetching, but each host is active in exactly one)
R3.9 Runbook
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Pages fetched per second vs plan | < 70% for 30 min | P2 | Spot capacity? Frontier nodes healthy? Many hosts backing off at once? |
| Politeness violations (requests to a host above its current rate, from fetch logs) | > 0 | P1 | Block the host, find the frontier node and the code path |
| Per-host error rate, top hosts | a top-1,000 host above 5% 5xx/429 | P2 | Confirm AIMD is backing off; check for a scheduler change |
| DNS latency P99 | > 50 ms | P2 | Fetcher caches cold? Resolver limits? |
| Frontier depth (due URLs not leased), per class | class 1 growing for 30 min | P2 | Add fetchers; confirm leases aren't stuck |
| Duplicate ratio (near-duplicates ÷ full bodies) | outside 10–20% | P3 | A normalization bug or a new trap pattern |
| Spot interruptions | > 30% of a region's fleet in 1 h | P2 | Add instance types or AZs |
| Freshness lag: news, time from feed to handoff | P95 > 15 min | P1 | Poller lag? Frontier node for the host? |
| Registry version lag on any frontier node | > 5 min | P1 | Restart the node's refresher; hold its handoffs |
Procedure: emergency block of one host (a webmaster reports overload, or a violation alarm fires)
- Add the host to the
crawler-host-blockstable in the control region, with an expiry and a reason (command below). Every frontier node stops leasing it within about 60 seconds; leases already out finish within their 60-second deadline. - Confirm in the fetch logs that requests to the host stop within 2 minutes.
- Reply to the webmaster with what we did and when we'll resume.
- Find the cause: AIMD state, the host's recent ceilings, recent deploys. Fix it before the block expires, or extend it.
Commands (AWS CLI; check names and regions before running)
textaws dynamodb put-item --region us-east-1 --table-name crawler-host-blocks --item '{"host":{"S":"shop.example.com"},"blocked_until":{"N":"1790640000"},"reason":{"S":"webmaster overload report 2026-09-27"}}' aws dynamodb get-item --region eu-west-1 --table-name crawler-host-blocks --key '{"host":{"S":"shop.example.com"}}' aws autoscaling set-desired-capacity --region eu-west-1 --auto-scaling-group-name crawler-fetch-euw1 --desired-capacity 0 aws ec2 describe-spot-price-history --region us-east-1 --instance-types c6i.2xlarge c6a.2xlarge c7i.2xlarge --product-descriptions "Linux/UNIX" --start-time 2026-09-26T00:00:00Z aws s3api head-object --bucket crawl-archive-use1 --key 2026/09/27/use1-f031-000877.warc.gz --checksum-mode ENABLED
The second command reads the block from another region's replica, to confirm it has replicated. The third is the regional kill switch: it stops all fetching in one region. The last shows the object's stored checksum when the file was uploaded with one.
Incident flow OPS 10
Synthesizing vector architecture diagram...
The first question is always the blast radius. Every branch has a prepared first action that makes things safer before we understand the cause.
Game days. Monthly: kill a frontier node and time its recovery; block a test host and time the stop. Quarterly: fence a whole region in a staging copy and move its hosts; restore a sample of Deep Archive objects end to end. REL 12 · OPS 11
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | One owner per host with epochs and self-fencing; region failover by moving hosts; frontier recovery from snapshots and an idempotent change log; kill switches; game days. REL 9 · REL 10 · REL 12 · REL 13 |
| Performance Efficiency | Fetchers near the hosts; host-affine leases for warm DNS and connections; a frontier that reads a host's next URLs in one range read; 200 pages/s per instance after load tests. PERF 3 · PERF 4 · PERF 5 |
| Security | A verifiable crawler identity (published IPs, reverse DNS); webmaster verification by DNS record; opt-outs enforced before fetch and before use, with an audit trail by registry version. SEC 2 · SEC 4 · SEC 7 |
| Cost Optimization | Cost per million pages as the team's number ($3.25, from $13.6); a frontier on owned disks instead of per-row writes; S3 tiers matched to reads; text, not WARC, crosses regions; build versus buy. COST 1 · COST 8 · COST 11 |
| Operational Excellence | Webmaster complaints turned into a console and a policy; alarms with first actions; incident flow by blast radius; COEs. OPS 1 · OPS 8 · OPS 10 · OPS 11 |
| Sustainability | Value-based scheduling and 304s avoid fetches that buy nothing; near-duplicates aren't stored; fetching from the nearest region; old archives on the lowest-energy tier. SUS 1 · SUS 2 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Turns "freshness" into a budget problem with a value per fetch, and knows why "recrawl in proportion to change rate" is a trap.
- Moves the frontier out of managed queues when the numbers say so, and keeps it recoverable with snapshots and an idempotent log.
- Guarantees one owner per host across regions, including during a region failure, and knows why global tables need a single writer here.
- Makes politeness a feedback loop with ceilings and kill switches.
- Treats webmasters as customers: verifiable identity, controls, per-purpose opt-outs enforced at use as well as fetch.
- Knows the unit cost, what drives it, and when buying data beats crawling.
Follow-up questions
-
"A site's owner says we sent 50 requests a second yesterday. Our logs say 2. What now?" Answer: first, check whether the traffic was ours: their logs' source IPs against our published list, and reverse DNS. Crawler user agents are often faked. If it's not our IPs, we tell them how to verify and block the impostor. If it is ours, we block the host at once and treat it as a P1 politeness violation: find which frontier node owned it, whether two regions had it during a move (epochs and fencing should prevent this), and whether the headless pool's sub-requests were counted.
-
"Why not let every region fetch every host, and coordinate politeness through a global store?" Answer: every fetch would need a cross-region round trip to the store, 100 ms or more, and during a network split the regions would have to choose between stopping and risking double traffic. One owner per host keeps politeness a local decision; the only cross-region work is moving ownership, which is rare and fenced.
-
"The budget is cut by half. What do you drop?" Answer: the value model decides: we raise the cut-off, and the lowest-value fetches go first, which are mostly tail recrawls and low-importance new URLs. News feeds and the head keep their budgets. We'd also look at the biggest line: storage. Archiving only the head and news (and keeping fingerprints and text for the rest) would cut storage growth far more than any compute change.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Recrawl each page in proportion to how often it changes" | Pages that change faster than we can revisit waste the budget; spend where a fetch buys freshness. |
| "Every region can fetch every host" | Politeness needs one owner per host. |
| "Write host placement from any region" | Global tables resolve conflicts by last writer wins; a block or a move could silently vanish. |
| "robots.txt covers AI opt-outs" | It controls fetching. Use needs its own signal and a check at use time. |
| "Our user agent string proves it's us" | Anyone can send it. Published IPs and reverse DNS do. |
| "Send every fetch to the indexer" | Most are unchanged or duplicates; hand off only new and changed pages. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Design steps 1.0–1.5 | Design steps 2.1–2.7 | Design steps 3.1–3.6 |
| 40–50 min | Numbers + trade-offs | Numbers (verify the page size), cost, trade-offs | Fetch mix, regions, cost per million, trade-offs |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask a few scoping questions."
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What happens when a worker dies mid-fetch?" (REL 11) | Its message reappears after the visibility timeout, and rows not yet marked as archived come due again, so the URL is fetched again, never lost. | 1–2 | Steps 1.1, 1.5, 2.5 |
| "How do you survive Spot?" (REL 5) | Idempotent work by URL, a checkpoint on the two-minute notice, and leases that don't depend on the notice arriving. | 2 | Step 2.5 | |
| "What if a region goes down?" (REL 10, REL 13) | Its hosts move to other regions after a fencing wait, from hourly snapshots; we lose at most an hour of discoveries. | 3 | R3.8 | |
| "How do you recover lost state?" (REL 9) | The Bloom filter rebuilds from the table; frontier nodes rebuild from snapshots and an idempotent change log. | 2–3 | Steps 2.1, 3.2 | |
| Performance | "How do you check billions of URLs fast?" (PERF 3) | A 6.9 GB Bloom filter in memory, split into 64 parts, answers "probably seen" without a database call. | 2 | Step 2.1 |
| "Why fetch from several regions?" (PERF 4) | A fetch is several round trips; fetching near the host makes each fetch faster and needs fewer open connections. | 3 | Step 3.1 | |
| Security | "Can your crawler be turned against you?" (SEC 5, SEC 6) | Every resolved IP is checked and pinned, redirects re-checked, IMDSv2 only, and parsing isolated with hard limits. | 1–2 | R2.8, step 2.7 |
| "How can a site tell it's really you?" (SEC 2) | Published IP ranges and reverse DNS; a user agent alone proves nothing. | 3 | Step 3.5 | |
| Cost | "Why Spot?" (COST 7) | Fetching is retryable, so interruptions cost a few duplicate fetches; roughly half off the fleet at current prices. | 2 | Step 2.5, R2.6 |
| "Where does transfer bite?" (COST 8) | A NAT gateway on the fetch path would charge for every downloaded byte; cross-region we ship text, not archives. | 1, 3 | R1.7, step 3.6 | |
| "What does a page cost you?" (COST 1) | About $13.6 per million in Round 2 and $3.25 in Round 3, where storage is about a third of the bill. | 2–3 | R2.6, R3.6 | |
| "Should we build this at all?" (COST 11) | Build for freshness and control; public crawls can cover breadth. | 3 | Step 3.6 | |
| Operations | "How do you know you're being polite?" (OPS 8) | Politeness violations are an alarm at zero, measured from our own fetch logs, with a per-host kill switch. | 2–3 | R2.10, R3.9 |
| "How do you handle a webmaster complaint?" (OPS 1) | Verify it's our traffic, block the host within a minute, then fix the cause; the console lets owners set our rate themselves. | 3 | Steps 3.4, 3.5, R3.9 | |
| Sustainability | "How do you avoid wasted work?" (SUS 2, SUS 4) | Conditional requests, adaptive and value-based recrawls, no storage for duplicates, and old archives on the coldest tier. | 2–3 | Steps 2.6, 3.3, 3.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Frontier | A durable queue; seen set with conditional writes. | Mercator two tiers; the table as the frontier with leases; knows SQS limits. | A disk-backed, host-partitioned frontier with a heap of hosts, snapshots and an idempotent log. |
| Politeness | One lane per host, one second apart; robots.txt per RFC 9309. | Plus a cap per registered domain; per-host backoff on 429/503. | Adaptive AIMD with webmaster ceilings; one owner per host worldwide; kill switches. |
| Dedup and freshness | URL normalization. | Bloom filter sized and maintained; SimHash ≤ 3 bits; adaptive recrawl with conditional GET. | Value = importance × chance of change ÷ cost; feeds for news; freshness per class. |
| Safety | Timeouts, size caps, no inbound access. | Traps, bombs, SSRF and rebinding with specific limits. | Verifiable identity; opt-outs per purpose enforced at fetch and at use. |
| Cost | Finds the NAT gateway line. | Verifies the page size; finds the frontier table as the biggest line; Spot. | Cost per million pages; storage tiers; build versus buy. |
| Evolving under new scope | Builds from one script, one problem at a time. | Opens with "what breaks" and checks the inherited numbers. | Changes the operating model: regions, budgets, and the people on the other end of every request. |