Skip to content
SeeItFlow
System Design · 17 chapters · ~23 min read

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.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

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

StageQuestion it answersIn this design?
CrawlWhich addresses exist, and may we fetch them now?Yes — the core of this page
RenderWhat does the page look like after its scripts run?Optional lane, chapter 14
ExtractWhat text, links and metadata are in the page?Yes
IndexWhich documents match a query term?Interface only: we hand over documents
RankWhich result should come first?No — a different system
The five words people blur together. Only crawl, extract and scheduling are designed here.
Swipe to pan
DG01 — The problem: seeds exist, fresh pages do notDG01 — The problem: seeds exist, fresh pages do notAtlas Discovery (fictional) and its three invented publishersToday: manual scriptSeed S-01pasted by handrunScript runs sometimesresultsFeedmisses Tuesday articleNaive fix: run it fasterMany parallel scripts5 GETs at t=0news-a server429 + angry operatorbanAtlas blockedWhat is missingDiscovery: new pages from links and sitemaps.Scheduling: when and how often to fetch.What must holdPolite, safe and explainable fetches.Crawl hands documents to an indexer; it does not rank.
The problem: a manual script misses a new article, and the naive fix, running more scripts in parallel, floods the publisher.

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.

Interactive

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.

Chapter 02

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.

TermMeaningWhere U-104 meets it
URLA full address: scheme, host, optional port, path, query, fragmenthttps://news-a.example.test/articles/chip-supply?utm_source=x#top
Host and originThe host is the machine name; an origin is scheme + host + port, the unit this design uses for politenesshttps://news-a.example.test:443
SeedA starting URL supplied by a person or partner feedS-01 = https://news-a.example.test/
HTTP request/responseThe GET message and the status code, headers and body returnedGET → 200, about 120,000 bytes compressed
DNSThe lookup that turns a hostname into an IP addressResolved before connecting; validated for safety (ch. 11)
robots.txtA file at the root of an origin that tells crawlers which paths to avoidFetched before any page on news-a
Link and frontierA link is an address inside a page; the frontier is the set of discovered addresses waiting to be fetchedU-104 is a link found on S-01 and waits in the frontier
NormalizationRewriting different spellings of an address to one identity:80, #top, utm_source removed
PolitenessLimiting how often and how concurrently one origin is contactedOne request every 5 seconds for news-a
LeaseA time-bounded claim by a worker on a piece of workW-2 holds U-104 for 60 seconds
DedupeDetecting repeated URLs or repeated contentU-104 reached by two spellings; a print page with identical text
Recrawl and validatorFetching again later; an ETag or Last-Modified value lets the server say 'unchanged'If-None-Match → 304
Eleven terms, each tied to the tracked page.

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.

Chapter 03

Requirements and Non-Goals

Requirements come before boxes. For a crawler, the non-functional list carries more of the design than the functional one.

AreaRequirement
FunctionalAccept 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 ethicsHonest user-agent; honor robots exclusions, Retry-After and observed overload; public pages only; block private and metadata networks; process deletion and opt-out requests
FairnessNo origin is overloaded; no origin is starved; large publishers cannot consume the whole budget
FreshnessRecrawl by change likelihood and importance, within a budget, with measurable staleness
DurabilityA crash never forgets queued work; every URL has an explicit state
CostPer-origin and global budgets for requests, bytes and render time
ObservabilityEvery skip, block, deferral and failure has a reason code; stages (fetched, parsed, stored, index-ready) are visible separately
Non-goalsRanking, query serving, login-protected content, CAPTCHA or block evasion, harvesting personal data
Requirements for the Atlas reference design. Scene 2 of the journey shows the same list.
Chapter 04

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.

