Skip to content
SeeItFlow
System Design · 16 chapters · ~21 min read

Distributed Web Crawler Architecture Deep Dive: Frontier, Politeness, Dedupe and Storage

How does a production web crawler actually work inside? This page opens each component of the Atlas Discovery reference design: the domain model and URL state machine, a conservative URL canonicalizer, robots.txt evaluation under RFC 9309, the robots-aware atomic claim with leases and fence tokens, the safe fetch and egress pipeline, parsing, three-layer deduplication and Bloom filter sizing, conditional recrawl scoring, the JavaScript render lane, the outbox write path and fenced regional ownership. Four pseudocode mechanisms carry sample inputs, outputs and failure behavior.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

Component Map and Responsibilities

This page opens each component of the Atlas Discovery crawler and shows how it works inside: data shapes, algorithms, state transitions and the places where one component's guarantee ends and the next one begins. It assumes you know what a crawler is; each new term is defined when first used.

Atlas Discovery in one paragraph. Atlas crawls public pages from three fictional publishers (news-a, docs-b, store-c at .example.test) to feed a search index. A URL is an address; an origin is scheme://host:port and is the unit of politeness; the frontier is the durable set of discovered URLs waiting to be fetched; a lease is a time-limited claim by a worker, carrying a fence token that lets storage reject stale writers; robots.txt is the publisher's published crawling preference. The tracked page throughout is U-104, news-a.example.test/articles/chip-supply. Assumed scale: 10 million successful fetches per day, about 116 per second.

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.
The components covered here, grouped as discover, decide, fetch safely and refresh.
ComponentOwnsDoes not own
URL normalizer / scope guardIdentity of a URL; whether it is in scopeWhether it may be fetched
Policy service (robots, suppression)Allow/deny verdicts with policy versionRate limiting
FrontierDurable URL state, priority, leases, fencesNetwork I/O
Origin scheduler / bucketsWhen an origin may be contacted nextWhat the page contains
Fetcher + egress gateConnection, redirects, limits, SSRF defenseParsing
ParserText, links, metadata, languageFetch decisions
DedupeURL, content and near-duplicate identityStorage layout
Storage + outboxBodies, documents, eventsScheduling
Recrawl scorerNext-visit timePoliteness (it only proposes; buckets decide)
Render laneExecuting scripts for chosen pagesSkipping the policy checks
Each component has a narrow responsibility; the table is also a map for debugging.
Chapter 02

Domain Model and Data Layout

The tables below are logical, not a mandated schema. They show which fields exist, why, and which constraint protects which invariant.

EntityKey fieldsConstraint that matters
urlsurl_id, normalized_url, raw_discovered_url, origin_key, state, priority, next_fetch_at, attempt_count, lease_owner, lease_expires_at, fence, etag, last_modified, last_status, content_hash, policy_version, discovered_fromUnique on normalized_url: first-seen is a database decision
urls (continued)depth, lease_id, last_http_status, body_hash, last_changed_at, last_success_at, render_mode (raw or rendered)body_hash changes only when content changes; last_changed_at is set only then
originsorigin_key, next_eligible_at, max_in_flight, in_flight, backoff_until, robots_version, owner_region, owner_tokennext_eligible_at and in_flight change only in the claim transaction
robots_cacheorigin_key, fetched_at, expires_at, status_class, parsed_rules, versionVersion is stored on every verdict
documentscontent_hash, body_ref, text_ref, language, fetched_at, fetch_idOne row per distinct content, referenced by many URLs
url_documenturl_id, content_hash, first_seen_at, last_seen_at, provenanceMany URLs may point to one document
outboxevent_id, type, document_ref, created_at, published_atWritten in the same transaction as the document row
suppressionsscope (origin/host/URL), reason, created_at, verified_byChecked before every fetch and after redirects
Atlas's logical model.
FieldWhy it existsValue for U-104 after its first fetch
raw_url / normalized_urlAudit versus identityhttps://news-a.example.test/articles/chip-supply?utm_source=x#top / https://news-a.example.test/articles/chip-supply
origin_keyPoliteness grouping and shard keyhttps://news-a.example.test:443
discovered_from, depthProvenance and scope depth limitS-01, depth 1
priority, stateScheduling and the state machine0.72, FETCHED
next_fetch_atNext recrawl due time2026-03-02T09:40Z
attempt_countRetry budget1
lease_id, lease_fence, lease_expires_atOwnership and stale-write rejectionL-9913, 7, expired
robots_policy_versionWhich rules justified the fetch3
last_http_status, etag, last_modifiedConditional recrawl inputs200, "a41f", Tue 09:12 GMT
body_hash, last_changed_at, last_success_atTrue change detection, freshness metricsha256:9c1e…, 09:12, 09:40
render_modeWhich lane produced the textraw
A realistic row: every field earns its place by feeding one decision.

