Design a Distributed Web Crawler: From Script to Production
How do you design a web crawler that scales to millions of pages a day without harming the sites it reads? This page builds one reference design for the fictional Atlas Discovery: a one-process breadth-first script, then URL identity and scope limits, per-origin politeness, robots rules, a durable leased frontier with fencing tokens, safe fetching, URL and content deduplication, conditional recrawling, an optional JavaScript render lane, an outbox to the indexer and region ownership. Each component appears only after the failure that demands it. A 44-scene interactive Visual Design Journey is embedded below at the visual-journey anchor, and chapters cite its scenes by number.
Problem and Boundary: Seeds Without Fresh Pages
A web crawler discovers public pages, decides whether and when each one may be fetched, downloads it politely and safely, extracts text and links, removes duplicates, stores the result and schedules a later revisit. This page builds that system from a ten-line script to a multi-region crawler, and each new component appears only after the failure that demands it.
The running example is fictional. Atlas Discovery builds a topic-search feed and a change-monitoring service from public article and documentation pages. Its first crawler gathers pages from three invented publishers, news-a.example.test, docs-b.example.test and store-c.example.test, starting from user-provided seed URLs (a seed is an address you start from). Every rate, size and time on this page is a teaching assumption, not a benchmark, and none describes a real site or product.
One page is followed through the whole story: U-104, an article at news-a.example.test/articles/chip-supply. It enters through seed S-01, is normalized, approved by robots rules, scheduled for a host slot, leased to worker W-2, fetched, parsed, deduplicated, stored, mined for links and later re-checked with a conditional request that returns 304. An interactive 44-scene Visual Design Journey, embedded below at the visual-journey anchor, shows the same page moving through each stage, and chapters cite scenes by number.
What a crawler is, and what it is not
| Stage | Question it answers | In this design? |
|---|---|---|
| Crawl | Which addresses exist, and may we fetch them now? | Yes — the core of this page |
| Render | What does the page look like after its scripts run? | Optional lane, chapter 14 |
| Extract | What text, links and metadata are in the page? | Yes |
| Index | Which documents match a query term? | Interface only: we hand over documents |
| Rank | Which result should come first? | No — a different system |
The concrete miss is simple. A major article appears on news-a on Tuesday. Atlas's feed does not show it until Friday, because a person has to remember to run the script. The obvious response, running the script from many machines at once, makes the second problem visible: the publisher's small server answers with 429 Too Many Requests, and a well-run site may block the offending address range. A crawler therefore has two jobs that pull against each other: find and refresh pages quickly, and never be a nuisance to the sites it reads.
Visual Design Journey
Follow one article, U-104, from a one-process breadth-first script to a polite, durable, region-aware crawler — URL identity, robots rules, per-origin budgets, leases and fencing, safe fetching, deduplication, conditional recrawl and a sandboxed render lane — for the fictional Atlas Discovery.
The Vocabulary, Through One Page
Instead of a glossary, here is every term this page needs, defined at the moment U-104 meets it. If you arrived from a search result and skipped other lessons, nothing here depends on them.
| Term | Meaning | Where U-104 meets it |
|---|---|---|
| URL | A full address: scheme, host, optional port, path, query, fragment | https://news-a.example.test/articles/chip-supply?utm_source=x#top |
| Host and origin | The host is the machine name; an origin is scheme + host + port, the unit this design uses for politeness | https://news-a.example.test:443 |
| Seed | A starting URL supplied by a person or partner feed | S-01 = https://news-a.example.test/ |
| HTTP request/response | The GET message and the status code, headers and body returned | GET → 200, about 120,000 bytes compressed |
| DNS | The lookup that turns a hostname into an IP address | Resolved before connecting; validated for safety (ch. 11) |
| robots.txt | A file at the root of an origin that tells crawlers which paths to avoid | Fetched before any page on news-a |
| Link and frontier | A link is an address inside a page; the frontier is the set of discovered addresses waiting to be fetched | U-104 is a link found on S-01 and waits in the frontier |
| Normalization | Rewriting different spellings of an address to one identity | :80, #top, utm_source removed |
| Politeness | Limiting how often and how concurrently one origin is contacted | One request every 5 seconds for news-a |
| Lease | A time-bounded claim by a worker on a piece of work | W-2 holds U-104 for 60 seconds |
| Dedupe | Detecting repeated URLs or repeated content | U-104 reached by two spellings; a print page with identical text |
| Recrawl and validator | Fetching again later; an ETag or Last-Modified value lets the server say 'unchanged' | If-None-Match → 304 |
Two more ideas are used later and are defined where they appear: fencing tokens (chapter 9), a number that lets storage reject a slow worker's stale write, and eventual consistency (chapter 16), the idea that regions agree after a delay rather than instantly.
Requirements and Non-Goals
Requirements come before boxes. For a crawler, the non-functional list carries more of the design than the functional one.
| Area | Requirement |
|---|---|
| Functional | Accept seeds and partner sitemaps; discover links; fetch HTTP(S) pages; extract text, metadata and links; deduplicate; store documents; schedule recrawls; hand index-ready documents downstream |
| Safety and ethics | Honest user-agent; honor robots exclusions, Retry-After and observed overload; public pages only; block private and metadata networks; process deletion and opt-out requests |
| Fairness | No origin is overloaded; no origin is starved; large publishers cannot consume the whole budget |
| Freshness | Recrawl by change likelihood and importance, within a budget, with measurable staleness |
| Durability | A crash never forgets queued work; every URL has an explicit state |
| Cost | Per-origin and global budgets for requests, bytes and render time |
| Observability | Every skip, block, deferral and failure has a reason code; stages (fetched, parsed, stored, index-ready) are visible separately |
| Non-goals | Ranking, query serving, login-protected content, CAPTCHA or block evasion, harvesting personal data |
Capacity Estimates: Success Is Not Attempt
Atlas wants ten million successful page fetches per day. The arithmetic below separates quantities that people routinely confuse: discovered, eligible, attempted, successfully fetched, parsed, stored and index-ready URLs.
| Quantity | Calculation (assumed inputs) | Result |
|---|---|---|
| Average successes | 10,000,000 ÷ 86,400 s | 115.74 pages/s |
| 8× burst successes | 115.74 × 8 | 925.93 pages/s |
| Average attempts at 80% success | 115.74 ÷ 0.80 | 144.68 attempts/s |
| Burst attempts | 925.93 ÷ 0.80 | 1,157.41 attempts/s (redirect hops extra) |
| Response bodies per day | 10 M × 120,000 B | 1.2 TB/day |
| Burst body ingress | 925.93 × 120,000 B | 111.11 MB/s |
| Extracted payload | 10 M × 24,000 B | 240 GB/day |
| Body retention, 5 days | 1.2 TB × 5 | ≈ 6 TB before replication |
| Document retention, 30 days | 240 GB × 30 | ≈ 7.2 TB before replication and indexes |
| Concurrency (Little's Law) | 144.68 attempts/s × 0.8 s mean | ≈ 116 requests in flight |
Discovery outruns fetching
Each successfully parsed page yields about 18 links, and 15 percent of them survive filtering and the exact already-known check: 10 M × 18 × 0.15 = 27 million accepted link discoveries per day. Separately, partner feeds and sitemaps submit up to 25 million candidate URLs per day. Whether those two inputs overlap is unknown, so show both cases. If the partner candidates are mostly the same pages the crawler discovers anyway, inflow is about 27 million per day and the backlog grows by 17 million per day. If they are independent, inflow is 52 million and the backlog grows by 42 million per day.
In either case ten million fetches cannot drain a sustained input of novel eligible URLs. A priority, admission and retention policy is not optional. It is part of the design, and it is the reason a crawler has a scheduler instead of a plain queue.
Version 0: One Machine, Breadth-First
Start with the simplest thing that works, because it defines the vocabulary and shows exactly where it breaks. Version 0 is a breadth-first search over links: a queue of URLs to visit, a set of URLs already seen, an HTTP GET, an HTML parser and an output file.
queue = [seed] # S-01
seen = {seed}
while queue:
url = queue.pop(0) # oldest first: breadth-first
resp = http_get(url, timeout=10)
if resp.status != 200: continue
doc, links = parse(resp.body, base=url)
save(url, resp.status, resp.headers, doc)
for link in links:
if scheme(link) in ("http", "https") and link not in seen:
seen.add(link)
queue.append(link)Run against a few hundred pages of news-a, this works. The worker fetches S-01 and receives a 200 with about 120,000 bytes of compressed HTML (scene 5). It resolves each relative link against the page address, drops the fragment after #, keeps only http and https, and enqueues about eighteen new addresses, including U-104 (scene 6). Two small details matter later. First, the crawler stores the status, headers and a body hash separately from the raw bytes, because a few hundred bytes of metadata drive future decisions while 120,000 bytes need their own retention rule. Second, it records where each link was discovered, which becomes provenance in chapter 12.
What this version quietly assumes
- One process, so a crash loses the queue and the seen-set.
- One publisher at a time, so nothing limits the request rate.
- Perfectly clean URLs, so string equality means page equality.
- Friendly servers, so nothing validates where a connection goes or how large a reply is.
- Pages never change, so nothing is ever fetched twice on purpose.
The remaining chapters take these assumptions one at a time, in the order real operators meet them.
Duplicates and Traps: Identity Is Harder Than It Looks
Within an hour of crawling a real site the queue contains the same article four times and a calendar that never ends. The visited set compares strings, so every spelling of an address counts as a new page.
| Variant found | Same page? | Rule |
|---|---|---|
HTTP://News-A.example.test:80/a | Yes | Lowercase scheme and host; drop the default port |
/a#comments | Yes | Drop the fragment: it is never sent to the server |
/a?utm_source=x | Usually | Remove only parameters on a maintained per-origin tracking list |
/a/../a | Yes | Resolve dot segments |
/Docs/Setup vs /docs/setup | Maybe not | Paths are case-sensitive on many servers; do not lowercase |
/article?id=7 vs ?id=8 | No | Query values can be the identity; do not drop unknown parameters |
The conservative rule is: merge only what is provably the same, keep the discovered spelling for audit, and treat the publisher's rel=canonical link as a hint rather than proof. A page can claim any canonical address; only content comparison (chapter 12) can show that two pages really match.
Traps need budgets, not perfect detectors
A calendar widget links every day to the next, so /calendar?day=1, ?day=2, ?day=3 are genuinely different addresses with no end. Faceted navigation (color × size × price filters) produces combinatorial URL counts. Soft 404 pages answer 200 for missing content, and pagination can run to page nine thousand. No rule catches every trap, so the design caps them: allowed hosts, a depth limit, a per-origin URL budget, a cap on distinct values for a repeating query key and a pagination limit. Rejections are recorded with a reason code so an operator can tell that the calendar was cut at thirty days and judge whether that was too strict.
Scenes 7 to 9 show the queue swelling to 40,000 entries for about 300 real pages, then shrinking to 312 admitted URLs once normalization and the scope guard run.
Politeness: A Property of the Origin, Not the Worker
Adding workers makes the crawler faster and makes it dangerous. Five workers each sleeping five seconds between requests still send five requests at time zero when their sleeps happen to align, and the publisher answers 429 (scene 10).
The fix is to make the origin a first-class object. In this design an origin is scheme://host:port. That choice is deliberate: a host name, an origin, a registrable domain (like example.test shared by many subdomains) and an IP address are four different groupings, and publishers can be sensitive at any of them. The reference design groups by origin and lets an operator add stricter groups, for example one shared budget for all subdomains of one organization, when a publisher asks for it.
Each origin gets a bucket that stores next_eligible_at, max_in_flight, the current in_flight count and a backoff_until time. A URL may be dispatched only if its bucket is eligible. The check and the clock advance must be one atomic step: when W-2 and W-3 both ask at t=0 for news-a, one receives the slot for 0 to 0.8 seconds and next_eligible_at becomes 5 seconds. The other does not sleep and retry on its own. It receives nothing for that origin until the bucket says so. Note that W-2 finishing at 0.8 seconds does not make U-105 eligible earlier, because the limit is the gap between request starts, not the duration of a request (scene 12).
Feedback: 429 and Retry-After
Fixed spacing is a floor, not a promise of safety. When news-a answers 429 with Retry-After: 60, the bucket records backoff_until = now + 60 s so every URL for that origin waits, whichever worker would have taken it. With no header, the delay doubles per failure up to a cap and is randomized (jitter) so thousands of URLs do not return in the same second. Retries are capped per URL: 404 is usually final, 503 and timeouts retry a few times, and exhausted URLs go to a terminal state with the last error recorded (scene 23).
Robots Rules Before Any Page
Before any page of an origin is fetched, the crawler reads that origin's robots.txt, announces its user-agent name and applies the rules that match it. Atlas follows the Robots Exclusion Protocol as standardized in RFC 9309.
| Question | Rule used in the reference design |
|---|---|
| Which rules apply? | The group whose user-agent line most specifically matches the crawler's name, else the * group |
| Which rule wins? | The longest matching path pattern; if an Allow and a Disallow tie, Allow wins |
| Case and encoding | Path matching is case-sensitive; percent-encoding is normalized before comparing |
| Redirects when fetching robots.txt | Followed up to a small limit, within the same checks as any fetch |
| 2xx | Parse and cache, typically up to 24 hours, shorter if the origin says so |
| 4xx (such as 404) | The file is 'unavailable': no restrictions were published, so crawl under normal limits |
| 5xx or network failure | 'Unreachable': treat as disallowed for now, use the last good copy for a bounded time, back the origin off |
Crawl-delay | Nonstandard. Some crawlers honor it; this design sets host rate and concurrency itself and may use the value as one input |
In the story, store-c disallows /account/ and /cart/. U-107 under /account/orders is blocked before any connection opens (scene 14), while U-108 at /products/lamp proceeds to its bucket. The URL is marked BLOCKED with the policy version that blocked it. That version matters because rules change: a URL admitted under policy version 3 must be rechecked against version 4 just before it is fetched, and again after every redirect.
A Durable Frontier, Leases and Fencing
The version 0 process dies at three in the morning and takes eleven thousand queued addresses, the seen-set and every politeness clock with it (scene 16). Restarting from the seeds repeats a day of work and bursts every publisher at once. State that decides what happens next must live outside the process.
The frontier becomes a durable table. One row per URL holds normalized_url, origin_key, state, priority, next_fetch_at, attempt_count and lease fields. A unique key on the normalized URL makes first-seen detection a database decision instead of a thread race. Origin buckets are derived from the same table, so a restart means resume.
A plain message broker could deliver URLs, but a crawler needs time-ordered lookup by origin, priority, deduplicate-on-insert and retry counts. Those are table features. A broker still has a role, for example carrying index-ready events (chapter 15), but the authoritative record of what must be fetched is the table.
Claim with a lease
Workers claim work instead of reading it. In one short transaction the frontier picks an eligible origin that is under its caps, picks its best URL, sets the state to LEASED, writes a lease id, increments a fence token and sets an expiry. W-2 claims U-104 with fence 7 and a 60-second lease (scene 18). The fetch itself happens outside the transaction, because no database transaction can span an HTTP request.
Now the failure. W-2 fetches U-104 successfully and crashes before committing. After 60 seconds the sweeper returns the row to ELIGIBLE with the attempt count kept, and W-3 fetches the page again. The publisher saw two requests. That is the honest guarantee: at-least-once fetch attempts, never exactly-once network I/O. The design makes the stored result safe to write twice rather than promising that it will be fetched once (scene 19).
A harder failure: W-2 was not dead, only slow. Its lease expired, W-3 leased the URL with fence 8 and committed, and then W-2 wakes up and tries to commit with fence 7. The store compares the fence on the row with the writer's token and rejects the stale write (scene 20). Time alone cannot decide who owns the work because pauses and clocks lie; a token that only grows can.
Many Workers: Scheduling, Fairness and Partitioning
With a durable frontier, fetch workers become stateless and horizontally scalable. The scheduler is the interesting part.
A single global queue ordered by priority sounds right and is wrong. If news-a holds four million waiting URLs it fills the head of the queue, and workers spend their time waiting for news-a's five-second clock while docs-b and store-c wait behind them (head-of-line blocking). The scheduler therefore chooses an origin first, then that origin's best URL: origins sit in a min-heap keyed by next_eligible_at, the scheduler pops the earliest eligible origin that is under its concurrency cap, and takes the highest-priority URL from that origin's bucket.
Fairness needs three mechanisms. Aging raises the priority of an origin as it waits. Class quotas stop big publishers from consuming the entire fetch budget (for example, no class takes more than a quarter of attempts while others have eligible work). A starvation cap guarantees every origin with eligible URLs is served within a bounded time (scene 21).
Scaling out is then simple but bounded. Throughput is roughly the smaller of global concurrency (about 116 in flight on average here) and the sum of per-origin rate caps over origins that have eligible work (scene 32). Replicas help only while many different origins are eligible at once. They never break a host cap, because the cap lives in the bucket rather than in the worker. Partition the frontier by origin, not by URL: the origin bucket is the coordination boundary, and sharding by URL would scatter one origin's budget across partitions.
Fetching Safely
The fetcher is where the crawler touches the hostile, messy internet. Treat every response and every address as untrusted.
Redirects
U-104 answers 301 to a different host. An HTTP client that follows redirects silently skips every policy check. Atlas treats the target as a fresh candidate: normalize, check scope, read the target origin's robots rules, take a slot in the target origin's bucket and validate its network address. Hops are bounded (five here) and a repeated normalized URL ends the chain. Both the original and final URL are stored (scene 22).
Where a connection may go (SSRF)
A public page can link to a hostname that resolves to an address inside Atlas's own network, where internal services and cloud metadata endpoints live. If the crawler connects, a stranger has made it fetch internal data. This is server-side request forgery. The defense is at connect time: allow only http and https; resolve the name; check every returned address against private, loopback, link-local, multicast and metadata ranges for both IPv4 and IPv6; connect to the validated address and keep it pinned for the connection, so a name server that answers differently the second time (DNS rebinding) cannot change the destination; verify TLS; and repeat all of it per redirect. Checking the hostname once before connecting is insufficient (scene 24).
Bounding responses
A few kilobytes of compressed data can expand to gigabytes, and a response labeled HTML can be a binary file. Limits apply to the streamed decompressed bytes, not only to Content-Length. The fetcher stops reading at 5 MB for a page, checks the declared type against an allowlist, sniffs the first bytes, and runs parsers with time and memory limits. Anything suspicious goes to a QUARANTINED state with the reason, and never reaches the parser (scene 25).
From bytes to a document
The parser detects the character set, reads the base address, extracts text, title, language and links, notes rel=nofollow according to policy, and records the canonical link as a hint. Fetch success, parse success, storage success and index readiness are recorded as separate stages, so a drop in indexed pages can be traced to the network, the parser or the pipeline after it (scene 26).
Two Kinds of Duplicate
'Have I seen this?' has three different answers depending on what is being compared and when.
URL dedupe happens before any network cost. Discovery does an insert-if-absent on the unique normalized URL, so exactly one of two simultaneous discoveries wins regardless of thread or machine. An in-memory visited set cannot do this: two threads can both see a URL as new, and the set vanishes on restart (scene 27). Content dedupe happens after extraction: hash the extracted text. When U-104 and its print version /print return identical text, the document is stored once and both URLs map to it, with the places each link was discovered kept as provenance. Dropping the second URL would lose that history (scene 28). Near-duplicate detection (SimHash or MinHash style fingerprints) estimates that two pages are, say, 96 percent alike. It can cluster them for the indexer, but a threshold can merge a corrected article with its outdated version, so clusters are labels, every member keeps its document, the threshold is recorded and a bad merge can be undone (scene 29).
Bloom filters at the front door
At very large scale, a Bloom filter (a compact bit structure that can answer 'definitely not seen' or 'maybe seen') can avoid most database lookups. It never gives a false 'no', but it can give a false 'maybe'. The rule is therefore one-way: a 'no' lets the URL be inserted without a lookup; a 'maybe' must be confirmed in the exact store before any URL is discarded.
Recrawling: Freshness Within a Budget
A crawl that fetches each page once is a snapshot. Six hours after Atlas fetched U-104 the publisher corrected the headline, and Atlas's index still shows the old one (scene 33). The gap between a page changing and Atlas noticing is its freshness gap, and it should be measured.
Recrawling is conditional. The row stores the ETag and Last-Modified validators from the last fetch. The recrawl sends If-None-Match with the ETag. If the server answers 304 Not Modified, a few hundred bytes cross the network instead of 120,000, nothing is reparsed or reindexed, and the row's last_checked_at is updated (scene 34). Two cautions. A 304 is still a request, so it spends an origin slot and politeness budget. And validators are hints: some servers send ETags that change with no content change, or ignore conditional headers. A new ETag with a 200 therefore triggers a parse and a content-hash comparison. If the hash differs, the page truly changed, a new version is stored and an outbox event is emitted (scene 35).
How often to come back is a scheduling problem. Atlas estimates each page's change interval from its history, scales by importance, clamps the result between a minimum and a maximum, and applies jitter so thousands of pages fetched together do not all fall due together (scene 36). A fixed share of the daily budget, 30 percent in this illustration, is reserved for discovering new URLs so that freshness work cannot consume the whole crawl.
When Raw HTML Is Not Enough
For news-a and docs-b the HTML contains the article. For store-c product pages the fetched HTML is a two-kilobyte shell and the product text is drawn by JavaScript after load (scene 37). A plain fetcher extracts zero words.
A browser would see the content, but rendering costs many times the processor and memory of a plain fetch and executes scripts from many third parties. Atlas therefore decides by measurement: for each site or page class, sample raw extraction against rendered extraction. Where raw HTML already holds the content, rendering adds only cost. Where it does not, those pages go to a separate render lane.
The lane is sandboxed, has its own queue, CPU, memory and time limits and its own metrics. If it saturates, the crawler falls back to HTML-only for those pages and records the fallback. Nothing about the rules relaxes: every request the browser makes, including images and scripts, passes through the same safe egress gate and counts against the same origin bucket. A render lane is not a side door around robots or SSRF protection (scene 38).
Storage and the Index Interface
A fetched, parsed page has to be stored and the indexer told. Four separate systems are involved: the HTTP origin, object storage, the metadata database and a message broker. No single transaction spans them, so the order of operations carries the correctness.
| Data class | Where it lives | Retention (illustrative) | Why separate |
|---|---|---|---|
| URL and state | Frontier / metadata DB | While the URL is known | Drives scheduling |
| Fetch metadata | Metadata DB | Rolling history per URL | Validators, status, change history |
| Raw body | Object storage, content-addressed | 5 days (≈ 6 TB) | Reprocessing and audit; large and costly |
| Parsed document | Metadata DB / document store | 30 days (≈ 7.2 TB) | What the indexer consumes |
| Discovered links | Frontier rows + provenance | With the document | Explains why a URL was fetched |
| Robots copies | Policy cache + history | Policy version history | Explains why a URL was blocked |
| Suppression and deletion records | Policy store | Until withdrawn, audited | Opt-out must outlive the data it removed |
The write order is: (1) put the body in object storage under a content-addressed key, which is safe to repeat; (2) in one database transaction mark the frontier row FETCHED, write the document metadata and write an outbox row that says 'index this document'; (3) a relay process reads the outbox and publishes to the indexer, resending after a crash; (4) the indexer deduplicates by event id. An event therefore exists only if the document is durably stored, and a stored document eventually produces an event. A crash after step 1 leaves a harmless orphan body that a sweeper removes; a crash after step 2 resumes from the outbox (scene 30).
Scale, Regions, Incidents and Observability
The remaining design is about operating the crawler at scale and across regions without losing its guarantees.
Backlog. Accepted discoveries exceed fetch capacity (scene 31), so admission decisions use priority, per-origin budgets and retention. Unfetched low-value URLs are dropped on purpose, and the drop is visible in metrics.
Regions. Atlas adds a European and a US region. Origin buckets are partitioned by origin and each bucket is owned by one region at a time through an ownership lease from a control plane. For a very busy origin, the rate budget can be split into pre-leased shares so regions need not coordinate on every request (scene 39). If a network partition leaves both regions believing they own news-a, the bucket accepts only the newest ownership token and rejects the older one (scene 40). Some duplicate fetches inside the old lease window are possible, so the guarantee is a bounded overshoot, not perfect global rate limiting across independent regional counters.
Outage. When the metadata database is down for two hours, workers stop taking new leases, a bounded spool holds finished results and expiring leases return. If 200 tasks per second keep arriving while only 50 complete, the backlog grows by (200 − 50) × 7,200 = 1,080,000 tasks. After recovery at 250 per second against 200 per second of inflow, draining takes 1,080,000 ÷ (250 − 200) = 21,600 seconds, six hours, assuming origin budgets allow that rate. If the backlog is concentrated in a few capped origins the true drain is slower (scene 41).
Observability. The crawler publishes per-origin request rate against its limit and the 429 share (politeness), share of fetches by origin class (fairness), queue age by priority and the oldest eligible URL (backlog), blocked-by-robots and blocked-by-scope shares, parse yield, 304 rate, changed-page yield and dedupe ratio, and freshness gap by priority class (scene 42). Every decision carries a reason code, so 'why did you crawl us at three in the morning' has an answer in the data.
Opt-out and deletion. A verified request goes to a suppression list that blocks future fetches, then frontier rows, parsed documents, raw bodies and index entries are deleted or tombstoned with each action audited.
The Final Architecture
Here is the mature crawler, with every component traced back to the failure that required it.
| Component | Added because | New complexity it introduced |
|---|---|---|
| Normalizer and scope guard | Spellings and traps inflated work (ch. 6) | Identity rules that can over-merge; budgets to tune |
| Origin buckets | Workers flooded publishers (ch. 7) | Shared state on the hot path |
| Robots cache | Publishers state what to avoid (ch. 8) | Policy versions, failure semantics |
| Durable frontier + leases + fences | A crash lost the queue (ch. 9) | At-least-once duplicates; sweeper |
| Origin-first scheduler | One FIFO starved small publishers (ch. 10) | Heap, aging, quotas |
| Safe egress and limits | SSRF and bombs (ch. 11) | Central choke point to operate |
| Two-layer dedupe | Repeated URLs and content (ch. 12) | Provenance model; threshold risk |
| Conditional recrawl and adaptive score | Staleness (ch. 13) | Validator quirks; budget split |
| Render lane | Shell pages (ch. 14) | Browser sandbox cost |
| Outbox | Four systems, no shared transaction (ch. 15) | Duplicate events; relay |
| Ownership leases | Many regions (ch. 16) | Bounded overshoot |
Four responsibilities summarize the design, and they are also the diagnostic questions when something breaks. Discover: turn links into a safe, deduplicated identity inside a scope with a budget. Decide: for each URL, whether it may be fetched, when, by whom and in what order. Fetch safely: connect only to validated addresses, bound every response, keep rendering optional and isolated. Refresh: come back when the page is likely to have changed, asking cheaply with validators, within a budget. A flood is a decision failure, a missing page is a discovery or scope failure, a private address fetched is a safety failure and an old article is a refresh failure (scene 44).
- 01A crawler is a scheduler first: it decides which URL, from which origin, at what pace, before it downloads anything.
- 02Politeness belongs to the origin, shared by every worker and region, and reacts to 429 and Retry-After for the whole origin.
- 03Identity is conservative: merge only what is provably the same; canonical links are hints.
- 04Robots is a protocol preference, not authorization; 404 and 5xx mean different things.
- 05Durable frontier rows, leases and fence tokens give at-least-once fetching with idempotent results, never exactly-once network I/O.
- 06Safe egress validates the address actually connected to, per redirect, and bounds the streamed response.
- 07URL dedupe, content hash and near-duplicate similarity prove different things.
- 08Recrawl is conditional and budgeted; a 304 saves bytes, not a request.
- 09Regions share budgets through fenced ownership leases; overshoot is bounded, not zero.