QuantityCalculation (assumed inputs)Result
Average successes10,000,000 ÷ 86,400 s115.74 pages/s
8× burst successes115.74 × 8925.93 pages/s
Average attempts at 80% success115.74 ÷ 0.80144.68 attempts/s
Burst attempts925.93 ÷ 0.801,157.41 attempts/s (redirect hops extra)
Response bodies per day10 M × 120,000 B1.2 TB/day
Burst body ingress925.93 × 120,000 B111.11 MB/s
Extracted payload10 M × 24,000 B240 GB/day
Body retention, 5 days1.2 TB × 5≈ 6 TB before replication
Document retention, 30 days240 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
Little's Law says average concurrency equals arrival rate times mean service time. Redirects, TLS, headers and failed attempts add traffic that is not in these body figures.
Swipe to pan
DG25 — 10 M successes per day: attempts, bytes, concurrencyDG25 — 10 M successes per day: attempts, bytes, concurrencyAll values are assumptions with units10,000,000 / 86,400= 115.74 successes/s÷ 0.80 success= 144.68 attempts/s× 0.8 s mean≈ 116 in flight× 120,000 B= 1.2 TB/day8× burst925.93 /s · 111.11 MB/sExtracted 24,000 B= 240 GB/dayRetention (illustrative)5 days of bodies ≈ 6 TB; 30 days of documents ≈ 7.2 TB, beforereplication.Not equal115.74/s is successes; attempts are 144.68/s. Redirect hops addmore.
The capacity worksheet. Each value carries its unit and its assumption.

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.

Swipe to pan
DG23 — Queue growth when input exceeds fetch throughputDG23 — Queue growth when input exceeds fetch throughputTwo input cases against 10 M successful fetches per dayFetch capacity10 M / dayInflow, overlapping27 M / day → +17 M backlogInflow, independent52 M / day → +42 M backlogNeededPriority score, per-origin budgets, retention, and droppingstale low-value URLs.Say itNot fetching something is a decision, and it should be visible.
Backlog growth in the two input cases against 10 million successful fetches 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.

Chapter 05

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.

textVersion 0 in a dozen lines. Fetching, parsing and saving are separate calls so later chapters can replace them independently.
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)
Swipe to pan
DG02 — V0: one-machine breadth-first crawlerDG02 — V0: one-machine breadth-first crawlerA queue, a visited set, GET, parse, enqueueQueueS-01popvisited?in-memory setnewHTTP GET200 · 120 KBhtmlParse18 linksdocStoreoutput fileLoop: parsed links go back to the queueLinksresolve relativeU-104 …Appendif unseenWorks forA few hundred pages from one polite site.Breaks onURL variants, traps, many hosts, crashes, scale.
Version 0: queue, visited set, fetch, parse, store, and links returning to the queue.

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.

Chapter 06

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.

Swipe to pan
DG03 — URL variants and a conservative identityDG03 — URL variants and a conservative identityMerge only what is provably the same pageSafe to merge → one identityHTTP://News-A.example.test:80/articles/chip-supply#topnormalizescheme/host lower · :80 · # · utm_*https://news-a.example.test/articles/chip-supplyMust stay distinct/Docs/Setup/docs/setup?id=7?id=8NeverLowercase the path by default.Drop unknown query parameters.KeepThe discovered spelling for audit; canonical link is only ahint.
Safe merges collapse to one identity. Merges that would be wrong stay separate.
Variant foundSame page?Rule
HTTP://News-A.example.test:80/aYesLowercase scheme and host; drop the default port
/a#commentsYesDrop the fragment: it is never sent to the server
/a?utm_source=xUsuallyRemove only parameters on a maintained per-origin tracking list
/a/../aYesResolve dot segments
/Docs/Setup vs /docs/setupMaybe notPaths are case-sensitive on many servers; do not lowercase
/article?id=7 vs ?id=8NoQuery values can be the identity; do not drop unknown parameters
Normalization rules and the two cases where merging loses data.

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.

Swipe to pan
DG04 — Calendar and tracking explosion, bounded by scopeDG04 — Calendar and tracking explosion, bounded by scopeDistinct pages stay small; rejected variants are counted with a reasonNaive visited set40,000 entriesAfter normalization≈ 9,000 (calendar remains)After scope guard312 admittedScope rulesAllowed hosts, depth limit, per-pattern value cap, paginationcap.AuditEvery rejection stores a reason: calendar capped at 30 values.
Queue entries for the same site: unbounded, after normalization, and after the scope guard.

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.

Chapter 07

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.

Swipe to pan
DG05 — Three origins, three eligibility clocksDG05 — Three origins, three eligibility clocksRates are per origin, not per worker0 s1.252.53.755 snews-a (1/5 s)reqnext eligible t = 5 sdocs-b (1/s, ×2)reqreqreqreqreqstore-c (robots)/account/ and /cart/ never; rest by slower rateA 5 s gap is between startsFetch duration does not shorten the gap.
Three origins with independent eligibility clocks: news-a one request per 5 seconds, docs-b one per second with two in flight, store-c shaped by robots rules.

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).