Two design choices deserve a reason. First, keeping raw_discovered_url beside normalized_url makes normalization reversible and auditable. Second, url_document separates the address from the content, because the same text can live at several addresses and one address can serve different text over time.

The state machine

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.
URL states: DISCOVERED → ELIGIBLE → LEASED → FETCHED, with BLOCKED, DEFERRED, FAILED, QUARANTINED and SUPPRESSED as explicit terminals or waiting states.

The spec-level terminal states map as follows: FAILED covers retry-exhausted URLs (the retryable form is LEASED → DEFERRED with next_fetch_at in the future), BLOCKED and QUARANTINED are terminal until a policy or parser change, and SUPPRESSED is terminal until an opt-out is withdrawn.

FromToTrigger
DISCOVEREDELIGIBLEPassed identity, scope, policy and priority admission
DISCOVEREDBLOCKEDRobots/suppression/scope verdict, with reason code
ELIGIBLELEASEDClaim transaction (fence incremented)
LEASEDFETCHEDCommit with a matching fence
LEASEDELIGIBLELease expired with no valid commit; attempt_count kept
LEASEDDEFERRED429/503; next_fetch_at set from Retry-After or backoff
LEASEDFAILEDRetry budget exhausted
LEASEDQUARANTINEDLimit violated or suspicious content type
Allowed transitions. Anything not listed is rejected by the store.
Chapter 03

URL Canonicalization in Detail

The normalizer produces one identity for provably equal addresses and refuses to merge anything uncertain. The goal is stable identity with auditability, not the shortest key.

BeforeAfterWhy
HTTP://News-A.EXAMPLE.test:80/articles/chip-supply#tophttp://news-a.example.test/articles/chip-supplyScheme and host are case-insensitive; :80 is http's default; fragments never reach the server
https://news-a.example.test/a/b/../chip-supplyhttps://news-a.example.test/a/chip-supplyDot segments resolved
https://news-a.example.test/articles/chip-supply?utm_source=x&utm_medium=y…/chip-supplyBoth parameters are on news-a's tracking list
https://news-a.example.test/a?b=2&a=1…/a?a=1&b=2Sort parameters only for origins whose query order is known not to matter
https://docs-b.example.test/Guide/Setup…/Guide/Setup (unchanged)Path case is significant on many servers; merging with `/guide/setup` would be wrong
https://store-c.example.test/products?id=7 and ?id=8Both unchanged and distinctThe value is the identity; unknown parameters are preserved; merging would be wrong
Six before-and-after examples; the last two show where merging loses pages.
textSafe canonicalizer. Input `HTTP://News-A.EXAMPLE.test:80/a#x?utm_source=q` yields identity `http://news-a.example.test/a`; unparsable input returns REJECT with a reason code, and every rule that fired is traced.
function canonicalize(raw, base, originPolicy):
    u = parse(resolve(raw, base))            # relative -> absolute
    if u.scheme not in {"http","https"}: return REJECT("scheme")
    u.scheme = lower(u.scheme); u.host = idna_to_ascii(lower(u.host))
    if u.port == default_port(u.scheme): u.port = None
    u.fragment = None
    u.path = remove_dot_segments(percent_normalize(u.path))
    if originPolicy.tracking_params:
        u.query = drop(u.query, originPolicy.tracking_params)
    if originPolicy.query_order_insensitive:
        u.query = sort_params(u.query)
    if len(str(u)) > 2048: return REJECT("too_long")
    return ACCEPT(identity=str(u), raw=raw, rules_fired=trace)