Swipe to pan
DG06 — Two workers race for one host slotDG06 — Two workers race for one host slotBefore: each sleeps alone · After: one atomic claimW-2 (U-104)W-3 (U-105)news-a bucketnews-a serverclaim slot (t=0)claim slot (t=0)granted 0–0.8 snext_eligible_at := 5 sdenied until t ≥ 5 sGET U-104200 at 0.8 sclaim at t=5 s → grantedWithout the bucketBoth sleeps expire together: 2 requests at t=0.With the bucketThe check and the clock advance are one atomic step; a thirdworker in another region uses the same bucket.
Two workers racing for one slot. Without shared state both send; with an atomic bucket, one waits until t ≥ 5 seconds.

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).

Swipe to pan
DG19 — Origin rate, concurrency cap and Retry-AfterDG19 — Origin rate, concurrency cap and Retry-Afternews-a answers 429 at t=10 s with Retry-After: 600 s10 s40 s70 s80 snews-a requests429retryretrybucket stateeligible every 5 sbackoff: all news-a URLs waitjittered429Retry-After endsRuleBackoff belongs to the origin bucket: every worker and region obeys it.
A 429 at t=10 s with Retry-After 60: the whole origin cools, then jittered retries resume.
Chapter 08

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.

QuestionRule 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 encodingPath matching is case-sensitive; percent-encoding is normalized before comparing
Redirects when fetching robots.txtFollowed up to a small limit, within the same checks as any fetch
2xxParse 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-delayNonstandard. Some crawlers honor it; this design sets host rate and concurrency itself and may use the value as one input
Robots semantics for the Atlas-Bot user-agent.
Swipe to pan
DG07 — robots.txt: group choice and longest matchDG07 — robots.txt: group choice and longest matchWorked rule evaluation for store-c (RFC 9309 semantics)Rules in the Atlas-Bot groupDisallow /account/length 9Allow /account/helplength 14Disallow /cart/length 6Results/account/orderslongest: Disallow → BLOCK/account/help/faqlongest: Allow → ALLOW/products/lampno match → ALLOWGroup choiceMost specific user-agent group wins; fallback to *.TiesEqual length: Allow wins. Rules arecase-sensitive on the path.Not securityrobots.txt is a crawler convention, notaccess control.
Worked evaluation for store-c: /account/orders is blocked, /account/help/faq is allowed by the longer Allow rule, /products/lamp matches nothing.
Swipe to pan
DG08 — robots.txt fetch status and cache decisionDG08 — robots.txt fetch status and cache decision404 and 503 are different situationsGET /robots.txtstatus2xxparse, cache ≤ 24 h4xx (e.g. 404)unavailable → allow5xx / timeoutunreachable → pauseUnreachable handlingUse the last good copy for a bounded time, then back the originoff with growing delays.Record itStore status, time and policy version used for each decision.
Fetch status decides what the cache may assume. 404 and 503 are different situations.

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.

Swipe to pan
DG27 — robots rule changes while a URL is queuedDG27 — robots rule changes while a URL is queuedRecheck the cached policy version immediately before fetchingFrontierRobots cacheWorkerstore-cU-108 admitted (policy v3)robots.txt now Disallow /products/refresh → policy v4pre-fetch recheck U-108v4: BLOCKEDstate := BLOCKED, no connectionRulePolicy checks are not one-time admission; they recur at fetch time and at redirects.
A rule change while a URL is queued: the pre-fetch recheck blocks it with no connection.
Chapter 09

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.

Swipe to pan
DG09 — Durable frontier state machine with lease expiryDG09 — Durable frontier state machine with lease expiryEvery URL row is in exactly one stateDISCOVEREDscope + robots okELIGIBLEclaimLEASEDlease + fencecommitFETCHEDOther exitsFAILED_RETRYABLEbackoffBLOCKEDrobots / scopeTERMINAL404 · max attemptsLease expiryLEASED past lease_expires_at → ELIGIBLE, attempt_count kept.Why a tableTime-ordered lookup, priority, unique key dedupe and retrycounts.
Every URL is in exactly one state. LEASED rows past their expiry return to ELIGIBLE.

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.

Swipe to pan
DG10 — Atomic host and URL admission with fencingDG10 — Atomic host and URL admission with fencingOne short database transaction; no network call inside itWorker W-2Frontier DBOrigin bucketPublisherclaimEligible()origin eligible? in_flight < maxU-104 → LEASED, fence 7next_eligible_at += 5 slease_id, fence 7, expires +60 sGET (outside transaction)commit with fence 7rejected if fence ≠ storedInside the transactionCheck, lease and clock advance.OutsideHTTP, object storage and broker calls.
The claim transaction, and the commit that must present the fence.

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).

Swipe to pan
DG21 — Worker crash after the HTTP response, before commitDG21 — Worker crash after the HTTP response, before commitRecovery repeats the fetch; the stored result is idempotentW-2FrontierW-3news-alease U-104 (fence 7)GET200crash before commitlease expires → ELIGIBLElease (fence 8)GET (second request)commit fence 8Honest guaranteeAt-least-once fetch attempts. Never exactly-once network I/O.Idempotent outputCommit keyed by url_id + fence; replay does not duplicatedocuments.
Crash after the HTTP response and before commit: the work is not lost, and the result is idempotent.

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.

Swipe to pan
DG22 — Stale lease commit and fence rejectionDG22 — Stale lease commit and fence rejectionA slow worker is not a dead workerW-2 (fence 7)Frontier / storeW-3 (fence 8)lease (fence 7)pause / slow networklease expireslease (fence 8)commit fence 8 ✓commit fence 7 …REJECTED: current fence is 8Why tokensClocks and pauses lie; a monotonically increasing token does not.
A late worker's commit with fence 7 is rejected because the current fence is 8.
Chapter 10

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.

Swipe to pan
DG11 — Scheduler: origin heap, host buckets and workersDG11 — Scheduler: origin heap, host buckets and workersChoose an origin first, then its best URLOrigin min-heapkey: next_eligible_atpopPick originunder cap · agedURLHost bucketbest URL by priorityleaseWorker poolstateless fetchersHead-of-line blockingOne global FIFO lets a busy origin blockeveryone.FairnessAging raises priority of long-waitingorigins; class quotas cap big sites.PartitioningPartition by origin so a bucket has oneowner.
Origin heap, host buckets and a stateless worker pool.
Swipe to pan
DG24 — Per-origin fairness vs popular-domain starvationDG24 — Per-origin fairness vs popular-domain starvationShare of fetch slots over an hour (illustrative)Flat priority: news-a92% of attempts (mostly waiting)Flat priority: docs-b6%Fair: news-a≤ 25% quotaFair: docs-bby eligibility + agingMechanismsPer-origin ready buckets, aging, per-class quotas, starvation cap.
With a flat priority queue the busiest origin dominates attempts; per-origin buckets, aging and class quotas restore fairness.

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.

Chapter 11

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).

Swipe to pan
DG13 — Redirects re-run SSRF and robots checks per hopDG13 — Redirects re-run SSRF and robots checks per hopMax 5 hops; a repeated URL ends the chainWorkerPolicy gatenews-acdn-news-ahop 0: checks passGET U-104301 → cdn-news-ahop 1: scheme, IP, scope, robots, slotallowed (or blocked)GET target200Store bothOriginal and final URL, hop count and each decision.Bound it5 hops; loops detected by repeated normalized URL.
Every redirect hop repeats the checks.

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).

Swipe to pan
DG29 — SSRF gate and DNS rebinding defenseDG29 — SSRF gate and DNS rebinding defenseValidate the address you actually connect toWorkerEgress gateDNSTargetfetch evil-link.example.testresolve10.0.0.5BLOCKED: private rangepublic name: resolve203.0.113.9connect to pinned 203.0.113.9 (TLS verified)Block listPrivate, loopback, link-local, metadata, multicast; IPv4 andIPv6.InsufficientA hostname-only precheck: the second lookup can differ.
The egress gate validates the address it actually connects to.

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).

Chapter 12

Two Kinds of Duplicate

'Have I seen this?' has three different answers depending on what is being compared and when.