Trailing slashes, encoding, internationalised names and collisions

Trailing slash. /docs and /docs/ are different resources by the URL rules, and many servers redirect one to the other. Atlas does not strip slashes blindly. It records the redirect when it sees one and learns the origin's preferred form. Percent-encoding. %7e and ~ are equivalent (unreserved characters), so the normalizer decodes unreserved bytes and upper-cases the hex digits of the rest, but it never decodes %2F, because a slash inside a segment is not a path separator. Internationalised hosts. bücher.example.test becomes its ASCII (punycode) form before comparison, and mixed-script lookalike hosts are flagged for review rather than merged. Collisions. Two raw URLs that normalize to the same identity are a *collision*; the system keeps both raw spellings in discovered_from provenance, and an operator can list the collisions a rule produced. That list is how F02 is caught: a rule that suddenly creates thousands of collisions on one origin is suspicious.

Redirect and canonical relationships. A redirect is the server's statement that a fetch of A returned B, and it is evaluated as a new candidate (the fetch pipeline chapter). A canonical link is the page's claim about its preferred address and is only a hint. Neither rewrites the identity of A: the frontier keeps A, records A → B or A ~ B as an edge, and decides to merge only through repeated redirects confirming it or a matching content hash.

Failure behavior: a parser error rejects with a reason and never guesses. Internationalised hostnames are converted to their ASCII form so that visually similar names are compared reliably. rel=canonical is stored as a hint and may later add a *candidate* merge, but only content comparison (chapter 8) turns a hint into a decision.

Chapter 04

Robots.txt Parsing and Evaluation

The parser reduces a file into groups of rules for named user-agents. The evaluator finds the applicable group, then the best matching rule for a path.

textstore-c's file (abridged). Atlas-Bot's group is chosen over `*` because it names us.
User-agent: *
Disallow: /account/
Disallow: /cart/

User-agent: Atlas-Bot
Disallow: /account/
Allow: /account/help/
Disallow: /search
PathMatching rulesDecision
/account/ordersDisallow: /account/ (9 chars)Blocked
/account/help/faqDisallow: /account/ (9), Allow: /account/help/ (14)Longest wins: Allowed
/products/lampnoneAllowed (default)
/search?q=lampDisallow: /search (7)Blocked
Longest-match worked evaluation; equal lengths resolve to Allow.

Wildcard * and a dollar-sign end anchor are supported in patterns. Atlas picks the most specific user-agent group and does not merge groups of different agents. Fetching the file is itself a fetch: redirects are bounded and every hop is checked. Status handling is deliberate. 2xx: parse and cache for up to 24 hours or less if the origin sends shorter cache headers. 4xx: the file is unavailable, which RFC 9309 says means no crawl restrictions, so crawl normally (do still honor everything else). 5xx or network error: unreachable, so treat the origin as disallowed temporarily, reuse the last good copy for a bounded time and back the origin off. An oversize file is truncated at a limit and parsed as far as read, which the document says is acceptable at around 500 KiB.

Every verdict records policy_version. At claim time and after each redirect the claim transaction compares the URL's verdict version against the current one; a mismatch triggers a recheck before any connection. A Crawl-delay line is a hint from outside the standard; Atlas treats it as one input into the origin's rate, never as an authority that can raise the rate above the operator's cap.

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.
Evaluation of three store-c URLs, with the deciding rule highlighted.
Chapter 05

Frontier, Origin Buckets and the Atomic Claim

The frontier combines two structures: the table of URLs and, derived from it, a priority-ordered view of origins that are due. The claim is the only operation that moves a URL toward the network.

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 min-heap by `next_eligible_at`; each origin has its own ordered URL set.

The scheduler keeps origins in a min-heap keyed by next_eligible_at. Popping gives the origin that became due first. An origin with in_flight == max_in_flight, an active backoff_until or an exhausted budget is skipped and re-inserted with a later key. Inside an origin, URLs are ordered by priority, then by age. Aging adds a bonus for time waiting so that a low-priority origin cannot starve.