Swipe to pan
DG14 — URL dedupe vs content dedupe vs near-duplicate clusterDG14 — URL dedupe vs content dedupe vs near-duplicate clusterThree layers answer three different questionsURL known?unique key, pre-fetchsaves fetchesContent hash equal?post-extract, exactsaves storageSimilar?SimHash/MinHash, estimateProvesURL: same string identity. Hash: identicalbytes.Cannot proveHash: that URLs are interchangeable.Similarity: equality.KeepURL→document mapping and inlinkprovenance.
URL dedupe, content hash and near-duplicate similarity answer different questions.

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.

Swipe to pan
DG15 — Bloom filter false positive and exact verificationDG15 — Bloom filter false positive and exact verificationA 'maybe seen' answer is not a reason to discardDiscoveryBloom filterExact storeseen? U-210NO → definitely new, insertseen? U-211MAYBE (could be false positive)exact lookup U-211not found → insertSizingm ≈ −n·ln(p) / (ln 2)²: 1 billion URLs at p = 1% needs about 9.6billion bits ≈ 1.2 GB.RuleNever drop the only copy of a URL on a filter answer alone.
A 'maybe seen' answer is confirmed by an exact lookup; the only copy of a valuable URL is never dropped on a filter answer.
Chapter 13

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).

Swipe to pan
DG17 — ETag, conditional GET and 304 vs a changed 200DG17 — ETag, conditional GET and 304 vs a changed 200Validators are hints, not guaranteesRecrawl workernews-aDocument storeGET U-104 · If-None-Match: v1304 Not Modified (~300 B)last_checked := now, reuse bodylater: GET · If-None-Match: v1200 · ETag v2 · full bodyparse + hash differs → new versionStill a request304 saves bytes and parsing; it still uses an origin slot.Weak validatorsETag may change without content change: confirm by hash.
A 304 reuses the stored document; a 200 with a new ETag is parsed and hashed to confirm the change.

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.

Swipe to pan
DG18 — Adaptive recrawl: change rate, importance and budgetDG18 — Adaptive recrawl: change rate, importance and budgetIllustrative revisit intervals, bounded 10 min – 30 daysnews-a home pageevery ~20 minnews-a article U-104daily (after first week)docs-b guideevery ~3 daysstatic archive pageevery 30 days (max)Scoreinterval = clamp(est. change interval ÷ importance, min, max) ×jitter.Budget70% refresh / 30% discovery (assumed).
Illustrative revisit intervals by page behavior, bounded and budgeted.
Chapter 14

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.

Swipe to pan
DG16 — HTML-only extraction vs sandboxed JavaScript laneDG16 — HTML-only extraction vs sandboxed JavaScript laneRender only where measured need existsDefault pathFetch HTML~0.8 s, low CPUParse textDocumentBudgeted lane (store-c products only)Render queueown budgetSandboxed browserCPU/mem/time capsSafe egresssame rulesRendered DOM → parseFallbackIf the lane saturates, fall back to HTML-only and record it.
The default HTML path and the isolated, budgeted 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).

Chapter 15

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.

Swipe to pan
DG20 — Raw store, metadata DB and outbox: where atomicity endsDG20 — Raw store, metadata DB and outbox: where atomicity endsNo single ACID transaction spans HTTP, object storage and the broker1 Object storeput body (idempotent)body key2 One DB transactionstate + doc + outboxoutbox3 Relaypublish, may repeatevent4 Indexerdedupes by event idCrash after 1Orphan body is harmless; a sweepercollects unreferenced bodies.Crash after 2Relay resumes from the outbox; no event islost.NeverPublish before the transaction commits.
Where atomicity ends: object store, one database transaction, outbox relay, idempotent consumer.
Data classWhere it livesRetention (illustrative)Why separate
URL and stateFrontier / metadata DBWhile the URL is knownDrives scheduling
Fetch metadataMetadata DBRolling history per URLValidators, status, change history
Raw bodyObject storage, content-addressed5 days (≈ 6 TB)Reprocessing and audit; large and costly
Parsed documentMetadata DB / document store30 days (≈ 7.2 TB)What the indexer consumes
Discovered linksFrontier rows + provenanceWith the documentExplains why a URL was fetched
Robots copiesPolicy cache + historyPolicy version historyExplains why a URL was blocked
Suppression and deletion recordsPolicy storeUntil withdrawn, auditedOpt-out must outlive the data it removed
Each data class has its own lifecycle; nothing is retained forever by default.

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).

Chapter 16

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.