textRobots-aware atomic claim. Two workers calling `claim` at t=0 for news-a: the first gets U-104 with fence 7 and `next_eligible_at = 5 s`; the second finds no eligible URL for news-a and moves to another origin. Crash inside the transaction: it rolls back, nothing is leased.
function claim(worker, now):                # one short transaction
    for origin in due_origins(now):          # min-heap order, caps applied
        if origin.in_flight >= origin.max_in_flight: continue
        if origin.backoff_until > now:               continue
        url = best_eligible_url(origin)              # priority, then age
        if url is None:                              continue
        verdict = policy.check(url, origin)          # robots + suppression
        if verdict.version != url.policy_version:
            mark(url, recheck); continue
        if verdict.denied:
            set_state(url, BLOCKED, verdict.reason);  continue
        url.state = LEASED; url.lease_owner = worker
        url.fence += 1;     url.lease_expires_at = now + LEASE
        origin.in_flight += 1
        origin.next_eligible_at = now + origin.min_gap
        return Claim(url, token=url.fence)
    return NONE

Aging and starvation caps. An origin's effective priority is base + age_weight × minutes_waiting, capped so aging cannot outweigh critical freshness work entirely. A hard starvation cap overrides everything: any origin with an eligible URL and no service for 30 minutes is moved to the front. Class quotas limit one class to a share of attempts while others have eligible work. Partitioning and host affinity. The frontier is partitioned by hash of origin_key, and each partition is served by schedulers that own whole origins, so an origin's bucket is only ever touched by one partition's claim transaction. Workers are stateless and may take work from any partition, but because they present the lease fence on commit, they need no affinity for correctness; affinity (keeping an origin's connections on one worker) only saves TLS handshakes.

LEASE is longer than the expected fetch time but not so long that a crashed worker delays recovery for minutes. A sweeper returns rows with lease_expires_at < now to ELIGIBLE and decrements in_flight. A worker finishing early renews nothing; one needing more time extends the lease with its fence token, and the extension fails if the fence changed.

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.
One claim transaction: verdict, state, fence, origin clock and in-flight counter move together.
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.
Why the origin clock must be advanced inside the claim, not by the worker.
Chapter 06

The Fetch Pipeline and Egress Gate

The fetcher is a pipeline of small gates, each of which can stop the request before more expensive work begins.

#GateRejects whenReason code
1SchemeNot http/httpsbad_scheme
2Network destinationAny resolved IP in private, loopback, link-local, multicast, reserved or metadata rangesblocked_network
3ScopeHost not allowed, depth or budget exceededout_of_scope
4Robots / suppressionVerdict is deny or version stalerobots_denied, suppressed
5Origin bucketNot eligible(not rejected; deferred)
6Connect + TLSCannot verify the certificatetls_failed
7Response limitsDecompressed size, type, time or header limits exceededoversize, bad_type, timeout
Gate order is cheapest-first and safety-critical-first.
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.
Resolve, validate every address, connect to the validated address, pin it, repeat per hop.
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.
Each redirect target is a new candidate for gates 1 to 5.

Connect-time pinning means that the address the validator approved is the address the socket opens. Resolving the name a second time inside the HTTP library would let a rebinding attacker return a public address for validation and an internal one for the connection. IPv4-mapped IPv6 forms such as ::ffff:10.0.0.5 are unwrapped before checking. Redirect hops are capped at five and the sequence of normalized URLs is remembered to stop loops. Limits apply to bytes read after decompression: the reader aborts the stream at the cap rather than trusting Content-Length.

ResponseClassCrawler action
200 with HTMLSuccessParse, hash, commit
304Unchanged (conditional request only)Update last_success_at; no parse
301/302/307/308RedirectNew candidate; all gates again; hop limit 5
404 / 410GoneTerminal after one or two confirmations; 410 sooner; keep tombstone
401 / 403Not available to usDo not retry aggressively; never attempt to bypass
429Slow downOrigin-wide backoff by Retry-After or backoff schedule
500 / 502 / 503 / 504RetryableBackoff with jitter, bounded attempts
Timeout, reset, DNS failureRetryable networkBackoff; repeated DNS failure becomes a terminal reason
Status classes and what the crawler does.

Timeouts and cancellation. Three separate limits apply: connect (a few seconds), time to first byte, and a total deadline for the whole response. When the deadline passes the fetcher cancels the read, closes the socket and records timeout; a result arriving after cancellation is ignored. TLS and SNI. The client sends the original hostname in SNI and verifies the certificate against that name, *while connecting to the pinned validated IP*; connecting by IP without the name would fail verification, and verifying against the IP would be wrong. Soft 404. A page that returns 200 with text like 'not found' is detected by comparing its extracted text and title against a stored fingerprint of the origin's error page, and by a very short text with no outlinks; it is stored with soft_404=true and excluded from the index, but it still counts against the fetch budget. Transfer encodings. Chunked bodies are read incrementally, and Content-Encoding decoding happens in the same size-limited stream.

textFetch + commit with bounded retries. Input: U-104 leased with fence 7. Output on success: document stored, outbox row written. If the worker is slow and fence 8 exists, `commit` aborts and the result is discarded; the stored body is content-addressed, so re-uploading is harmless.
function fetch_and_commit(claim):
    url, token = claim.url, claim.token
    for attempt in 1..MAX_ATTEMPTS:                  # bounded, e.g. 3
        try:
            resp = egress.get(url, conditional=url.validators,
                              limits=LIMITS, deadline=now()+T_MAX)
        except Retryable as e:                       # timeout, 503, reset
            sleep(backoff(attempt, e.retry_after)); continue
        except Rejected as e:                        # gates: terminal
            return commit(url, token, state=e.state, reason=e.code)
        body_ref = object_store.put_content_addressed(resp.body)  # idempotent
        return commit(url, token, resp, body_ref)    # fence-checked
    return commit(url, token, state=DEFERRED, reason="retries_exhausted")

function commit(url, token, ...):
    BEGIN
      if row(url).fence != token: ABORT -> StaleWriter   # fenced out
      update frontier row; write document metadata; write outbox row
    COMMIT
Chapter 07

Parsing and Extraction

The parser turns bytes into a document and a list of candidate links. Its output is evidence; later stages decide what to do with it.

Swipe to pan
DG12 — Ingestion, fetch, parse and storage dataflowDG12 — Ingestion, fetch, parse and storage dataflowStages are observable separatelySeeds + sitemapsNormalize + scopeFrontierSchedulerthen: outlinks return to Normalize + scopeFetch (safeegress)ParseDedupeStore + outboxIndexer
From response bytes to a document, links and stage outcomes.

It detects the character encoding (response header, byte-order mark, a meta tag, then a statistical guess) and records which method won. It extracts the visible text (excluding scripts and styles), title, headings, language, rel=canonical, robots meta directives and links with their anchor text. Relative links resolve against the <base> element if present, otherwise the final response URL after redirects, which is why the fetcher stores both. A nofollow marking influences priority and policy rather than being silently ignored, and frames and image-map links are optional extraction targets. Parse failures (broken markup is common) degrade to best-effort text with a parse_quality flag; a parser exceeding its time or memory budget quarantines the document.

Extraction confidence. Each document carries parse_quality (clean, repaired, partial) and text_ratio (visible text bytes over body bytes). A low ratio with a script-heavy body is the signal that feeds the render-lane decision. Raw HTML versus rendered DOM. Links found only after scripts run are tagged from_render=true; they enter the frontier like any other candidate but with lower initial priority because they cost more to confirm. Document types. HTML and plain text are parsed; PDF and feeds go to dedicated extractors behind the same limits; images, video and archives are skipped by the type allowlist. Malformed markup is handled by the parser's error-recovery rules, not by regexes, and never aborts the batch.

Extraction yield is also an observation: if a URL class suddenly returns almost no text, either the page changed, the site moved content into scripts (chapter 10) or the fetcher is receiving a block page. The parse stage therefore reports words extracted and links found per origin.

Chapter 08

Deduplication Mechanics

Three mechanisms answer three questions at three costs.

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 insert-if-absent, content hash, near-duplicate fingerprint.
LayerInputQuestionWhereError mode
URLNormalized URLKnown address?Unique key on urlsOver-merge if normalization is too aggressive
Content hashExtracted text (after boilerplate trimming)Same text?documents.content_hashBoilerplate differences hide equality; hash of raw bytes would see ad rotation as change
Near-duplicateSimHash/MinHash fingerprintMostly the same?Fingerprint index with a thresholdFalse merge of corrected vs outdated versions
What each layer assumes and how it can be wrong.