Swipe to pan
DG26 — Region ownership split-brain and fenced failoverDG26 — Region ownership split-brain and fenced failoverOwnership token for news-a: 41 → 42EU regionControl planeUS regionnews-a bucketlease news-a, token 41partition: renew failslease news-a, token 42dispatch token 41dispatch token 42REJECTED stale tokenBoundOvershoot ≤ lease length × rate, or the pre-leased share.Not claimedPerfect global rate limits across independent regional counters.
Ownership token 41 is rejected once token 42 exists.

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).

Swipe to pan
DG30 — Storage outage: backlog growth and drainDG30 — Storage outage: backlog growth and drain200 tasks/s in; 50/s during a 2 h outage; 250/s after02 h5 h8 h10 hOutage phase+150/sdrain at 250 − 200 = 50/s netBacklog→ 1,080,000falls to 0 after 6 hservice backdrainedCaveatIf the backlog sits in a few origins with hard caps, thefleet-wide drain estimate is too optimistic.GuardsBackpressure, bounded spool, shed lowest priority.
Backlog growth and drain around a two-hour storage outage.

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.

Swipe to pan
DG31 — Opt-out and deletion request lifecycleDG31 — Opt-out and deletion request lifecycleDistinct data classes have distinct retentionRequest verifiedorigin or ownerSuppression listblocks new fetchesDelete / tombstonedocs, bodies, indexAudit recordwhat, when, by whomData classesURL rows, fetch metadata, parsed content, raw bytes, outlinks,robots copies each have their own retention.Not silentNo record is retained forever by default.
A verified opt-out or deletion request, from suppression to audited deletion.
Chapter 17

The Final Architecture

Here is the mature crawler, with every component traced back to the failure that required it.

Swipe to pan
DG28 — Final architecture with the feedback loopDG28 — Final architecture with the feedback loopDiscover · decide · fetch safely · refreshDiscoverSeeds · sitemaps · linksNormalize + scopeDurable frontierstates, priority, next timeDecide + fetch safelyScheduler + bucketsrobots, caps, ownerLease + fenceWorkersSafe egressDNS/IP, limitsProcess + refreshParse + dedupeObject + metadata +outboxIndexerChange score → revisitback to frontierSide planesControl plane: policy, ownership leases, deletion requests.Telemetry: origin, queue age, freshness.Render laneOptional, isolated, budgeted; same egress rules.
Discover, decide, fetch safely, refresh: the complete flow with its change-detection feedback loop.
ComponentAdded becauseNew complexity it introduced
Normalizer and scope guardSpellings and traps inflated work (ch. 6)Identity rules that can over-merge; budgets to tune
Origin bucketsWorkers flooded publishers (ch. 7)Shared state on the hot path
Robots cachePublishers state what to avoid (ch. 8)Policy versions, failure semantics
Durable frontier + leases + fencesA crash lost the queue (ch. 9)At-least-once duplicates; sweeper
Origin-first schedulerOne FIFO starved small publishers (ch. 10)Heap, aging, quotas
Safe egress and limitsSSRF and bombs (ch. 11)Central choke point to operate
Two-layer dedupeRepeated URLs and content (ch. 12)Provenance model; threshold risk
Conditional recrawl and adaptive scoreStaleness (ch. 13)Validator quirks; budget split
Render laneShell pages (ch. 14)Browser sandbox cost
OutboxFour systems, no shared transaction (ch. 15)Duplicate events; relay
Ownership leasesMany regions (ch. 16)Bounded overshoot
Every box exists because an earlier scene broke without it.

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).

Key takeaways
  1. 01A crawler is a scheduler first: it decides which URL, from which origin, at what pace, before it downloads anything.
  2. 02Politeness belongs to the origin, shared by every worker and region, and reacts to 429 and Retry-After for the whole origin.
  3. 03Identity is conservative: merge only what is provably the same; canonical links are hints.
  4. 04Robots is a protocol preference, not authorization; 404 and 5xx mean different things.
  5. 05Durable frontier rows, leases and fence tokens give at-least-once fetching with idempotent results, never exactly-once network I/O.
  6. 06Safe egress validates the address actually connected to, per redirect, and bounds the streamed response.
  7. 07URL dedupe, content hash and near-duplicate similarity prove different things.
  8. 08Recrawl is conditional and budgeted; a 304 saves bytes, not a request.
  9. 09Regions share budgets through fenced ownership leases; overshoot is bounded, not zero.