Content hashing needs a decision about *what* is hashed. Hashing raw HTML marks a page changed whenever an advertisement rotates. Atlas hashes normalized main text. When U-110 (/print) and U-104 produce the same text hash, both URLs reference one documents row and url_document records two provenance entries; the second URL is not dropped, so the discovery path and a possible canonical declaration are preserved.

Bloom filter sizing. A Bloom filter with *n* inserted items and false-positive rate *p* needs about m = −n·ln(p) ÷ (ln 2)² bits and (m ÷ n)·ln 2 hash functions. For one billion URLs at 1 percent that is roughly 9.6 billion bits, about 1.2 GB. Because a hit means 'maybe', the exact store confirms before any discard (diagram below). The filter's job is to remove most *lookups for new URLs*, never to remove URLs.

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 'no' means insert without lookup; a 'maybe' triggers an exact check.
Chapter 09

Recrawl, Conditional Requests and the Priority Score

Recrawl turns the crawler from a snapshot into a monitor. Each page's history decides when it is due.

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.
If-None-Match → 304 versus 200 → hash compare.

A conditional request carries the last ETag as If-None-Match and/or the last Last-Modified value as If-Modified-Since. A 304 response has no body, and the stored document stays current. A 200 is parsed and hashed; the page counts as changed only if the content hash differs. Servers vary: some give weak ETags, some change an ETag per request, some ignore conditional headers. Atlas therefore tracks per-origin validator reliability (how often a 200 followed by an equal hash happened) and, past a threshold, stops trusting validators there.

textAdaptive recrawl scoring. Input: a news front page whose history shows changes every 20 minutes returns about 22 minutes (jittered); an archive page unchanged for months returns the maximum. Failure behavior: with no history the page gets a default medium interval, and repeated 200s with identical hashes lengthen it.
function next_fetch_at(page, now, origin):
    interval = clamp(page.est_change_interval, MIN, MAX)   # from history
    score = importance(page) / interval                    # how worthwhile
    if origin.validator_reliable and page.changed_recently == False:
        interval = min(interval * 1.5, MAX)                # back off stable pages
    jitter = random(0.9, 1.1)
    return now + interval * jitter

Estimating the change interval. For each page keep a smoothed estimate: after each recrawl, if the content hash changed, est = max(MIN, 0.5 × est) (come back sooner); if unchanged, est = min(MAX, 1.5 × est) (come back later). A page that changed three times in three visits converges to MIN in a few steps; a stable archive page reaches MAX in a few more. Importance. A score from inbound-link evidence, audience tier of the requesting tenant and whether downstream queries hit the page. Multi-tenant prioritization. Each tenant has a share of the recrawl budget; unused share flows to others, and a tenant cannot starve discovery because discovery has its own reserved fraction. Preventing storms. Intervals are jittered by ±10 percent, newly onboarded batches are spread over the interval using hash(url) mod interval as an offset, and recrawl enqueue is rate-limited per origin so a thousand pages becoming due at 09:00 do not become a burst. Staleness metrics. Freshness gap is now − last_changed_at_observed for pages known to have changed, reported as percentiles by priority class, plus the share of due pages that are overdue.

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).
Revisit intervals by volatility and importance.

Budgeting: a fixed fraction of attempts is reserved for discovery, the rest is split into freshness classes. Recrawl candidates still pass through the origin bucket, so a very volatile site cannot override politeness by scoring highly.

Chapter 10

The JavaScript Render Lane

Rendering is a separate service with its own contract: take a URL that has passed the same checks, return the post-script document or a failure.

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.
Sampling decides whether a class needs the lane.

Selection: for a page class, fetch raw HTML and rendered DOM for a sample and compare extracted words. If raw extraction recovers at least a chosen fraction (say 90 percent) of rendered text, render nothing. Otherwise the class is marked render-required. The render worker runs a headless browser in an isolated sandbox with no access to internal networks, a hard wall-clock limit, a memory cap and a cap on sub-requests. Every sub-request, including images, scripts and API calls the page makes, is routed through the same egress gate and counted against the same origin bucket; a page that makes forty requests uses forty units of politeness budget. If the lane is saturated, pages fall back to raw extraction and are marked render_skipped. Outputs are tagged rendered=true so downstream consumers can compare quality.

Chapter 11

Storage, Outbox and Idempotent Consumers

The write path crosses four systems and no shared transaction. Correctness comes from the order of operations and from idempotency at each boundary.

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.
Atomicity exists only inside the database transaction; everything else is made safe to repeat.
StepSystemAtomic with…If a crash happens after this step
1Object store put (content-addressed)NothingOrphan body; sweeper removes unreferenced bodies after a grace period
2DB transaction: frontier row, document, url_document, outbox rowAll four togetherFully committed, or none of it
3Relay publishes outbox row, marks published_atNothingEvent may be sent twice; consumer dedupes by event_id
4Indexer applies eventIts own storeSafe to replay: update-by-id
The four write steps and their crash behavior.

Reconciliation. A periodic worker compares three things: object-store keys with no referencing row older than a grace period (delete them), rows in FETCHED with no outbox event (insert the missing event), and index-ready counts against document counts per origin. Mismatches are alerts, not silent repairs, except for the two safe ones above. The index-ready event is written only after the document row exists, so no consumer ever sees an event for content that is not durable.

The outbox solves the dual-write problem: updating a database and publishing a message cannot be done atomically across two systems. Writing the event as a row in the same transaction guarantees an event exists if and only if the document row exists. The relay is at-least-once, so events are idempotent: the consumer stores processed event_ids or applies updates keyed by document id and content hash.

Chapter 12

Regional Ownership and Fencing

Politeness is a property of the origin even when the crawler runs in several regions, so each origin's bucket needs one owner at a time.

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.
An older ownership token is rejected by the bucket.

Partition key and hot partitions. The partition key is origin_key, because the origin's bucket is the coordination boundary. Skew arises when a single origin holds a huge share of URLs: its rows are heavy, but its *rate* is still capped by politeness, so the load it puts on the scheduler is small; the real hot spots are storage size and sweeper scans. The fix is to sub-partition a giant origin's URL rows by hash(url) while keeping one bucket row for the origin, so claims still serialize on that single row. Region choice follows network distance and residency; failover reassigns ownership with a higher token. A control plane assigns each origin to a region with an ownership token that increases on every reassignment (41, then 42). The bucket accepts a claim only if the presented token equals the current one. During a partition the old owner may keep working until its lease expires, so a bounded window of double scheduling exists. Large origins may be split into pre-allocated shares (for example, 60 and 40 percent of the cap) so that both regions can run without per-request coordination; the shares sum to the cap. The guarantee is therefore: fenced ownership with *bounded* overshoot, not a perfectly synchronized global counter.

Chapter 13

Policy Versioning and Suppression

Rules change: robots files are edited, publishers opt out, internal blocklists grow. The design records which version of which policy produced each decision.

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 queued URL is rechecked against a newer policy before connecting.

policy_version is a monotonically increasing number per origin covering robots rules, suppressions and scope settings. A verdict is valid only for the version that produced it; the claim transaction rechecks if versions differ. Suppression (an opt-out or deletion request) outranks everything: it blocks the fetch, triggers deletion workflows and is itself retained as a record without the content it removed.

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 request, from suppression to audited deletion.
Chapter 14

Observability Model and Reason Codes

A crawler without reason codes is unexplainable. Every state change in the frontier carries one.

SignalHealthy shapeWhat a deviation means
Request rate vs origin limitAt or below capAbove cap: a bucket bug or ownership overlap
Share of 429/503 per originLow, short-livedPersistent: cap too high or publisher complaint
Oldest eligible URL ageSeconds to minutesRising: scheduler or capacity problem
Blocked by robots / scopeStableSudden jump: a robots change or a scope bug
Parse yield (words, links)Stable by classDrop: block page, script-rendered site or parser regression
304 rate and changed-page yieldStableFalling 304 share: validators broken; low yield: wasted recrawls
Freshness gap by priorityWithin targetRising: recrawl budget or scheduling
Stage counts: fetched, parsed, stored, index-readyClose togetherA widening gap identifies the failing stage
Signals and their meaning; the same incidents appear on the failure page.

Operational formulas. Attempt rate = successes ÷ success ratio (115.74 ÷ 0.80 = 144.68 per second). Origin saturation = requests per second to an origin ÷ its cap; sustained values near 1.0 with rising 429s mean the cap is too high. Byte throughput = attempts × mean compressed bytes. Backlog age = now − next_fetch_at of the oldest eligible URL. Changed-page yield = changed pages ÷ recrawl attempts. Crawl coverage = distinct pages stored ÷ pages known to exist from sitemaps or samples. A falling 304 share with a stable changed-page yield means validators have degraded.

Chapter 15

Security, Privacy and Compliance Mechanics

The crawler is an automated client with network reach and a store of third-party content, so it carries its own security and compliance duties.

RiskMechanism
SSRF and DNS rebindingConnect-time address validation, pinning, per-hop checks, network deny rules (fetch pipeline chapter)
Malicious scripts in the render laneIsolated sandbox, no internal network, no credentials, per-page limits, throwaway profile per page
Data exfiltrationRender workers and fetchers have no access to internal services or secrets; egress is allowlisted to the public internet
Personal data in pagesDo not crawl authenticated or robots-excluded areas; filter obvious personal identifiers (emails, phone numbers) from the indexable text when policy requires; keep raw bodies for a short, bounded period
Deletion and opt-outA verified request writes a suppression, blocks fetches immediately and drives deletion across frontier, documents, bodies and index entries
Access controlSeparate roles: operators may pause or suppress an origin; only a privileged role may change rate caps or retention; consumers read documents but cannot read raw bodies
AuditabilityEvery block, suppression, policy version change and deletion is an append-only record with actor, time and reason
Risks and the mechanism that answers each.

A deletion request is processed as a small state machine: RECEIVED → VERIFIED → SUPPRESSED → DELETING → DELETED → AUDITED. Each step is idempotent and keyed by the suppression id, so a crash mid-deletion resumes rather than restarts. The suppression record outlives the deleted data, because it is what prevents the content from being fetched again. Identity of the requester is verified against the origin (for example, a token served at a known path) before any deletion happens, so a stranger cannot erase a publisher's pages from the index.

Chapter 16

Correctness Invariants and the Tests Behind Them

Each invariant below is a statement that must always hold, with the test that would catch a violation.

#InvariantTest that guards it
1No connection opens before scheme, network, scope, robots and bucket checks pass; they repeat at every redirectUnit test with a blocked URL and a redirect to a private address: zero sockets opened
2An origin's request starts are never closer than its minimum gap, regardless of worker countTwo-claim race test at the same instant: exactly one succeeds
3A commit succeeds only if its fence equals the row's current fenceStale-token commit test returns stale
4Every URL is in exactly one state; transitions outside the table are rejectedProperty test over random transition sequences
5First-seen of a normalized URL is decided only by the database unique keyConcurrent insert test: one winner
6Normalization never merges distinguishable case, path or query-value identitiesTable test with the six before/after examples
7Robots: longest match wins, ties allow; 4xx means unavailable, 5xx or timeout means unreachableTable-driven tests from the standard's examples
8A Bloom 'maybe' is always verified before a URL is discardedTest with a forced false positive: URL is still inserted
9An outbox row exists if and only if its document row existsCrash-injection test between each write step
10A stale ownership token is rejected by the bucketToken 41 versus 42 test
11Retries honor Retry-After and never exceed the attempt capFake-clock retry test
12Suppressed origins are never fetched, and deletion is repeatableSuppression plus replay test
Twelve invariants. The repository's logic tests exercise the pure-function ones; the crash-injection and concurrency ones belong in the integration suite of a real implementation.
Key takeaways
  1. 01Every URL has one state and every change has a reason code.
  2. 02The claim transaction is the single place where policy, fairness, rate and ownership meet.
  3. 03A fetch result is committed only with a matching fence; bodies are content-addressed so retries are harmless.
  4. 04Dedupe layers answer different questions and fail differently; a Bloom 'maybe' is always verified.
  5. 05Idempotency, not exactly-once delivery, makes the storage path safe.
  6. 06Regional ownership is fenced and bounded, not globally exact.