Exa Web Crawling Engineer
A twelve-week curriculum in Rust, web-scale crawling, retrieval, and the application strategy to match — with verified resources, buildable project specs, and a mastery path beyond the job description.
Exa Web Crawling Engineer — Learning & Application Kit
Built September 2026. 14 documents, ~6,750 lines: every phase of the 12-week Rust + crawling roadmap, with verified links, crate versions, hands-on exercises, project specs, and Exa-specific application intel. Companion research prompts live in .research-prompts/ and raw agent logs in .research-logs/ (both safe to delete).
Read it as one page: open
index.html— a single offline file with sidebar navigation, section search (/or⌘K), scrollspy, and dark mode. Rebuild it withnode build_guide.mjsafter editing any markdown.
File map
| File | What it covers | Use when |
|---|---|---|
| 01-rust-fundamentals.md | Rust Book, Rustlings, 100 Exercises, books, videos, CLI book, thiserror/anyhow, proptest, criterion | Weeks 1–2 |
| 02-async-rust-tokio.md | Async book, Tokio tutorial + mini-redis, futures/pin, Semaphore, buffer_unordered, cancellation, tracing | Weeks 3–4 |
| 03-http-scraping-rust.md | reqwest, scraper, serde, url, lol_html, dedup hashing, wiremock testing, extraction patterns | Weeks 5–6 / Project A |
| 04-polite-crawling-frontier.md | RFC 9309, sitemaps, Mercator URL frontier, governor/backoff, conditional GETs, SQLite/Postgres state, crawlers to study | Weeks 7–8 / Project A |
| 05-browser-automation-cdp.md | CDP fundamentals, Playwright, chromiumoxide/fantoccini/headless_chrome, hybrid HTTP-first routing, SPA-shell detection | Weeks 9–10 / Project B |
| 06-distributed-crawlers-observability.md | Queues (NATS/Kafka/Redis/SQS), partitioning, idempotency, metrics/tracing, Docker, profiling, DDIA | Week 11 / Project D |
| 07-search-retrieval.md | IR fundamentals, Tantivy, fastembed + Qdrant/LanceDB, boilerplate removal, snippets, nDCG/MRR | Weeks 8–10 / Project C |
| 08-exa-application-portfolio.md | Exa company research, crawler JD, outreach templates, portfolio presentation, resume keywords, legal framing, backups | Throughout |
| 09-project-specs.md | Buildable specs for all 4 portfolio projects: architecture, data models, acceptance criteria, benchmark plans, build order | Weeks 3–12 |
| 10-rust-crawler-code-patterns.md | 14 production patterns with Rust code: client factory, per-host politeness, robots cache, retry taxonomy, frontier leasing, shutdown, metrics, wiremock tests + full Cargo.toml/main.rs | Weeks 3–10 |
| 11-interview-prep.md | Crawler systems-design question bank, Rust topics, 10+ mock Q&As, 2-week crunch plan | Weeks 10–12 |
| 12-public-signal-community.md | OSS contribution targets, 4-post writing plan, networking handles/channels, weekly signal calendar, outreach templates | Throughout |
| 13-toolchain-verified-2026.md | Version audit of files 01–08 (Sep 2026), copy-paste Cargo.toml, rust-toolchain.toml, macOS setup, corrections table | Day 0 |
| 14-exa-deep-research-2.md | Second-pass Exa intel: Series C, 500B+ URLs, crawler roles SF + Singapore, comp, hiring managers, quotes, interview questions | Before applying |
| 15-jd-gap-analysis.md | Live JD check (Backend vs Web Crawling), requirement-by-requirement coverage matrix, 5 gaps to close, 12-point proof-of-scale plan for being overqualified | Before applying |
| 16-rust-mastery-path.md | 6-level Rust mastery path beyond "enough to ship": mastery bars, exercises with acceptance criteria, proof artifacts (Miri/loom, perf before/after, OSS), resource tables, weeks 1-16 map | Weeks 1-16, daily morning track |
| 17-overqualified-projects.md | Overqualified editions of all projects: A+ distributed crawler (100M/day capacity model), B+ TS/Playwright sidecar + anti-bot resilience, C+ self-built ANN index, D+ benchmarks, E mini exa-d, F 10B-page search feasibility, scorecard + weeks 3-16 schedule | Weeks 3-16 |
| 18-syllabus.md | The spine. Exact assignments: every row = topic + exact source (chapter/section/exercise/example file) + one drill, weeks 1-16, plus the frozen JD project title list with resume lines | Open while working |
First week (start here)
- Day 0: install the toolchain using the commands in 13-toolchain-verified-2026.md (
rustup, components, cargo-nextest/watch/flamegraph). - Days 1–3: Rust Book + Rustlings from 01; build the URL-normalizer CLI.
- Days 4–7: finish the 4 CLI drills (robots.txt fetcher, title extractor, CSV/JSON exporter) and start 02.
12-week schedule → files
| Weeks | Focus | Deliverable | Files |
|---|---|---|---|
| 1–2 | Rust foundations + Tokio/reqwest/scraper fluency | 4 CLI tools, clippy/fmt green | 01, 02, 13 |
| 3–5 | Project A: polite crawler | polite-v0.1.0: 1k pages/20 hosts, robots + rate-limit proof, /metrics, crash-resume |
03, 04, 10, 09 |
| 6–7 | Project B: hybrid HTTP/browser | hybrid-v0.1.0: 30-URL parity fixture, fallback table, measured cost multiplier |
05, 09, 10 |
| 8–10 | Project C: crawl-to-search | search-v0.1.0: 2k docs indexed, /search live, recall@3 ≥ 0.75, p95 < 200ms |
07, 09 |
| 11–12 | Project D: benchmarks + polish + apply | bench-v1.0: benchmark matrices, writeup, reviewer-ready READMEs, demo video |
06, 09, 11, 08, 12, 14 |
Dependency rule from 09: B reuses A's politeness/frontier modules; C consumes A's crawl.db (≥2k pages from a permissive corpus); D quotes A+B+C numbers. Each milestone ends with a tagged release and README results table.
Overqualified track (files 16 + 17). 16 adds a 6-level Rust mastery path (~140-170h core, +40-50h extension) run as a morning track across weeks 1-12: borrow-checker katas, crawler error taxonomy, MPMC queue with Miri/loom receipts, profiling + zero-copy/SIMD passes, production-grade testing/supply chain, Tokio internals. 17 upgrades the projects: A+ adds a 100M pages/day capacity model, 5k-host politeness simulation and OPIC scheduling; B+ adds a TypeScript Playwright sidecar and challenge/anti-bot resilience; C+ adds a self-built HNSW/IVF+PQ ANN index with a 1B-vector extrapolation; E/F cover the Backend JD (mini exa-d pipeline, 10B-page search feasibility package). Sequence and cut lines are in 17 §8; the proof-of-scale checklist remains 15 §6.
Application fast facts (verify before applying — listings change weekly)
- Crawler roles were live in San Francisco ($180K–$350K + equity) and Singapore as of Sep 2026; the SF req has been open over a year, per 14.
- Referrals convert at 40–70% vs 0.1–2% for cold portals; the playbook is proof-of-work + specific cold email/X DM + PRs to
exa-labsrepos + their Discord. - Resume/outreach language: "web-scale crawling," "distributed crawler," "crawl politeness / RFC 9309," "JavaScript rendering, CDP/Playwright," "high-performance Rust," "search/retrieval quality."
- Never use "anti-bot bypass" framing; say politeness-aware and responsible crawling (08 §legal, 14).
- Smart questions to ask in the interview: 14, interview-loop details in 11.
Accuracy notes
- 13 audited every pinned version and ~90 links across files 01–08 against crates.io/docs.rs on 2026-09-11; only a few genuine corrections found (e.g.,
console-subscriber0.4 → 0.5, criterion repo moved tocriterion-rs/criterion.rs, don't bump html5ever past scraper's pin). - Files 01–08 were researched by 8 parallel phase agents; files 09–14 by Kimi K3 and Muse 1.3 xhigh deep-research runs. Treat job/comp specifics as perishable; everything link-based was spot-checked.
Rust Fundamentals for a Crawler Engineer
Exa's crawler team runs 100M+ pages/day in Rust/C++, so ownership/borrowing, zero-copy parsing, and predictable performance are job requirements, not trivia. Weeks 1-2 of your roadmap need to get you to "I can ship a tested CLI" — not to Rust mastery. Everything below is chosen for that single goal: enough Rust to write parsing, error-handling, and benchmarking tools, with the language rabbit holes deferred to weeks 3+.
Core path in one line: rustup -> The Book + Rustlings (parallel) -> 100 Exercises -> The Rust CLI Book -> thiserror/anyhow -> tests + proptest -> criterion. Everything else is lookup or enrichment.
Start Here (core path)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| rustup (toolchain installer) | https://rustup.rs/ | CLI tool | Free | One-command install/update of rustc, cargo, clippy, rustfmt; pin stable |
| The Rust Programming Language, 3rd ed. ("The Book") | https://doc.rust-lang.org/stable/book/ | Online book / print | Free online | The canonical curriculum (Rust 2024 edition); read ch. 1-13 in weeks 1-2 |
| Rustlings | https://rustlings.rust-lang.org/ | Interactive CLI exercises | Free | ~94 exercises driven by compiler errors; run in parallel with The Book chapters |
| 100 Exercises To Learn Rust | https://rust-exercises.com/100-exercises/ | Test-driven course | Free | Best "second pass" after Rustlings; closes syntax/type-system gaps with real tests |
| Rust by Example | https://doc.rust-lang.org/rust-by-example/ | Runnable examples | Free | Fast lookup when the Book's prose is not enough; every snippet is editable |
| The Rust CLI Book | https://rust-cli.github.io/book/ | Online book | Free | clap, exit codes, stdout/stderr discipline — exactly the shape of crawler tooling |
| Command-Line Rust | https://github.com/kyclark/command-line-rust | Project book + code | Paid (~$50) | 14 test-first CLI projects (grep, cut, wc clones); builds production habits |
| Crust of Rust: Lifetime Annotations + Iterators | https://www.youtube.com/@jonhoo | Video (2 x ~1.5h) | Free | Clears the two concepts beginners fake-understand; watch at end of week 1 |
Do this, not everything: finish Book ch. 1-13 with Rustlings, do the first ~60 of 100 Exercises, then build the exercises in this file. Only then branch into videos and paid books.
Books (free + paid)
Free
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| The Rust Programming Language, 3rd ed. | https://doc.rust-lang.org/stable/book/ | Online book | Free | Starting point; updated for edition 2024, assumes recent stable (1.9x) |
| Interactive Rust Book (Brown University) | https://rust-book.cs.brown.edu/ | Interactive web book | Free | Quizzes, highlighting, ownership visualizations if the borrow checker is not clicking |
| Rust by Example | https://doc.rust-lang.org/rust-by-example/ | Online book | Free | Example-first reference for syntax and std APIs |
| 100 Exercises To Learn Rust (PDF) | https://rust-exercises.com/100-exercises-to-learn-rust.pdf | Free | Offline version of the exercise course; print edition also sold | |
| Comprehensive Rust (Google) | https://google.github.io/comprehensive-rust/ | Slide course | Free | 4-day fundamentals course for experienced engineers; good structured review in week 3 |
| The Rust CLI Book | https://rust-cli.github.io/book/ | Online book | Free | Arg parsing, errors, output, packaging for command-line tools |
| Rust Cookbook | https://rust-lang-nursery.github.io/rust-cookbook/ | Recipe collection | Free | Copy-paste recipes (files, text, CSV, concurrency); verify APIs against current crates |
| Effective Rust | https://www.lurklurk.org/effective-rust/ | Online book | Free | 35 concise, actionable idiom rules; read right after The Book to write professional code |
| Rust By Practice | https://practice.rs/ | Exercise book | Free | Extra graduated exercises with detailed explanations when you need more reps |
| Tour of Rust | https://tourofrust.com/ | Interactive tour | Free | 2-hour syntax overview in the browser before committing to the full Book |
| Learn Rust With Entirely Too Many Linked Lists | https://rust-unofficial.github.io/too-many-lists/ | Online book | Free | Ownership/Box/Rc/interior-mutability intuition; do in weeks 3-4 |
| Rust Atomics and Locks | https://marabos.nl/atomics/ | Online book | Free | Concurrency mental model; read before the distributed-crawler phase |
| The Rust Performance Book | https://nnethercote.github.io/perf-book/ | Online book | Free | Profiling and benchmarking playbook; pairs directly with criterion |
| Rust Design Patterns | https://rust-unofficial.github.io/patterns/ | Online book | Free | Idiom catalog; keep as reference for later phases |
| The Rustonomicon | https://doc.rust-lang.org/nomicon/ | Online book | Free | unsafe deep details; skip unless you actually need unsafe |
Paid
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Programming Rust, 2nd ed. | https://www.oreilly.com/library/view/programming-rust-2nd/9781492052586/ | Book (O'Reilly) | Paid (~$50) | Deepest coverage of ownership, borrowing, and memory layout; best second book |
| Command-Line Rust | https://www.oreilly.com/library/view/command-line-rust/9781098109424/ | Book (O'Reilly) | Paid (~$50) | Project-based CLI primer; code updated for clap v4 (2024) |
| Rust for Rustaceans | https://nostarch.com/rust-rustaceans | Book (No Starch) | Paid (~$40) | Trait design, async internals, API design; read after ~6 months of Rust |
| Rust in Action | https://www.manning.com/books/rust-in-action | Book (Manning) | Paid (~$45) | Systems projects (networking, files, CPU) for people from C/C++ backgrounds |
| Learn Rust in a Month of Lunches | https://www.manning.com/books/learn-rust-in-a-month-of-lunches | Book (Manning) | Paid (~$40) | Short time-boxed chapters for working engineers |
| Zero To Production in Rust | https://www.zero2prod.com/ | Book/course | Paid (~$40) | Production backend patterns; useful at Phase 7, not weeks 1-2 |
| The Rust Programming Language, 3rd ed. (print) | https://nostarch.com/rust-programming-language-3rd-edition | Print/ebook | Paid (~$50) | Same content as the free Book; buy only if you want paper |
Video Courses & Series
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Crust of Rust (Jon Gjengset) | https://www.youtube.com/@jonhoo | Video series | Free | The benchmark for intermediate Rust; start with Lifetime Annotations, Iterators, Smart Pointers |
| Jon Gjengset (channel) | https://www.youtube.com/@jonhoo | Video | Free | "Decrusting" serde/tokio, Q&As, distributed-systems challenges; relevant from Phase 2 onward |
| fasterthanlime (Amos Wenger) | https://www.youtube.com/@fasterthanlime | Video + articles | Free | First-principles long-form content; "Catching up with async Rust" is a Phase 2 must |
| fasterthanlime (articles) | https://fasterthanli.me/ | Articles | Free | Deep written walkthroughs on async, HTTP, and dependencies |
| No Boilerplate | https://www.youtube.com/@NoBoilerplate | Short videos | Free | Mental models and motivation ("How to Learn Rust", "Rust for the impatient"); not a curriculum |
| Let's Get Rusty | https://www.youtube.com/@letsgetrusty | Video | Free (paid bootcamp) | Clear beginner explainers of ownership/traits; free content is enough, bootcamp reviews are mixed |
| Logan Smith | https://www.youtube.com/@_noisecode | Video | Free | Visually clear explanations of lifetimes, traits, memory; great when stuck |
| Jeremy Chone | https://www.youtube.com/@JeremyChone | Video | Free | Practical patterns: error handling, project structure, CLI tips |
| Comprehensive Rust (Google) | https://google.github.io/comprehensive-rust/ | Slide course | Free | Classroom-style 4-day course used to train Google engineers; strong structured review |
| Rust Adventure | https://rustadventure.dev/ | Courses | Paid | Project-driven Rust (CLI, web) with community; sane alternative to bootcamps |
| CodeCrafters: Build your own HTTP server | https://app.codecrafters.io/courses/http-server/overview | Guided project | Free tier + paid | Builds a TCP/HTTP server in Rust; the perfect bridge from Phase 1 into Phase 2/3 |
Interactive Exercises
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Rustlings | https://rustlings.rust-lang.org/ | CLI exercises (~94) | Free | Core path: fix broken code while reading The Book |
| 100 Exercises To Learn Rust | https://rust-exercises.com/100-exercises/ | TDD course (~100) | Free | Second pass with unit tests you can run; wr runner verifies solutions |
| Exercism Rust Track | https://exercism.org/tracks/rust | 100+ exercises + mentoring | Free | Human code review on idiomatic Rust; best free feedback loop available |
| Rustfinity | https://www.rustfinity.com/ | Browser exercises | Free | Zero-setup drills with server-side tests; good on a laptop with no toolchain |
| Advent of Rust (Rustfinity) | https://www.rustfinity.com/advent-of-rust | Puzzle event | Free | Seasonal harder puzzles to consolidate parsing skills |
| Rust By Practice | https://practice.rs/ | Exercise book | Free | Additional graduated exercises with explanations |
| Tour of Rust | https://tourofrust.com/ | Interactive tour | Free | Gentle browser intro before or alongside The Book |
| Rust Playground | https://play.rust-lang.org/ | Browser compiler | Free | Zero-setup experiments; shareable snippets for asking questions |
| Advent of Code | https://adventofcode.com/ | Puzzles | Free | Best parsing/iterator workout; scaffold with cargo-aoc |
| cargo-aoc | https://github.com/gobanos/cargo-aoc | Cargo helper | Free | Generates AoC crate skeletons, downloads inputs, benchmarks solutions |
Tooling Setup
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| rustup | https://rustup.rs/ | CLI installer | Free | Toolchain management; rustup component add clippy rustfmt |
| rust-analyzer | https://rust-analyzer.github.io/ | Language server | Free | Inline errors/types and quick fixes in any editor; the single highest-ROI setup |
| rust-analyzer (repo) | https://github.com/rust-lang/rust-analyzer | Source | Free | Understand go-to-definition, "expand macro", and assist behavior |
| VS Code + rust-analyzer | https://code.visualstudio.com/ | Editor + extension | Free | Most common setup; install extension rust-lang.rust-analyzer |
| RustRover (JetBrains) | https://www.jetbrains.com/rust/ | IDE | Free for non-commercial | Batteries-included debugger/profiler if you like JetBrains IDEs |
| Zed | https://zed.dev/ | Editor | Free | Fast editor with first-class Rust support |
| rustfmt | https://github.com/rust-lang/rustfmt | CLI component | Free | cargo fmt; run on save from day 1 so style is never a topic |
| Clippy | https://doc.rust-lang.org/clippy/ | Lint suite | Free | cargo clippy -- -D warnings; teaches idiomatic Rust through lints |
| bacon | https://github.com/Canop/bacon | CLI watcher | Free | Background check/test/clippy with instant red/green feedback |
| cargo-watch | https://github.com/watchexec/cargo-watch | CLI watcher | Free | Re-runs arbitrary cargo commands on file change |
| cargo-nextest | https://nexte.st/ | Test runner | Free | 2-3x faster tests with clearer failure output; adopt as soon as tests grow |
| cargo-expand | https://github.com/dtolnay/cargo-expand | CLI tool | Free | See macro/derive expansions when errors are cryptic |
cargo add (built into cargo) |
https://doc.rust-lang.org/cargo/commands/cargo-add.html | Cargo subcommand | Free | Add dependencies with correct versions/features; no hand-editing TOML |
| insta | https://github.com/mitsuhiko/insta | Testing crate | Free | Snapshot assertions with cargo insta review; ideal for parser output |
| cargo-flamegraph | https://github.com/flamegraph-rs/flamegraph | Profiler | Free | Flame graphs from cargo flamegraph once benchmarks show a problem |
| mold (Linux linker) | https://github.com/rui314/mold | Linker | Free | Cuts link time on Linux; skip on macOS |
Minimal setup, copy-paste:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup component add clippy rustfmt
cargo install bacon cargo-nextest cargo-expand
cargo new crawlkit --bin && cd crawlkit
cargo add anyhow thiserror
cargo fmt && cargo clippy -- -D warnings && cargo test
Project hygiene worth adopting immediately (Rust 1.74+):
# Cargo.toml
[lints.clippy]
unwrap_used = "warn"
todo = "warn"
Error Handling, Testing & Benchmarking
Error handling (learn this in week 1, not month 3)
Core rule: typed errors in libraries (thiserror), dynamic errors in binaries (anyhow). Use ? everywhere; unwrap() only in tests and genuinely unreachable cases.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
std::result::Result |
https://doc.rust-lang.org/std/result/ | Std docs | Free | The recoverable-error type; ? propagation |
std::option::Option |
https://doc.rust-lang.org/std/option/ | Std docs | Free | Representing "no value" without an error path |
std::error::Error trait |
https://doc.rust-lang.org/std/error/trait.Error.html | Std docs | Free | The trait behind all error interop; implement via thiserror derive |
| thiserror 2.0 (latest 2.0.20) | https://docs.rs/thiserror | Crate | Free | #[derive(Error)] enums with #[from]/#[source] and clean Display; library code |
| anyhow 1.0 (latest 1.0.104) | https://docs.rs/anyhow | Crate | Free | One opaque error type plus .context(); main(), CLIs, glue code |
| snafu | https://docs.rs/snafu | Crate | Free | Alternative context-selector error style; some teams prefer it |
Small, realistic pattern:
#[derive(Debug, thiserror::Error)]
pub enum CrawlError {
#[error("invalid url: {0}")]
Url(#[from] url::ParseError),
#[error("robots.txt unavailable")]
Robots(#[source] std::io::Error),
}
fn load(path: &str) -> anyhow::Result<String> {
std::fs::read_to_string(path).context("reading robots.txt")
}
Testing
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
Unit tests (#[cfg(test)]) |
https://doc.rust-lang.org/book/ch11-01-writing-tests.html | Book ch. 11 | Free | Testing private functions in the same file |
Integration tests (tests/) |
https://doc.rust-lang.org/book/ch11-03-test-organization.html | Book ch. 11 | Free | Exercising your public API like an external user |
| Doc tests | https://doc.rust-lang.org/rustdoc/documentation-tests.html | Rustdoc | Free | Examples in /// run under cargo test; docs that cannot rot |
| proptest 1.11 | https://github.com/proptest-rs/proptest | Crate | Free | Property tests with automatic shrinking; perfect for URL/robots parsers |
| insta | https://github.com/mitsuhiko/insta | Crate | Free | Snapshot tests for CLI/JSON output instead of brittle string asserts |
| trybuild | https://github.com/dtolnay/trybuild | Crate | Free | Compile-fail tests; useful when you design typed APIs later |
| cargo-llvm-cov | https://github.com/taiki-e/cargo-llvm-cov | CLI tool | Free | Coverage via LLVM source-based instrumentation |
| tarpaulin | https://github.com/xd009642/tarpaulin | CLI tool | Free | Alternative coverage tool; Linux-focused |
| cargo-mutants | https://github.com/sourcefrog/cargo-mutants | CLI tool | Free | Mutation testing to verify your tests actually catch bugs (week 3+) |
Benchmarking
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| criterion 0.8 (latest 0.8.2) | https://github.com/bheisler/criterion.rs | Crate | Free | The default: statistical significance, regression detection, HTML reports |
| Criterion book | https://bheisler.github.io/criterion.rs/book/ | Docs | Free | Setup plus analysis interpretation; note the API changed since 0.4 tutorials |
| divan 0.1 (latest 0.1.21) | https://github.com/nvzqz/divan | Crate | Free | Attribute-macro microbenchmarks with the simplest API |
| iai-callgrind | https://github.com/iai-callgrind/iai-callgrind | Crate | Free | Instruction-count benchmarks; stable results in noisy CI |
| hyperfine | https://github.com/sharkdp/hyperfine | CLI tool | Free | End-to-end timing of your compiled CLI; before/after comparisons |
| The Rust Performance Book | https://nnethercote.github.io/perf-book/ | Online book | Free | How to benchmark without fooling yourself, then optimize safely |
std::hint::black_box |
https://doc.rust-lang.org/std/hint/fn.black_box.html | Std docs | Free | Prevent the optimizer from deleting the code you are measuring |
Cargo + criterion skeleton:
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
[[bench]]
name = "normalize"
harness = false
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn bench(c: &mut Criterion) {
let input = "HTTPS://Exa.AI:443/a/../b?q=1#frag";
c.bench_function("normalize", |b| b.iter(|| black_box(black_box(input))));
}
criterion_group!(benches, bench);
criterion_main!(benches);
Communities
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Rust Users Forum | https://users.rust-lang.org/ | Forum | Free | Highest-quality Rust Q&A; include a minimal reproducer and compiler version |
| r/rust | https://www.reddit.com/r/rust/ | Subreddit | Free | Ecosystem news and design discussion; search before posting |
| r/learnrust | https://www.reddit.com/r/learnrust/ | Subreddit | Free | Beginner questions answered without judgment |
| Rust Community Discord | https://discord.gg/rust-lang-community | Discord | Free | Real-time help; great for "why does this not compile" in under 5 minutes |
| Rust Zulip | https://rust-lang.zulipchat.com/ | Chat | Free | Where language/library development happens; lurking is educational |
| This Week in Rust | https://this-week-in-rust.org/ | Newsletter | Free | The best signal-per-minute for crates, RFCs, and jobs |
| Rust Blog | https://blog.rust-lang.org/ | Blog | Free | Release notes and project direction; know what "stable" means |
| Tokio Discord | https://discord.gg/tokio | Discord | Free | Async/Tokio help for Phase 2 and beyond |
Stack Overflow (rust tag) |
https://stackoverflow.com/questions/tagged/rust | Q&A archive | Free | Borrow-checker answers already indexed; watch for pre-2021 idioms |
Recommended Order (weeks 1-2 and beyond, with time estimates)
Assumes ~3 focused hours/day, ~40 hours total for Phase 1. If you have less time, cut the videos, not Rustlings or the exercises.
Week 1 — syntax, ownership, first CLIs (~20h)
| Day | Work | Time |
|---|---|---|
| 1 | rustup + rust-analyzer setup; Book ch. 1-3 (install, guessing game, basics); Rustlings intro through primitive_types |
3h |
| 2 | Book ch. 4-6 (ownership, structs, enums); Rustlings move_semantics -> enums; start Exercise 1 (URL normalizer) |
3h |
| 3 | Book ch. 7-9 (modules, collections, error handling); Rustlings strings -> error_handling; finish Exercise 1 |
3h |
| 4 | Book ch. 10 (generics, traits, lifetimes); Rustlings generics -> lifetimes; read thiserror + anyhow docs |
3h |
| 5 | Book ch. 11 (testing); Rustlings tests, iterators; Exercise 2 (robots.txt checker) |
3h |
| 6 | Book ch. 12-13 (grep project, closures/iterators); The Rust CLI Book ch. 1-3; Exercise 3 (title extractor) | 3h |
| 7 | Finish Rustlings (smart_pointers -> conversions); 100 Exercises sections 1-4; review notes |
2-3h |
Week 2 — idioms, errors, tests, benchmarks (~20h)
| Day | Work | Time |
|---|---|---|
| 8 | 100 Exercises sections 5-9; refactor Exercise 2/3 errors to thiserror + anyhow; Exercise 4 (link deduper) | 3h |
| 9 | Command-Line Rust projects 1-3 (echo, cat, head); Exercise 5 (NDJSON to CSV) |
3h |
| 10 | Crust of Rust: Lifetime Annotations + Iterators; Exercise 6 (frontier scheduler) | 3h |
| 11 | Testing day: unit + integration + first proptest; install/run cargo-nextest; Exercise 7 (local fixture fetch) | 3h |
| 12 | Benchmarking day: criterion + hyperfine on Exercise 1; Exercise 8 (optimize + document) | 3h |
| 13 | Clippy/rustfmt cleanup, README + error-message polish, push crawlkit to GitHub |
2h |
| 14 | Buffer day: Command-Line Rust #4-5 or Comprehensive Rust days 1-2 | 3h |
Phase 1 exit criteria (do not move on until all are true)
- You can split a crate into
lib.rs+main.rsand organize modules without fighting the compiler. - You explain move/copy/borrow rules and fix borrow errors without cloning everything.
- You design a
thiserrorenum, propagate with?, and add.context()at binary boundaries. - Unit + integration + at least one proptest pass;
cargo clippy -- -D warningsis clean. - A criterion benchmark has a saved baseline and a hyperfine run compares CLI before/after.
- One public GitHub repo with tests, README, and honest commit history.
Beyond weeks 1-2 (keep it time-boxed)
- Weeks 3-4 (solidify, ~15h/week): finish Command-Line Rust; Effective Rust; Comprehensive Rust; Crust of Rust (Iterators, Smart Pointers, Channels); skim mini-redis. Then start Phase 2 (async Rust/Tokio) — do not spend more than 4 weeks on pure fundamentals.
- Months 2-3 (as phases require): Rust for Rustaceans; Rust Atomics and Locks (before distributed crawler); The Rust Performance Book; Zero To Production (Phase 7 reference).
Hands-on Exercises
Put all of these in one cargo workspace named crawlkit, one crate per tool, each with a README and tests. None require network access; the real fetching starts in Phase 3.
1. unorm — URL normalizer CLI
- Reads URLs from argv or stdin (one per line); lowercases scheme/host, removes default ports (
:80/:443), resolves./..segments, drops fragments;--jsonemits{original, normalized}. - Acceptance: invalid URLs print to stderr and exit non-zero; unit tests cover IPv6 hosts, IDN/percent-encoding, query preservation; proptest proves
normalize(normalize(u)) == normalize(u); never panics. Crate:url(https://github.com/servo/rust-url).
2. robots-check — robots.txt policy checker
- Parse
User-agent,Allow,Disallow,Crawl-delaygroups; support*wildcards and$anchors; longest-match wins; unknown agent falls back to*; no rules = allow. - Acceptance: fixture suite with mixed-case directives, comments, empty groups, duplicate agents;
--user-agentand file args; proptest: any random rule set yields deterministic decisions and never panics. Implement the parser yourself; compare against therobotstxtcrate (https://docs.rs/robotstxt) in tests.
3. title — HTML title/meta extractor
- Reads HTML from file/stdin; outputs JSON
{title, description, link_count}; collapses whitespace, decodes entities, handles uppercase tags and malformed documents. - Acceptance: 10 fixtures including truncated HTML, missing
<title>, and nested comments; integration tests invoke the binary; no panic on arbitrary bytes. Crates:scraper(https://docs.rs/scraper),serde_json(https://github.com/serde-rs/json).
4. links — link extractor + deduper
- Extract
<a href>, resolve relative URLs against--base, dropjavascript:,mailto:, and fragment-only links, dedupe by normalized URL, sort, print one per line. - Acceptance: fixture with protocol-relative (
//host/path) and entity-encoded URLs; proptest: output has zero duplicates and every line parses as a URL; handles Unicode paths unchanged.
5. crawl-export — streaming NDJSON to CSV
- Reads crawl records
{url, status, depth, fetched_at}(one JSON per line), filtersstatus == 200, writes CSV with a stable header; malformed lines are reported to stderr with line numbers and skipped. - Acceptance: streams 1M synthetic lines with RSS < 100 MB (measure with
/usr/bin/time -lor hyperfine); unit tests for CSV escaping (quotes, commas, newlines);BufReader/BufWriteronly.
6. frontier — in-memory polite crawl scheduler
- Library crate (no network):
Frontier::push(url, depth)andFrontier::next(); respects per-host minimum delay and max in-flight count; BFS by depth; dedupes by normalized URL; injectable clock for tests. - Acceptance: deterministic unit tests prove per-host spacing and BFS order; criterion benchmark scheduling 1M URLs; zero
unsafe; document the design in the README as if reviewing a crawler PR.
7. fetch-fixture — minimal HTTP client against a local test server
- In tests, build a
std::net::TcpListenerfixture server serving canned responses; the client sendsGETwithUser-Agent,Host,Connection: close, parses status line andContent-Length, and enforcesset_read_timeout. - Acceptance: offline tests for 200, 404, slow-server timeout, and connection reset; no external HTTP crates yet. This is the warm-up for Phase 3's reqwest/HTTP work.
8. bench-normalize — performance pass on Exercise 1
- Add a criterion bench over 100k URLs; save baseline; make one measured optimization (fewer allocations,
Cow, faster percent-decoding) and document the delta. - Acceptance:
cargo bench -- --save-baseline mainworks in CI mode; README shows throughput and p-value from criterion plus hyperfine before/after wall times; no regression versus baseline.
Common Pitfalls / What to Skip
- Do not chase lifetime mastery in week 1. Learn elision and when annotations are required; the rest comes from reps.
rustc --explain E0502is a built-in teacher. - Clones to get unstuck are fine; clones in hot paths are not. Do not reach for
Rc<RefCell<>>to make Rust feel like Python. - Never
unwrap()I/O, network, or parser input in production code. A panic in a crawl worker silently drops work. Reserveunwrapfor tests. - Do not put
anyhowin library APIs. Callers cannot match variants; use thiserror in libraries, anyhow in binaries. - Skip macros,
unsafe, pinning, andSend/Syncinternals for now. They belong to weeks 6+, and crawler work rarely needs hand-writtenunsafe. - Do not learn async simultaneously with ownership. Phase 2 is dedicated to Tokio for a reason.
- Do not buy a bootcamp before finishing the free path. Reviews of the ~$500 Let's Get Rusty bootcamp are mixed; free material covers fundamentals.
- Distrust pre-2021 tutorials. clap v2, criterion 0.4,
lazy_static, pre-?error handling, and pre-edition-2024 idioms will teach you bad habits. - Do not benchmark with hand-rolled
Instantloops or single runs. Use criterion/divan withblack_box, and hyperfine for whole binaries. - Do not spread across five books. One core path plus lookups; more input is not more skill.
- Do not defer fmt/clippy/tests. They are part of the language culture and the interview signal.
- Skip for now: embedded, WASM/frontend, blockchain, game dev, macro-heavy crates, tokio before Phase 2, and "100-day" content-mill courses.
Open-Source Repos to Read
Read actively: start at main.rs, run cargo doc --open, use cargo expand on confusing derives, trace one feature end-to-end, and run the test suite.
| Repo | Link | Size / Level | Best for |
|---|---|---|---|
| tokio-rs/mini-redis | https://github.com/tokio-rs/mini-redis | Small, heavily commented | Idiomatic error handling and async crate layout; read when Phase 2 starts |
| sharkdp/fd | https://github.com/sharkdp/fd | Small | A complete readable CLI: clap, tests, cross-platform details; ideal first real codebase |
| sharkdp/hyperfine | https://github.com/sharkdp/hyperfine | Small/Medium | How to build statistically sound benchmarking into a CLI; directly relevant to crawl throughput work |
| sharkdp/bat | https://github.com/sharkdp/bat | Medium | Asset handling, config, error messages, integration tests |
| BurntSushi/ripgrep | https://github.com/BurntSushi/ripgrep | Large | CLI architecture, argument parsing, performance discipline; start in crates/core/main.rs and args.rs |
| lycheeverse/lychee | https://github.com/lycheeverse/lychee | Medium | Async link checker: concurrency limits, HTML parsing, per-host politeness; closest to Phases 3-5 |
| spider-rs/spider | https://github.com/spider-rs/spider | Medium/Large | Production Rust web crawler; read after Phase 4 to compare designs and steal ideas |
| BurntSushi/xsv | https://github.com/BurntSushi/xsv | Medium (archived) | Streaming CSV with iterators and zero-copy parsing; inspiration for Exercise 5 |
Async Rust & Tokio for Crawlers
Exa's crawler team keeps 100M+ pages/day moving through a Rust/C++ stack, and the difference between "a script that fetches pages" and "a crawler that runs for months" is almost entirely async runtime engineering: bounded concurrency, backpressure, per-host politeness, timeouts, cancellation, and observability. This phase turns Tokio from a runtime you call into a system you understand, so that the later phases (polite crawling, JS fallback, distributed scheduling) have something solid to build on. Everything below is chosen for one target artifact: a concurrent fetcher that can pull 10,000 URLs without falling over.
Start Here (core path)
Work these in order. Do not start at the bottom.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| The Rust Book, Ch. 17 (Async/Await, Futures, Streams) | https://doc.rust-lang.org/book/ch17-00-async-await.html | Book chapter | Free | Official, up-to-date async vocabulary before any Tokio specifics |
| Asynchronous Programming in Rust (official async book) | https://rust-lang.github.io/async-book/ | Online book (WIP rewrite) | Free | End-to-end async concepts; note the rewrite banner and read Part 1 first |
| Tokio Tutorial (the "mini-redis" tutorial) | https://tokio.rs/tokio/tutorial | Online tutorial | Free | The canonical, maintained introduction to Tokio; finish all 10 chapters |
| tokio-rs/mini-redis | https://github.com/tokio-rs/mini-redis | Code repo | Free | Working code for Semaphore limiting, graceful shutdown, tracing, std Mutex in async |
| Crust of Rust: async/await (Jon Gjengset) | https://www.youtube.com/watch?v=ThjvMReOXYM | Video (2h34m) | Free | How async/await really works; build a future by hand |
| Tokio: Select + Streams chapters | https://tokio.rs/tokio/tutorial/select and https://tokio.rs/tokio/tutorial/streams | Tutorial chapters | Free | The two primitives every crawler uses for racing and fan-out |
| Build: 10k-URL fetcher (Exercise 2 below) | n/a | Project | Free | Converts everything above into one portfolio-grade artifact |
Books & Long-Form Guides
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Asynchronous Programming in Rust | https://rust-lang.github.io/async-book/ | Online book | Free | The official async book; Part 1 is the best linear path, advanced chapters are stand-alone |
| The Rust Book, Ch. 17 | https://doc.rust-lang.org/book/ch17-00-async-await.html | Online book | Free | Precise definitions of concurrency vs parallelism, futures, streams |
| Async Rust (Flitton & Morton, O'Reilly, 2024) | https://www.oreilly.com/library/view/async-rust/9781098149086/ | Book | Paid (~$50) | Deep dive into executors, wakers, pinning, runtime internals; opinionated, skim the coroutine chapters |
| Rust for Rustaceans (Jon Gjengset) | https://rust-for-rustaceans.com/ | Book | Paid (~$40) | Async chapter + type-system habits that make tokio::spawn errors stop hurting |
| Zero to Production in Rust (Luca Palmieri) | https://www.zero2prod.com/ | Book | Paid (~£35) | Structured logs, tracing, error handling, and testing patterns you'll reuse in the crawler's ops layer |
| Rust Atomics and Locks (Mara Bos) | https://marabos.nl/atomics/ | Online book | Free | Background for Arc/Mutex/Send/Sync; useful later for the distributed scheduler, optional now |
| Pin and suffering (fasterthanlime) | https://fasterthanli.me/articles/pin-and-suffering | Article (34 min) | Free | The best step-by-step mental model of Future, poll, Waker, Pin, Unpin; read after the Tokio tutorial |
| Async: What is blocking? (Alice Ryhl) | https://ryhl.io/blog/async-what-is-blocking/ | Article (15 min) | Free | The one rule that prevents 90% of async production incidents: no long stretches without .await |
| Actors with Tokio (Alice Ryhl) | https://ryhl.io/blog/actors-with-tokio/ | Article (20 min) | Free | Bounded channels, backpressure, and graceful shutdown for task-per-component designs |
| Request coalescing in async Rust (fasterthanlime) | https://fasterthanli.me/articles/request-coalescing-in-async-rust | Article (53 min) | Free | Advanced: sharing in-flight work across tasks with tracing; useful for dedupe in a crawler |
| Reducing tail latencies with automatic cooperative task yielding | https://tokio.rs/blog/2020-04-preemption | Blog post | Free | Why Tokio forces yields every ~128 ops and how that affects p99 latency at 10k concurrency |
Video Courses & Talks
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Crust of Rust: async/await (Jon Gjengset) | https://www.youtube.com/watch?v=ThjvMReOXYM | Video (2h34m) | Free | Re-implementing futures until pinning and polling click |
| Decrusting the tokio crate (Jon Gjengset) | https://www.youtube.com/watch?v=o2ob8zkeq2s | Video (3h32m) | Free | Every public Tokio API: blocking, cancellation, spawning, sync primitives, runtime internals |
| Decrusting the tracing crate (Jon Gjengset) | https://www.youtube.com/watch?v=21rtHinFA40 | Video (2h57m) | Free | How spans propagate across tasks; the foundation of crawler observability |
| The What and How of Futures and async/await in Rust (Jon Gjengset) | https://www.youtube.com/watch?v=9_3krAQtD2k | Video (2h) | Free | Older but still the clearest "what is a future, actually" lecture |
| Catching up with async Rust (fasterthanlime) | https://www.youtube.com/watch?v=bnmln9HtqEI | Video (18 min) | Free | 2025 state of the async ecosystem in one sitting; great for context before interviews |
| TokioConf 2026 (full playlist) | https://www.youtube.com/playlist?list=PLgVIJ9TpEgOmHj0ADDpf-qGckYEJs4QCE | Conference talks | Free | Production Tokio war stories from the inaugural Tokio conference (April 2026) |
| TokioConf 2026 recordings announcement | https://tokio.rs/blog/2026-05-29-tokioconf-2026-videos | Blog + links | Free | Index of the talks with context, plus TokioConf 2027 dates |
| What it takes to keep Tokio running (Alice Ryhl) | https://www.youtube.com/watch?v=Lc3aw_NIOt0 | Talk (~40 min) | Free | Maintainer perspective on runtime evolution and what production users get wrong |
| Solving distributed systems challenges in Rust (Jon Gjengset) | https://www.youtube.com/watch?v=gboGyccRVXI | Video series | Free | Stretch goal before Phase 6: RPC, gossip, and failure handling in async Rust |
Official Docs & Examples
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| tokio crate docs | https://docs.rs/tokio/latest/tokio/ | API docs | Free | Canonical reference; read the module-level docs, not just signatures |
| Tokio "Async in depth" chapter | https://tokio.rs/tokio/tutorial/async | Tutorial | Free | Futures, executors, and why tasks are green threads |
| Tokio Graceful Shutdown topic | https://tokio.rs/tokio/topics/shutdown | Guide | Free | CancellationToken + TaskTracker patterns used by real services |
| Tokio Getting Started with Tracing | https://tokio.rs/tokio/topics/tracing | Guide | Free | tracing/tracing-subscriber setup with #[instrument] |
| Tokio Next Steps with Tracing (tokio-console) | https://tokio.rs/tokio/topics/tracing-next-steps | Guide | Free | Enabling console-subscriber and reading the task/resource views |
| Tokio Bridging with sync code | https://tokio.rs/tokio/topics/bridging | Guide | Free | spawn_blocking vs rayon vs dedicated threads; critical when HTML parsing gets expensive |
| Tokio Unit Testing | https://tokio.rs/tokio/topics/testing | Guide | Free | #[tokio::test] and paused-time testing for retry/backoff logic |
| tokio-rs/tokio examples directory | https://github.com/tokio-rs/tokio/tree/master/examples | Code | Free | tinyhttp, proxy, chat, graceful-shutdown, custom-executor: small and readable |
| tokio-util docs (CancellationToken, TaskTracker) | https://docs.rs/tokio-util/latest/tokio_util/ | API docs | Free | The two utilities behind every clean shutdown story |
| futures crate docs (StreamExt) | https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html | API docs | Free | buffer_unordered, buffered, for_each_concurrent: the crawler fan-out toolkit |
| tokio-stream docs | https://docs.rs/tokio-stream/latest/tokio_stream/ | API docs | Free | Stream wrappers over Tokio channels/receivers |
| std::future::Future docs | https://doc.rust-lang.org/std/future/trait.Future.html | API docs | Free | The exact poll contract you must honor if you implement Future manually |
| tokio-rs/console | https://github.com/tokio-rs/console | Code + docs | Free | Source of console-subscriber and the tokio-console debugger |
| tokio-rs/tracing | https://github.com/tokio-rs/tracing | Code + docs | Free | tracing, tracing-subscriber, and the OpenTelemetry bridge |
Concurrency Patterns for Crawlers
The core question is always: how many requests are in flight, and what happens when one of them hangs?
| Pattern | Link | Format | Cost | Best for |
|---|---|---|---|---|
Semaphore (bounded permits) |
https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html | API docs | Free | Hard cap on in-flight requests; works with both spawn and streams |
StreamExt::buffer_unordered |
https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.buffer_unordered | API docs | Free | Cleanest bounded fan-out when you have an iterator of URLs |
JoinSet (structured concurrency) |
https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html | API docs | Free | Spawn-and-forget with accountability; dropping the set aborts its tasks |
mpsc::channel (bounded worker pool) |
https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.channel.html | API docs | Free | Explicit backpressure: queue depth is a visible, tunable number |
DashMap (sharded concurrent map) |
https://docs.rs/dashmap/latest/dashmap/ | API docs | Free | Per-host state (semaphores, retry budgets, delays) without a global lock |
governor (rate limiting) |
https://docs.rs/governor/latest/governor/ | API docs | Free | Token-bucket rate limits per key; alternative to hand-rolled per-host delays |
reqwest (HTTP client) |
https://docs.rs/reqwest/latest/reqwest/ | API docs | Free | Reuse one Client (connection pool); set pool_max_idle_per_host >= your concurrency |
1. Semaphore + JoinSet (recommended default for the 10k fetcher). Acquire the permit before spawning so the spawning loop itself applies backpressure.
use std::sync::Arc;
use tokio::{sync::Semaphore, task::JoinSet};
let sem = Arc::new(Semaphore::new(64));
let mut set = JoinSet::new();
for url in urls {
let permit = sem.clone().acquire_owned().await.unwrap();
set.spawn(async move {
let _permit = permit;
fetch(url).await
});
}
while let Some(res) = set.join_next().await {
match res {
Ok(outcome) => stats.record(outcome),
Err(join_err) => stats.record_panic(join_err),
}
}
2. buffer_unordered (least code, no task handles). Concurrency is the buffer size; results arrive as they complete. Beware: the whole stream must be 'static or you must .collect() before locals drop.
use futures::{stream, StreamExt};
let outcomes: Vec<Outcome> = stream::iter(urls)
.map(|url| async move { fetch(url).await })
.buffer_unordered(64)
.collect()
.await;
3. Bounded worker pool (explicit backpressure). Best when the URL source is unbounded (a frontier queue). mpsc::channel(N) blocks the producer when the queue is full, which is what you want.
let (tx, rx) = tokio::sync::mpsc::channel::<Url>(1_000);
let rx = Arc::new(tokio::sync::Mutex::new(rx));
let mut workers = JoinSet::new();
for _ in 0..64 {
let rx = rx.clone();
workers.spawn(async move {
loop {
let next = { rx.lock().await.recv().await };
match next {
Some(url) => process(url).await,
None => break,
}
}
});
}
4. Per-host politeness. One semaphore per host + a fixed delay gives "1 in-flight per host, >=200ms apart" without a global bottleneck.
let per_host: DashMap<String, Arc<Semaphore>> = DashMap::new();
let gate = per_host
.entry(host.clone())
.or_insert_with(|| Arc::new(Semaphore::new(1)))
.clone();
let _permit = gate.acquire_owned().await.unwrap();
tokio::time::sleep(politeness_delay).await;
fetch(url).await;
Choosing between them: Semaphore + JoinSet when you need per-task cancellation and metrics; buffer_unordered when you already have a finite URL list and want minimal code; bounded mpsc when the frontier is endless; FuturesUnordered (https://docs.rs/futures/latest/futures/stream/struct.FuturesUnordered.html) only when you need a set of unspawned futures for non-'static borrows.
Timeouts, Retries, Cancellation, Graceful Shutdown
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
tokio::time::timeout |
https://docs.rs/tokio/latest/tokio/time/fn.timeout.html | API docs | Free | Hard deadline around any single future; first line of defense |
tokio::time::sleep |
https://docs.rs/tokio/latest/tokio/time/fn.sleep.html | API docs | Free | Backoff delays that never block the runtime |
reqwest timeouts (connect_timeout, timeout) |
https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html | API docs | Free | Separate connect vs total deadlines; prevents socket pile-ups |
backoff crate |
https://docs.rs/backoff/latest/backoff/ | API docs | Free | Standard exponential backoff with jitter and retry predicates |
tokio-retry |
https://docs.rs/tokio-retry/latest/tokio_retry/ | API docs | Free | Small, Tokio-native retry combinator with strategies |
tower (timeout/retry/rate-limit middleware) |
https://docs.rs/tower/latest/tower/ | API docs | Free | Composable middleware if you later build a fetch "service"; overkill for one script |
CancellationToken |
https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html | API docs | Free | One signal fans out to every task (child tokens supported) |
TaskTracker |
https://docs.rs/tokio-util/latest/tokio_util/task/task_tracker/struct.TaskTracker.html | API docs | Free | Wait for all spawned work to drain after cancel; no leaked tasks |
tokio::signal::ctrl_c |
https://docs.rs/tokio/latest/tokio/signal/fn.ctrl_c.html | API docs | Free | Wire SIGINT to your cancellation token |
tokio::select! |
https://docs.rs/tokio/latest/tokio/macro.select.html | API docs | Free | Race futures; read the cancellation-safety notes for every branch you use |
Budget every network operation. A crawler without deadlines slowly fills up with hung sockets.
async fn fetch_with_deadline(client: &reqwest::Client, url: &str) -> Result<Bytes, Error> {
tokio::time::timeout(Duration::from_secs(10), async {
let resp = client.get(url).send().await?;
let status = resp.status();
if status.as_u16() == 429 || status.is_server_error() {
return Err(Error::Retryable(status));
}
if status.is_client_error() {
return Err(Error::Permanent(status));
}
Ok(resp.bytes().await?)
})
.await?
}
Retry only what deserves it, with jitter. 429/408/5xx/connect errors are retryable; 404/403 are not. Jitter prevents synchronized retry storms across 10k tasks.
let mut attempt = 0u32;
loop {
match fetch_with_deadline(&client, url).await {
Ok(bytes) => break Ok(bytes),
Err(e) if e.is_retryable() && attempt < 3 => {
let base = Duration::from_millis(200 * 2u32.pow(attempt) as u64);
let jitter = Duration::from_millis(rand::random::<u64>() % 100);
tokio::time::sleep(base + jitter).await;
attempt += 1;
}
Err(e) => break Err(e),
}
}
Graceful shutdown = stop accepting -> signal -> drain. The Tokio shutdown guide (above) is the authoritative walkthrough; this is the shape:
let token = CancellationToken::new();
let tracker = TaskTracker::new();
for url in urls {
let token = token.clone();
tracker.spawn(async move {
tokio::select! {
_ = token.cancelled() => { /* stop early */ }
result = fetch(url) => stats.record(result),
}
});
}
tracker.close();
tokio::select! {
_ = tokio::signal::ctrl_c() => {
token.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), tracker.wait()).await;
}
_ = tracker.wait() => {}
}
stats.print_summary();
Cancellation-safety warning: dropping a future cancels it at its current .await. That is fine mid-GET (the request is aborted), but dangerous if you're mid-write to a checkpoint file. select! picks a random ready branch unless you add biased;; only use cancel-safe operations inside select! branches (the docs label each one).
Observability (tracing, tokio-console)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
tracing crate |
https://docs.rs/tracing/latest/tracing/ | API docs | Free | Structured spans/events; #[instrument] on every fetch function |
tracing-subscriber |
https://docs.rs/tracing-subscriber/latest/tracing_subscriber/ | API docs | Free | EnvFilter + fmt layer; RUST_LOG=info,crawler=debug control |
| Tokio tracing guide | https://tokio.rs/tokio/topics/tracing | Guide | Free | Shortest path from zero to spans with fields |
console-subscriber |
https://docs.rs/console-subscriber/latest/console_subscriber/ | API docs | Free | In-process collector for tokio-console telemetry |
| tokio-console | https://github.com/tokio-rs/console | CLI tool | Free | htop for tasks: busy time, poll counts, waker counts, self-wake warnings |
| Decrusting the tracing crate | https://www.youtube.com/watch?v=21rtHinFA40 | Video (2h57m) | Free | Understand span propagation before you debug a distributed crawler |
tracing-opentelemetry |
https://docs.rs/tracing-opentelemetry/latest/tracing_opentelemetry/ | API docs | Free | Export traces to Jaeger/Honeycomb when the crawler becomes a fleet (Phase 6) |
Instrument the fetch, not the loop. Attach host/URL/status/bytes as span fields so you can filter and aggregate; the span name becomes the unit in tokio-console and trace viewers.
use tracing::{info, instrument};
#[instrument(skip(client), fields(host = %url.host_str().unwrap_or("unknown")))]
async fn fetch(client: &reqwest::Client, url: &url::Url) -> anyhow::Result<usize> {
let resp = client.get(url.clone()).send().await?;
let status = resp.status();
let bytes = resp.bytes().await?.len();
info!(status = status.as_u16(), bytes, "fetched");
Ok(bytes)
}
tokio-console setup (two lines + one build flag):
# Cargo.toml
tokio = { version = "1", features = ["full", "tracing"] }
console-subscriber = "0.4"
fn main() {
console_subscriber::init(); // replaces tracing_subscriber::fmt::init() in dev
// ...
}
Build/run with RUSTFLAGS="--cfg tokio_unstable" cargo run and connect with cargo install --locked tokio-console && tokio-console. The console flags self-wakes, tasks that never yield, lost wakers, and large futures: exactly the failure modes of a 10k-request crawler. Keep it as a dev-only feature.
Communities
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Tokio Discord | https://discord.gg/tokio | Chat | Free | Fastest answers on Tokio behavior; maintainers are present |
| Tokio GitHub Discussions | https://github.com/tokio-rs/tokio/discussions | Forum | Free | Design questions with searchable history |
| users.rust-lang.org | https://users.rust-lang.org/ | Forum | Free | Language-level async questions (borrows, Send, lifetimes) |
| r/rust | https://www.reddit.com/r/rust/ | Forum | Free | Ecosystem news, crate recommendations, production postmortems |
| Rust Zulip (async working group) | https://rust-lang.zulipchat.com/ | Chat | Free | Follow async language development (#t-async) |
| Rust Async Working Group | https://rust-lang.github.io/wg-async/ | Site | Free | Roadmaps and design notes for where async Rust is heading |
Recommended Order (weeks 3-4, with time estimates)
Assumes ~7-8 focused hours per week on this phase; adjust to your schedule but keep the dependency order.
Week 3 — Fundamentals and the runtime (~14h)
| Day | Work | Time |
|---|---|---|
| 1 | Rust Book Ch. 17 + official async book Part 1, ch. 1-3 | 2h |
| 2 | Tokio tutorial: setup, hello-tokio, spawning, shared state | 2h |
| 3 | Tokio tutorial: channels, I/O, framing | 2h |
| 4 | Tokio tutorial: async in depth + select | 2h |
| 5 | Tokio tutorial: streams; clone mini-redis and read server.rs, shutdown.rs, db.rs |
2.5h |
| 6 | Crust of Rust: async/await (watch at 1.5x, pause at poll/pin sections) | 2.5h |
| 7 | Exercise 1 (baseline) and Exercise 2 (10k fetcher v1) | 2h |
Week 4 — Patterns, production hardening, and proof (~14h)
| Day | Work | Time |
|---|---|---|
| 8 | Pin and suffering (read once, no rabbit holes) + Tokio timeout/select docs | 2h |
| 9 | Decrusting tokio (watch selected chapters: blocking, cancellation, sync) + Exercise 3 | 2.5h |
| 10 | Timeouts/retries: backoff crate docs + Exercise 4 | 2h |
| 11 | Graceful shutdown topic + Exercise 5 | 2h |
| 12 | Tracing guide + Decrusting tracing (first hour) + Exercise 7 | 2.5h |
| 13 | Exercise 6 (per-host politeness) + Exercise 8 (manual future) | 2h |
| 14 | Write README/benchmark numbers, run tokio-console screenshots, publish repo | 1.5h |
If you only have one week, do days 1-5 and Exercises 1-2, then treat the rest as week 4 of Phase 2 continued in parallel with Phase 3.
Hands-on Exercises
Build one crate (crawler-lab) with a mock-server binary (e.g. axum/wiremock) that can inject delays, 429s, 500s, and hangs, and a fetcher binary that evolves across exercises.
1. Sequential baseline. Fetch 200 URLs sequentially; print total wall time and per-request latency.
- Acceptance: no panics; report shows p50/p95/p99 and total time; mock server logs 200 requests.
- Why: you need a number to beat for every later claim.
2. Concurrent 10k-URL fetcher with a hard concurrency cap.
- Acceptance: processes 10,000 URLs against the mock server; a shared
AtomicUsizehigh-water mark never exceedsMAX_CONCURRENCY(start at 64, then 512); handles 404, connection refused, malformed URL, and DNS failure without exiting; prints success/error/timeout counts, p50/p95/p99 latency, total bytes, and requests/sec; p95 at least 10x better than Exercise 1. - Uses:
Semaphore+JoinSet, one sharedreqwest::Client,timeoutper request.
3. Bounded worker pool with visible backpressure.
- Acceptance: producer feeds 1,000,000 synthetic URLs through
mpsc::channel(1_000); queue length never exceeds 1,000 (assert via a counter); RSS stays under 200 MB; all workers exit cleanly aftertx.drop(); worker JoinSet is fully drained. - Why: this is the shape of Phase 5's frontier queue.
4. Timeouts, retries, and error classification.
- Acceptance: a 404 produces zero retries; a server that fails twice then succeeds produces exactly two retries and one success; every request completes within a global deadline; delays show jitter (no two retries in a 100ms bucket share the same delay); a 429 with
Retry-After: 1waits >=1s. - Uses:
tokio::time::timeout, exponential backoff + jitter, retryable/permanent error enum.
5. Graceful shutdown on Ctrl+C.
- Acceptance: SIGINT mid-run stops new requests, in-flight requests finish (or time out) within 5s, a summary prints after draining, process exits 0; a second Ctrl+C force-exits within 200ms; tokio-console shows zero live tasks after the summary.
- Uses:
CancellationToken,TaskTracker,tokio::signal::ctrl_c,select!.
6. Per-host politeness.
- Acceptance: across 100 hosts the fetcher issues at most one in-flight request per host and timestamps per host are >=200ms apart (parse a request log to prove it); global concurrency still respects the 64 cap; one artificially slow host (10s delay) does not delay other hosts; host state map doesn't grow unboundedly (evict idle hosts or accept a documented bound).
- Uses:
DashMap<String, Arc<Semaphore>>, per-host delay, global semaphore.
7. Observability drill with tokio-console.
- Acceptance: every fetch emits a span with
host,status,bytes, and a per-task ID;console_subscriber::init()runs underRUSTFLAGS="--cfg tokio_unstable"; deliberately injectstd::thread::sleep(2s)and screenshot the blocked task in tokio-console; replace it withtokio::time::sleepand show busy-time drop; commit areport.mdwith both observations. - Uses:
#[instrument],tracing-subscriberEnvFilter, tokio-console.
8. Manual Future + mini executor (mental-model capstone).
- Acceptance: implement a
Futurethat returnsReadyon its third poll; write a ~30-line executor loop that polls aVec<Box<dyn Future>>with a no-op waker until all complete; a unit test asserts exactly 3 polls; a written note (<=200 words) explains what would break withoutPinand whytokio::spawnrequiresSend + 'static; finally, rewrite the same logic withasync+ Tokio and show identical output. - Why: interviews for systems roles at companies like Exa go here.
Stretch (optional, only if week 4 goes fast): rewrite Exercise 2 using buffer_unordered and benchmark it against the JoinSet version; write a one-page comparison of throughput, memory, and error-handling ergonomics.
Common Pitfalls / What to Skip
Pitfalls that will actually bite you
- Blocking the executor.
std::thread::sleep, blocking file I/O, and heavy HTML parsing insideasync fnstall every task on that worker. Rule: no more than ~10-100 microseconds between.awaits; move long sync work tospawn_blockingorrayon. - Holding a
std::sync::MutexGuardacross.await. Deadlocks and non-Sendfutures. Either usetokio::sync::Mutex(slower) or keep the critical section short and drop the guard before awaiting. mini-redis shows the right pattern. - Unbounded concurrency. 10k
tokio::spawns with no semaphore, or an unbounded channel, turns into file-descriptor exhaustion and OOM. Everything gets a cap: concurrent requests, queue depth, per-host rate, retries. - No per-request deadline. One hung TCP connection can pin a semaphore permit forever. Every network call gets
tokio::time::timeout(and reqwest getsconnect_timeout). - New
reqwest::Clientper request. Kills connection pooling and TLS reuse. Create once, clone (it's anArcinside). - Fighting
'staticontokio::spawn. If a task borrows locals, useJoinSet/futures::future::join_allwithout spawning, or move owned data in. Don't reach forunsafeorArc<Mutex<>>first. - Ignoring task failures.
JoinHandlereturnsResult<T, JoinError>; panics don't crash the process, they vanish into that error. Always match on it. - Synchronized retries (thundering herd). Same backoff for 10k tasks = a second self-inflicted denial of service. Add jitter and cap attempts.
select!cancellation surprises. Dropping a future can cancel an in-flight operation; know which branches are cancel-safe before relying on loop behavior.- Retrying non-idempotent mistakes. Don't retry 4xx (except 408/429); don't retry POSTs that may have succeeded. Politeness and correctness are part of crawl quality.
What to skip during weeks 3-4
- async-std / smol deep dives. Tokio is the ecosystem and the hiring market; a one-page comparison is enough.
- Actor frameworks (Actix, ractor). Learn bounded channels and task-per-component first; frameworks hide the mechanics you're being hired to know.
- Manual runtime internals beyond Exercise 8 (
epoll/io_uring,pin-projectinternals, writing a reactor). Good Phase 6+ reading, not a hiring signal right now. #[async_trait]everywhere. Async fn in traits has been stable since Rust 1.75; only pull inasync-traitwhen you needdyncompatibility.- Blocking HTTP clients, thread-per-request designs, or Python-style async porting. This phase is about the Tokio way.
- Premature distributed design. Don't build a scheduler in Phase 2; Exercises 2-6 are the foundation Phase 6 will generalize.
Open-Source Repos to Read
Read with a question in hand ("how is the permit released?"), not cover to cover.
| Repo | Link | Format | Cost | Best for |
|---|---|---|---|---|
| tokio-rs/mini-redis | https://github.com/tokio-rs/mini-redis | Rust code | Free | The reference implementation for Semaphore limiting, shutdown, tracing, and std Mutex in async |
| tokio-rs/console | https://github.com/tokio-rs/console | Rust code | Free | How runtime instrumentation is layered onto tracing; read console-subscriber first |
| tokio-rs/tokio (examples/) | https://github.com/tokio-rs/tokio/tree/master/examples | Rust code | Free | tinyhttp, proxy, chat, graceful-shutdown: compact and idiomatic |
| tokio-rs/tokio (source) | https://github.com/tokio-rs/tokio | Rust code | Free | Start with tokio/src/sync/semaphore.rs and tokio/src/task/join_set.rs |
| tokio-rs/tokio-util | https://github.com/tokio-rs/tokio | Rust code | Free | CancellationToken and TaskTracker implementations are short and instructive |
| tokio-rs/tracing | https://github.com/tokio-rs/tracing | Rust code | Free | Spans, subscribers, and layers; the model behind all crawler telemetry |
| rust-lang/futures-rs | https://github.com/rust-lang/futures-rs | Rust code | Free | FuturesUnordered and StreamExt internals when you outgrow the tutorials |
| spider-rs/spider | https://github.com/spider-rs/spider | Rust code | Free | Production crawling in Rust: concurrency, streaming, JS fallback, politeness; read after this phase |
| seanmonstar/reqwest | https://github.com/seanmonstar/reqwest | Rust code | Free | Connection pooling and timeout behavior you're relying on in the exercises |
| LukeMathWalker/zero-to-production | https://github.com/LukeMathWalker/zero-to-production | Rust code | Free | Observability and integration-test structure to copy into the crawler-lab repo |
HTTP-First Scraping in Rust
Most of the web at Exa's scale (100M+ pages/day) must be fetched and parsed without a browser — it is cheaper, faster, and parallelizes cleanly across machines. This phase gives you the production HTTP + HTML stack for that majority: reqwest for pooled, compressed, timeout-bounded fetching; scraper/dom_query for browser-grade CSS extraction; serde for typed records; and the url crate plus content hashing for crawler-grade URL and duplicate handling. Getting this layer excellent — retries, redirects, content-type filtering, canonicalization, dedup, fixture tests — is exactly the "extreme optimization" the Exa crawler role asks for, and it is the foundation for the polite-crawling and distributed phases that follow.
Versions and links verified September 2026. Stack snapshot: reqwest 0.13.5, scraper 0.27, dom_query 0.28 (rising alternative), serde 1.0.229, serde_json 1.0.151, url 2.5.8, lol_html 3.0.1, dom_smoothie 0.18, blake3 1.8.7, wiremock 0.6.5, texting_robots 0.2.2.
Start Here (core path)
If you only do five things, do these in order (roughly one sitting each):
- Cookbook: fetch a page with a configured
reqwest::Client— pooled, timed out, gzip+brotli, real User-Agent, non-2xx classified. Official docs and examples are the ground truth. - Parse it with
scraper—Html::parse_document, precompiledSelectors,ElementReftext/attr extraction. - Reshape it with
serde— typed structs,Optionfields,#[serde(rename_all = "camelCase")], JSONL output. - Normalize URLs with the
urlcrate —Url::parse+join, then your own policy layer (fragments, tracking params, trailing slash). - Test everything offline — fixture HTML with
include_str!, response mocking withwiremock, snapshot assertions.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| reqwest crate docs (0.13) | https://docs.rs/reqwest/latest/reqwest/ | Docs | Free | Exact builder methods, feature flags, examples |
reqwest ClientBuilder reference |
https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html | Docs | Free | Pooling, timeouts, gzip/brotli/zstd, redirects, cookies, retry policy |
| scraper crate docs | https://docs.rs/scraper/latest/scraper/ | Docs | Free | Html, Selector, ElementRef — the whole extraction API |
| serde: Using derive | https://serde.rs/derive.html | Docs | Free | Struct annotations you will use for every typed record |
| serde field attributes reference | https://serde.rs/field-attrs.html | Docs | Free | rename, default, flatten, skip_serializing_if |
url crate Url reference |
https://docs.rs/url/latest/url/struct.Url.html | Docs | Free | parse, join, query_pairs, set_* — WHATWG normalization built in |
| "Rust Web Scraping With reqwest and scraper" (Jul 2026) | https://www.scrapeless.com/en/blog/rust-web-scraping | Blog + code | Free | End-to-end walkthrough written against current 0.13/0.27 APIs |
| "Rust Web Scraping: the scraper crate" (Jul 2026) | https://webscraping.ai/blog/web-scraping-with-rust | Blog | Free | Real gotchas: Html is not Send, selector reuse, rustls trust roots |
| Tokio tutorial | https://tokio.rs/tokio/tutorial | Docs + code | Free | Async runtime fundamentals this phase depends on |
| Rust Async Book | https://rust-lang.github.io/async-book/ | Book | Free | Futures/streams model behind reqwest and buffer_unordered |
Core Crates (reqwest, scraper, serde, url, etc.)
The dependency set
# Cargo.toml — versions current as of Sep 2026
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "fs"] }
reqwest = { version = "0.13", features = ["gzip", "brotli", "zstd", "deflate", "cookies", "json", "stream"] }
scraper = "0.27"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2.5"
mime = "0.3"
blake3 = "1.8"
thiserror = "2"
anyhow = "1"
[dev-dependencies]
wiremock = "0.6"
insta = "1"
pretty_assertions = "1"
Notes that save hours:
- reqwest 0.13 defaults:
charset,http2,system-proxy, anddefault-tlsnow resolves to rustls (withrustls-platform-verifier). The old "works with curl, fails in Rust" corporate-TLS story is mostly gone; still verify against real hosts in containers. gzip/brotli/zstd/deflateenable automatic decompression: reqwest setsAccept-Encodingif you haven't and stripsContent-Encoding/Content-Lengthfrom decompressed responses.streamenablesResponse::bytes_stream()— required forlol_htmlstreaming later in this guide.serde_jsonandblake3are pulled in directly; don't re-export them from the HTTP layer.
| Crate | Link | Format | Cost | Best for |
|---|---|---|---|---|
| reqwest | https://docs.rs/reqwest/latest/reqwest/ | Library + docs | Free (MIT/Apache-2.0) | Async HTTP with pooling, gzip/brotli, cookies, redirects |
| scraper | https://docs.rs/scraper/latest/scraper/ | Library + docs | Free (MIT/Apache-2.0) | CSS-selector extraction on Servo's html5ever parser |
| dom_query | https://docs.rs/dom_query/latest/dom_query/ | Library + docs | Free (MIT) | jQuery-style DOM API, :has/:contains pseudo-classes, markdown output |
| serde / serde_json | https://docs.rs/serde_json/latest/serde_json/ | Library + docs | Free (MIT/Apache-2.0) | Typed extraction and JSONL output |
| url | https://docs.rs/url/latest/url/ | Library + docs | Free (MIT/Apache-2.0) | WHATWG URL parsing, joining, normalized serialization |
| html5ever | https://docs.rs/html5ever/latest/html5ever/ | Library + docs | Free (MIT/Apache-2.0) | Only if you need raw tree-builder hooks; scraper wraps it |
| lol_html | https://docs.rs/lol_html/latest/lol_html/ | Library + docs | Free (BSD-3) | Streaming parse/extract/rewrite without buffering the body |
| dom_smoothie | https://docs.rs/dom_smoothie/latest/dom_smoothie/ | Library + docs | Free (MIT) | Readability-style main-content + metadata extraction |
| async-compression | https://docs.rs/async-compression/latest/async_compression/ | Library + docs | Free (MIT/Apache-2.0) | Manual gzip/brotli streams (custom pipelines; reqwest handles the normal case) |
| mime | https://docs.rs/mime/latest/mime/ | Library + docs | Free (MIT/Apache-2.0) | Parse Content-Type for filtering |
reqwest: the one Client factory you will reuse everywhere
Create one Client per process/worker and clone it (it is an Arc internally). A new Client per request destroys connection pooling and TLS session reuse.
use std::time::Duration;
use reqwest::{Client, redirect::Policy};
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT, ACCEPT, ACCEPT_LANGUAGE};
pub const BOT_UA: &str = "FerrisCrawler/0.1 (+https://example.com/bot; contact@example.com)";
pub fn build_client() -> anyhow::Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.1"));
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
let client = Client::builder()
.user_agent(BOT_UA)
.default_headers(headers)
.connect_timeout(Duration::from_secs(5))
.read_timeout(Duration::from_secs(15)) // per-read, catches stalled sockets
.timeout(Duration::from_secs(30)) // total deadline; default is NONE
.pool_idle_timeout(Duration::from_secs(90)) // default 90s
.pool_max_idle_per_host(16) // default usize::MAX — cap it
.redirect(Policy::limited(5)) // default 10; be explicit
.cookie_store(true) // cookies feature
.https_only(true)
.gzip(true).brotli(true).zstd(true).deflate(true)
.build()?;
Ok(client)
}
Facts to internalize:
- No default timeout. A crawler without
.timeout(...)eventually hangs forever. Always set both total and connect timeouts; addread_timeoutfor stalled bodies. - Redirects are followed up to 10 by default and silently. Use
Policy::limited(n)and record the final URL (response.url()), because the redirect target — not your input URL — is what actually got fetched (and it may be off-domain). - Cookies are off by default.
.cookie_store(true)needs thecookiesfeature; required for session/logged-in flows and for sites that bounce you through a cookie wall. - Content-type filtering happens after the response. Check before parsing:
use reqwest::header::CONTENT_TYPE;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {} for {}", resp.status(), url);
}
let ctype = resp.headers().get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok()).unwrap_or("");
if !ctype.starts_with("text/html") && !ctype.starts_with("application/xhtml+xml") {
anyhow::bail!("skipping non-HTML content-type: {ctype}");
}
let body = resp.bytes().await?; // bytes first, not text()
let html = String::from_utf8_lossy(&body); // or decode via charset/encoding_rs
text()vsbytes():text()decodes using theContent-Typecharset (thecharsetfeature is on by default). For crawling, preferbytes()so you can hash the exact payload, enforce size caps, and control charset recovery (encoding_rs+ meta sniffing) yourself.- Retries: reqwest 0.13 added a built-in, scoped retry policy (
ClientBuilder::retry, modulereqwest::retry) that retries protocol NACKs by default with a 20% request budget. For crawl-specific status handling (429/503 withRetry-After), either configure that classifier or layerreqwest-retry/backoffon top. See reqwest::retry. reqwest::blockingis a trap inside async code (it panics/deadlocks in a Tokio runtime). This phase is async end to end.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
reqwest::retry module |
https://docs.rs/reqwest/latest/reqwest/retry/index.html | Docs | Free | Built-in per-host retry budgets in 0.13 |
| reqwest-retry 0.9 | https://docs.rs/reqwest-retry/latest/reqwest_retry/ | Library + docs | Free | Middleware retries with ExponentialBackoff policies |
| backoff 0.4 | https://docs.rs/backoff/latest/backoff/ | Library + docs | Free | Minimal async exponential backoff with jitter in ~5 lines |
| reqwest GitHub | https://github.com/seanmonstar/reqwest | Repo | Free | Read pool/redirect/retry internals when behavior surprises you |
scraper: selectors, text, attributes
use scraper::{Html, Selector};
use std::sync::LazyLock;
static ITEM: LazyLock<Selector> = LazyLock::new(|| Selector::parse("article.product_pod").unwrap());
static LINK: LazyLock<Selector> = LazyLock::new(|| Selector::parse("h3 a").unwrap());
#[derive(Debug, serde::Serialize)]
struct Item { title: String, url: String, price_gbp: f64 }
fn parse_items(html: &str, base: &url::Url) -> Vec<Item> {
let doc = Html::parse_document(html);
doc.select(&ITEM).filter_map(|el| {
let a = el.select(&LINK).next()?;
let title = a.value().attr("title")?.to_string();
let href = a.value().attr("href")?;
let url = base.join(href).ok()?.to_string();
let price_gbp = el.text().collect::<String>()
.split('£').nth(1)?.trim().parse().ok()?;
Some(Item { title, url, price_gbp })
}).collect()
}
Rules:
Selector::parseis fallible — parse once intoLazyLock<Selector>statics or prepare them in a struct; never inside per-element loops.element.text()yields descendant text nodes;element.value().attr("href")gets attributes;ElementRef::inner_html()/html()serialize. See ElementRef.scraper::Htmlis notSend(Tendril-backed). Extract ownedStrings in a synchronous scope, thenawait; never holdHtmlacross.await.Html::parse_documenttakes&stronly — decode bytes yourself if the page is not UTF-8.- Always resolve relative URLs with
Url::joinagainst the final response URL (post-redirect).
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
Selector API |
https://docs.rs/scraper/latest/scraper/selector/struct.Selector.html | Docs | Free | parse, matches, selector groups |
ElementRef API |
https://docs.rs/scraper/latest/scraper/element_ref/struct.ElementRef.html | Docs | Free | text, attr, inner_html, descendant selection |
| scraper source | https://github.com/rust-scraper/scraper | Repo | Free | ~2k-line codebase; the best "read a small crate" exercise |
serde: typed records, not Value soup
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageRecord {
url: String,
final_url: String,
status: u16,
title: Option<String>, // absent selector -> None, no panic
lang: Option<String>,
#[serde(default)]
links: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
canonical: Option<String>,
}
// JSONL output: one record per line, append-only, crash-friendly.
let mut out = std::io::BufWriter::new(std::fs::File::create("pages.jsonl")?);
for rec in records {
serde_json::to_writer(&mut out, &rec)?;
use std::io::Write; writeln!(out)?;
}
Use Value only for unknown/growing shapes (JSON-LD, API responses); convert to typed structs as soon as the shape stabilizes. Beware #[serde(flatten)] with Value — it forces buffering and is slower; never combine with deny_unknown_fields.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| serde_json crate docs | https://docs.rs/serde_json/latest/serde_json/ | Docs | Free | from_slice, to_writer, json!, Value |
| serde derive docs | https://serde.rs/derive.html | Docs | Free | The exact attribute set for scraped records |
| "How to Parse JSON in Rust" (2026) | https://www.rustfaq.org/en/how-to-parse-json-in-rust | Article | Free | Picking Value vs typed structs |
Extraction Patterns (selectors, text, JSON-LD, readability)
Selector patterns worth memorizing
| Goal | Selector | Rust access |
|---|---|---|
| Base URL | base[href] |
attr("href"), fall back to response URL |
| Canonical URL | link[rel="canonical"] |
attr("href"), resolve with join |
| Page title | title |
text().collect() — check h1 as fallback |
| Meta description | meta[name="description"] |
attr("content") |
| Language | html[lang] |
attr("lang") |
| Navigation links | nav a[href], main a[href] |
dedupe hrefs after join |
| Article body | article, main, [role="main"] |
last-resort; prefer readability below |
| Structured data | script[type="application/ld+json"] |
parse inner_html() as JSON |
Edge case that bites everyone: link[rel="canonical"] also matches rel="canonical alternate" and casing variants. The HTML parser normalizes rel token casing; treat the attribute as a whitespace-separated token list and test fixtures for REL="Canonical".
Canonical link extraction (do it on every page)
fn canonical_of(html: &str, base: &url::Url) -> Option<url::Url> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"link[rel~="canonical"][href], meta[property="og:url"][content]"#).ok()?;
for el in doc.select(&sel) {
let raw = el.value().attr("href").or_else(|| el.value().attr("content"))?;
if let Ok(u) = base.join(raw.trim()) { return Some(u); }
}
None
}
This matters for a search engine: the canonical URL is the identity a page claims for itself, and it is a strong dedup signal alongside your own normalization.
JSON-LD / schema.org extraction
Modern sites embed structured data as <script type="application/ld+json">. Parse it as Value, then match on @type; @graph wrappers are common.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ArticleLd {
#[serde(rename = "@type")]
kind: TypeOrVec,
headline: Option<String>,
author: Option<AuthorOrVec>,
datePublished: Option<String>,
url: Option<String>,
}
// Start loose: Value -> find object with @type Article/NewsArticle/WebPage,
// then deserialize that subtree into a typed struct.
fn first_json_ld(html: &str) -> Option<serde_json::Value> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"script[type="application/ld+json"]"#).unwrap();
doc.select(&sel)
.filter_map(|s| serde_json::from_str::<serde_json::Value>(s.inner_html().as_ref()).ok())
.next()
}
Treat malformed JSON-LD as expected: a huge fraction of the web ships invalid JSON in these scripts. Never fail a page because of it.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Schema.org | https://schema.org/ | Reference | Free | Vocabulary for Article, Product, BreadcrumbList |
| Google: structured data intro | https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data | Docs | Free | What search engines actually consume from JSON-LD |
Readability: main-content extraction with dom_smoothie
For "boilerplate to clean text" (the exact preprocessing Exa does before embedding), dom_smoothie is a faithful port of Mozilla's Readability.js with a serde feature and a test corpus you can reuse.
use dom_smoothie::{Article, Config, Readability};
let cfg = Config { max_elements_to_parse: 9000, ..Default::default() };
let mut r = Readability::new(html, Some(final_url.as_str()), Some(cfg))?;
let article: Article = r.parse()?;
println!("{} | {} chars", article.title, article.length);
let text = article.content.unwrap_or_default(); // cleaned HTML
Also in the family: dom_query (same author) as a scraper alternative with :has, :contains, :only-text, base-URI lookup, and a markdown feature that serializes a document to Markdown — very relevant to AI-facing extraction.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| dom_smoothie docs | https://docs.rs/dom_smoothie/latest/dom_smoothie/ | Docs | Free | Readability port: Readability::new(...).parse() and JSON-LD metadata |
| dom_smoothie repo | https://github.com/niklak/dom_smoothie | Repo + fixtures | Free | test-pages/ is a ready-made fixture corpus for your own tests |
| dom_query docs | https://docs.rs/dom_query/latest/dom_query/ | Docs | Free | jQuery-style queries, Matcher reuse, Markdown serialization |
| Readability.js (origin) | https://github.com/mozilla/readability | Repo | Free | The algorithm dom_smoothie follows; useful for behavior questions |
URL Canonicalization & Deduplication
What url gives you for free, and what is your policy
url implements the WHATWG URL Standard, so Url::parse already applies spec normalization: lowercases scheme and host, drops default ports (:80/:443), resolves dot segments (/a/../b), IDNA/punycode-encodes hosts, percent-encodes consistently, and serializes empty paths as /. Url implements Hash/Eq/Ord on that normalized serialization, so it is a valid dedup key as-is.
The remaining decisions are crawler policy, not spec: fragments, tracking params, query ordering, trailing slashes, www vs apex, index.html vs directory. Put them in one function with tests:
use url::Url;
pub fn normalize(url: &Url) -> Url {
let mut u = url.clone();
u.set_fragment(None); // never sent to servers anyway
// Drop tracking/session params; keep the rest in deterministic order.
let kept: Vec<(String, String)> = u.query_pairs()
.filter(|(k, _)| !matches!(k.as_ref(),
"utm_source"|"utm_medium"|"utm_campaign"|"utm_term"|"utm_content"
|"fbclid"|"gclid"|"mc_cid"|"mc_eid"|"sessionid"|"sid"))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if u.query().is_some() {
let mut q = u.query_pairs_mut();
q.clear();
let mut kept = kept;
kept.sort();
for (k, v) in kept { q.append_pair(&k, &v); }
}
// Optional policy: strip a trailing "index.html" and collapse "//".
// Do NOT strip trailing slashes site-wide: most servers treat /a and /a/ as different.
u
}
Normalization checklist — each item is a test case:
| Rule | Example | Handled by |
|---|---|---|
| Lowercase scheme/host | HTTPS://EXAMPLE.COM → https://example.com |
url |
| Drop default port | https://x.com:443/a → https://x.com/a |
url |
| Resolve dot segments | /a/../b/./c → /b/c |
url |
| IDNA host encoding | https://bücher.de → xn--bcher-kva.de |
url |
Empty path → / |
https://x.com → https://x.com/ |
url |
| Strip fragment | /page#section → /page |
your policy |
| Remove tracking params | ?utm_source=... |
your policy |
| Deterministic query order | ?b=2&a=1 → ?a=1&b=2 |
your policy |
index.html vs directory |
/docs/index.html → /docs/ (pick one) |
your policy (site-specific) |
| Trailing slash | /a vs /a/ — keep distinct by default |
your policy |
| Path case | /Docs ≠ /docs (paths are case-sensitive) |
keep |
www vs apex |
redirect usually resolves it — do not merge blindly | HTTP layer |
Url::join is the other workhorse: resolve every href against the base before dedup, or you will store /about, about, and https://site/about as three pages.
Two-layer dedup
Layer 1 — URL identity (before fetch). Keep a HashSet<Url> (or DashMap across tasks) of normalized URLs. At scale, a Bloom filter (fastbloom::AtomicBloomFilter, concurrent and serializable) gives approximate membership in bits-per-URL; false positives only cost you a missed fetch, never a duplicate. Real crawlers shard this by host (Common Crawl's SURT format reorders https://a.com/x → com,a)/x so same-site URLs cluster; useful once you go distributed).
Layer 2 — content identity (after fetch). Hash the payload and skip storing duplicates served at different URLs (mirrors, session URLs, pagination aliases).
use blake3;
// Hash normalized content, not raw bytes: strips volatile template bits.
let text = dom_smoothie_text_or_fallback(&html);
let digest = blake3::hash(text.as_bytes()); // 32 bytes, fast, parallelizable
let key = digest.to_hex().to_string();
| Hash | Link | Use |
|---|---|---|
| BLAKE3 | https://docs.rs/blake3/latest/blake3/ | Default choice: fastest cryptographic-strength hash, tree mode, update_rayon for large bodies |
SHA-2 (sha2) |
https://docs.rs/sha2/latest/sha2/ | Use when interoperating with systems that require SHA-256 (Browsertrix revisit digests; WARC itself is digest-agnostic, Common Crawl uses SHA-1) |
| fastbloom | https://docs.rs/fastbloom/latest/fastbloom/ | Concurrent Bloom filter for billions of URLs before fetch |
Practical notes:
- Hash normalized text (post-boilerplate) for semantic dedup, but also keep a raw-HTML hash to detect true byte-identical duplicates cheaply.
- A
HashMap<ContentHash, Url>gives you a canonical URL for every duplicate ("revisit" semantics). Browsertrix, the production crawler from Webrecorder, does exactly this with SHA-256 payload digests. - Near-duplicates (same article, different ad slots) need SimHash/MinHash or embeddings — out of scope here, but content hashing is the hook you will extend.
- Test your normalizer as a table and assert idempotence:
normalize(normalize(u)) == normalize(u).
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| crawler-test.com URL/Canonical pages | https://www.crawler-test.com/ | Live test site | Free | Real canonicalization corner cases: trailing slash, param order, encoded chars, index.html, fragments |
| Browsertrix dedupe design | https://crawler.docs.browsertrix.com/develop/dedupe | Docs | Free | How a production crawler models URL + content dedup with revisit records |
| Common Crawl "Get Started" (WARC/WAT/WET formats, digests, SURT-style keys) | https://commoncrawl.org/get-started | Docs | Free | How web-scale archives model URLs, payload digests, and extracted text — the format your pipeline must interoperate with |
Testing Scrapers (fixtures, wiremock, selector tests)
The rule for this phase: network is tested separately from parsing. Parsing tests are pure functions over strings; network behavior is tested against a local mock server. Unit tests should never touch the real web.
1. Fixture HTML + golden outputs
tests/
fixtures/
books_list.html
malformed_unclosed_tags.html
jsonld_article.html
canonical_variants.html
parse_books.rs
fn parse_fixture() -> Vec<Item> {
let html = include_str!("fixtures/books_list.html");
parse_items(html, &url::Url::parse("https://books.toscrape.com/").unwrap())
}
#[test]
fn extracts_every_card() {
let items = parse_fixture();
assert_eq!(items.len(), 20);
assert_eq!(items[0].title, "A Light in the Attic");
assert!(items.iter().all(|i| i.url.starts_with("https://books.toscrape.com/")));
}
#[test]
fn malformed_html_never_panics() {
let html = include_str!("fixtures/malformed_unclosed_tags.html");
let _ = parse_items(html, &url::Url::parse("https://example.com").unwrap());
}
Save fixtures with the date and source URL in an HTML comment; when a site redesigns, you add a new fixture rather than mutating the old one — that is how you keep regression coverage across layouts.
2. Mock the HTTP boundary with wiremock
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path, header_exists};
#[tokio::test]
async fn sends_bot_ua_and_accepts_gzip() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/page"))
.and(header_exists("user-agent"))
.respond_with(ResponseTemplate::new(200)
.set_body_raw("<html><title>ok</title></html>", "text/html"))
.expect(1)
.mount(&server)
.await;
let client = build_client().unwrap();
let resp = client.get(format!("{}/page", server.uri())).send().await.unwrap();
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn retries_then_succeeds() {
let server = MockServer::start().await;
// First two calls fail, third succeeds. wiremock >= 0.6 supports sequence responders.
Mock::given(method("GET")).and(path("/flaky"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.mount(&server).await;
Mock::given(method("GET")).and(path("/flaky"))
.respond_with(ResponseTemplate::new(200).set_body_string("done"))
.mount(&server).await;
// ... call your fetch_page_with_retry(&client, url) ...
// assert attempts == 3 and body == "done"
}
| Tool | Link | Format | Cost | Best for |
|---|---|---|---|---|
| wiremock 0.6 | https://docs.rs/wiremock/latest/wiremock/ | Library + docs | Free | Async mock server with request matchers, expectations, templating; pairs with Tokio |
| wiremock repo | https://github.com/LukeMathWalker/wiremock-rs | Repo | Free | Examples of stateful/sequence mocks for retry tests |
| httpmock | https://docs.rs/httpmock/latest/httpmock/ | Library + docs | Free | Sync + async HTTP mocking, standalone mode |
| mockito | https://docs.rs/mockito/latest/mockito/ | Library + docs | Free | Simple blocking mock server for sync tests |
| insta | https://docs.rs/insta/latest/insta/ | Library + docs | Free | Snapshot the extracted JSON; review changes with cargo insta review |
| pretty_assertions | https://docs.rs/pretty_assertions/latest/pretty_assertions/ | Library + docs | Free | Readable diffs for large struct comparisons |
| proptest | https://docs.rs/proptest/latest/proptest/ | Library + docs | Free | Property tests for normalizer invariants (idempotence, round-trips) |
| rstest | https://docs.rs/rstest/latest/rstest/ | Library + docs | Free | Table-driven selector/normalization tests with #[case] |
Testing strategy checklist:
- Selector tests: one fixture per real layout; assert exact field values, not just counts.
- Negative tests: missing selector, empty element, malformed HTML, non-UTF-8 page.
- Network tests: redirect chain, 429 with
Retry-After, 503-then-200, timeout, non-HTML content-type, gzip/brotli body. - Dedup tests: alias URLs collapse to one key; identical bodies from two URLs produce one content record.
- Snapshot the full
Vec<Record>JSON per fixture; diffs make site changes obvious.
Performance (streaming, compression, connection pooling)
Connection pooling and concurrency
- Reuse one
Client; keeppool_max_idle_per_hosthigh enough for your per-host concurrency (e.g., 16-32) andpool_idle_timeoutat 90s (default). HTTP/2 (default feature) multiplexes one connection for many requests to the same host. - Use
futures::stream::buffer_unordered(n)(orFuturesUnordered) for global concurrency, and a per-hosttokio::sync::Semaphorefor politeness. That per-host gate is also where rate limiting lands in Phase 4. tcp_nodelayis on by default; leave it.- Consider
.hickory_dns(true)(featurehickory-dns) when crawling many domains from one process to avoid blocking getaddrinfo calls.
Compression
- Enable
gzip,brotli,zstd,deflate. Brotli typically saves 15-25% over gzip on HTML at a comparable crawl speed; zstd wins on decompression speed. Let reqwest negotiate viaAccept-Encoding. - For manual pipelines (e.g., tee-ing raw bytes to storage while decompressing), use
async-compressionwith Tokio codecs. - Budget CPU: decompression at 100M pages/day is a real line item. Measure before choosing to prefer brotli-only.
Streaming parsing (the big memory win)
If you only need links or a few fields, do not buffer the whole body. reqwest gives you a byte stream and lol_html consumes it incrementally, running CSS-selector handlers as chunks arrive.
use futures_util::StreamExt;
use lol_html::{element, HtmlRewriter, Settings};
pub async fn stream_links(client: &reqwest::Client, url: &str) -> anyhow::Result<Vec<String>> {
let resp = client.get(url).send().await?.error_for_status()?;
let mut body = resp.bytes_stream();
let mut links: Vec<String> = Vec::new();
let mut rewriter = HtmlRewriter::new(
Settings::new().element_content_handlers(vec![element!("a[href]", |el| {
if let Some(href) = el.get_attribute("href") {
links.push(href);
}
Ok(())
})]),
|_chunk: &[u8]| {}, // discard output; we only collect side effects
);
while let Some(chunk) = body.next().await {
rewriter.write(&chunk?)?;
}
rewriter.end()?;
Ok(links)
}
Why this matters for Exa-scale crawling: peak memory becomes O(chunk + extracted state) instead of O(page), the first links are available before the last byte lands, and you can abort a page mid-stream the moment it is clearly low-value (spam, wrong language, huge payload).
Cap your inputs regardless: reject on Content-Length when present, and enforce a hard byte cap on the decompressed stream (a Content-Encoding zip bomb can otherwise expand hundreds of times).
Conditional requests (free bandwidth savings)
Store ETag and Last-Modified per URL; send If-None-Match/If-Modified-Since on refreshes. A 304 Not Modified costs almost nothing and keeps content hashes stable.
let resp = client.get(url)
.header(reqwest::header::IF_NONE_MATCH, etag)
.send().await?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED { /* keep prior record */ }
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
HtmlRewriter API |
https://docs.rs/lol_html/latest/lol_html/struct.HtmlRewriter.html | Docs | Free | Streaming parse API: write, end, output sink |
| lol_html repo + benches | https://github.com/cloudflare/lol-html | Repo | Free | How Cloudflare Workers rewrites HTML at edge scale |
Response::bytes_stream |
https://docs.rs/reqwest/latest/reqwest/struct.Response.html | Docs | Free | Bridging HTTP bodies into streaming parsers |
| HTTP caching (MDN) | https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching | Docs | Free | Correct ETag/If-None-Match semantics |
| reqwest feature flags | https://docs.rs/crate/reqwest/latest/features | Docs | Free | Exact feature set for gzip/brotli/zstd/stream/cookies |
Recommended Order (Weeks 5-6)
Assumes your Rust basics and Tokio phase are done (you can write async fns and use Result fluently). Total ≈ 26-30 focused hours.
Week 5 — Fetch, parse, type, normalize (≈ 14h)
| Day | Focus | Time | Outcome |
|---|---|---|---|
| 1 | reqwest client factory: builder, headers/UA, timeouts, redirects, cookies, gzip/brotli, status classification, content-type filter |
2.5h | client.rs with a tested build_client() |
| 2 | scraper: Html, precompiled Selectors, ElementRef text/attrs, relative-URL joining |
2.5h | First working extractor on a real page |
| 3 | serde/serde_json: typed structs, Option fields, JSONL writer |
2h | Records serialized to pages.jsonl |
| 4 | url: parse/join/normalize; write normalize_url with a table test of 15+ cases |
2.5h | Normalizer with passing tests |
| 5 | Testing: fixtures + wiremock + insta snapshots around the fetch/parse pipeline |
2.5h | cargo test green, no network |
| 6-7 | Mini-project: 5-site scraper (Exercise 2) | 2h | Clean JSONL from 5 real sites |
Week 6 — Hardening, dedup, streaming, structure (≈ 14h)
| Day | Focus | Time | Outcome |
|---|---|---|---|
| 1 | Retries: 429/503 classification, exponential backoff + jitter, Retry-After, retry budget; wiremock sequence tests |
2.5h | fetch_with_retry with tests |
| 2 | Dedup: URL set + blake3 content hash; canonical/alias mapping |
2h | Dedup index with stats output |
| 3 | Payload safety: byte caps, charset decoding, decompression-bomb guards, ETag revalidation |
2h | Hardened fetch path |
| 4 | Streaming: bytes_stream + lol_html link extraction; test with tags split across chunk boundaries |
2.5h | Constant-memory extraction |
| 5 | Content quality: dom_smoothie readability + JSON-LD + canonical precedence |
2.5h | Clean text + metadata records |
| 6-7 | Package as crawler-http crate + CLI; extend acceptance tests; write a short README |
2.5h | Portfolio-ready repo for the Exa application |
Hands-on Exercises
Each exercise is scoped for 1-3 hours and maps directly to a crawler-team concern.
Exercise 1 — Harden the client factory (2h)
Build build_client() with: bot UA, Accept/Accept-Language defaults, connect/read/total timeouts, Policy::limited(5), cookie store, all four compression features, https_only(true).
Acceptance: wiremock tests assert the outgoing user-agent contains a contact URL, gzip is negotiated, a redirect loop fails within the limit, a server that stalls past read_timeout returns your Timeout error, and non-2xx is classified (retryable vs terminal) without panicking.
Exercise 2 — Scrape 5 mostly-static sites into clean JSON with retries/gzip/redirects/timeout budgets/content-type filtering (3h)
Targets: books.toscrape.com (catalogue + pagination), quotes.toscrape.com, www.scrapethissite.com/pages/simple/ (countries), Hacker News front page (30 links + points), www.paulgraham.com/articles.html (essay index).
Acceptance: one JSONL record per page with url, final_url, status, title, links[], and site-specific typed fields; relative links resolved absolute; non-HTML responses skipped with a logged reason; the run completes five sites in under 60s total; exit code non-zero if any site fails permanently; a rerun produces identical records modulo volatile fields (snapshot-tested).
Exercise 3 — URL normalizer + torture table (2h)
Implement normalize() and a table-driven test with at least 15 (input, expected) cases.
Acceptance: covers scheme/host case, default ports, dot segments, IDNA host, fragment stripping, tracking-param removal, empty-path slash, and query sorting; a proptest asserts idempotence; invalid URLs fail with a typed error; 10 real cases from crawler-test.com's URL duplication pages are copied into fixtures.
Exercise 4 — Two-layer dedup index (2h)
Add URL-set dedup before fetch and content-hash dedup after fetch. Output a canonical_url -> [alias_urls] map plus duplicate stats.
Acceptance: three aliases of the same URL cause one fetch; two different URLs returning byte-identical bodies produce one content record with one alias; different content types never collide; a test uses wiremock to verify only one HTTP request occurs for aliased URLs; stats report url_dupes, content_dupes, fetched.
Exercise 5 — Resilient fetch: backoff, Retry-After, budget (2.5h)
Implement fetch_with_retry with jittered exponential backoff, terminal vs transient status classification, Retry-After parsing for 429/503, and a max-attempts budget.
Acceptance: wiremock sequence 503,503,200 yields success with exactly 3 calls; 429 with Retry-After: 1 waits at least 1s (assert via tokio::time::pause/pause-aware sleeps or recorded attempt timestamps); 404 returns immediately with no retry; attempts never exceed the budget for a permanently failing host.
Exercise 6 — Selector fixture suite (2h)
Create tests/fixtures/ with well-formed, malformed, missing-field, and "site redesign" variants for one target, plus insta snapshots of the extracted Vec<Record>.
Acceptance: every fixture has a passing test; malformed HTML never panics; a fixture where the main selector matches nothing returns an empty Vec and logs a warning (no unwrap); cargo insta test shows zero pending snapshots; one test proves selectors are compiled once (static LazyLock) by reusing them across 1,000 iterations.
Exercise 7 — Streaming extractor with a memory ceiling (2.5h)
Use bytes_stream + lol_html to extract all links from a generated large HTML fixture (concatenate a page 10,000x, ~20-50MB) served by a local file server or wiremock.
Acceptance: links emitted before end() is called; a unit test where a tag is split across two chunks still extracts the href; peak RSS (measure with /usr/bin/time -l on macOS or -v on Linux) stays under ~100MB while the full parse run does; running the same extraction with Html::parse_document on the full body for comparison shows materially higher memory.
Exercise 8 — Readability + structured data pipeline (2.5h)
For 3 fixture pages, extract readable article text with dom_smoothie, parse Article/NewsArticle JSON-LD with serde, and extract the canonical/og:url with the precedence rule: link[rel=canonical] > og:url > final response URL.
Acceptance: title and body match golden snapshots; JSON-LD given as @graph and as a top-level array both parse; invalid JSON-LD is skipped without failing the page; canonical precedence is covered by three tests including conflicting tags; extracted text length is reported and under a sane cap.
Definition of done for the phase: a crawler-http crate whose cargo test suite passes offline, plus a CLI that scrapes the exercise-2 sites to JSONL with dedup stats — this is the artifact you show in your portfolio from Phase 3.
Common Pitfalls / What to Skip
| Pitfall | Why it hurts | Do instead |
|---|---|---|
| Regex over HTML | Breaks on nesting/attribute order/malformed markup | scraper/dom_query (both on html5ever) |
New Client per request |
Kills pooling and TLS reuse; 3-10x slower | Build once, clone the Client |
| No timeout | Default is infinite; one dead host stalls the crawl | connect_timeout + read_timeout + total timeout |
Holding scraper::Html across .await |
Html is not Send; code does not compile |
Parse in a sync scope, return owned Strings |
Selector::parse inside a loop |
Recompiles the selector per element | LazyLock<Selector> or prepare once |
.unwrap() on attr/text/selector hits |
A single layout tweak panics the crawler | Option/Result + structured logging |
| Parsing before content-type check | PDFs/images/exes fed to the HTML parser | Check Content-Type + byte size first |
Trusting Content-Length |
It describes compressed size; removed after decompression | Cap the decompressed stream too |
| Assuming reqwest == curl TLS behavior | Different roots/proxy handling (less true in 0.13 with platform verifier, still real in containers) | Test against real hosts in CI; add trust roots explicitly |
| Following redirects blindly | Cross-domain redirects change politeness/robots context | Policy::limited(n), record response.url(), re-check host |
| Hashing raw HTML for content dedup | Volatile boilerplate makes every fetch look unique | Hash normalized/readability text |
| Treating fragments as distinct pages | #section URLs are never sent to servers |
Strip fragments before dedup/fetch |
| Sorting query params site-wide | Some sites use param order meaningfully | Sort only if your fixture tests confirm it is safe |
serde(flatten) everywhere |
Forces buffering, conflicts with deny_unknown_fields |
Use it only for extension objects |
| Following outdated 0.12 tutorials on 0.13 | TLS default changed (now rustls), retry API added | Pin reqwest = "0.13"; check docs.rs for the exact version |
| Reaching for headless Chrome now | 100x cost for content that HTTP already delivers | HTTP-first; browser fallback is Phase 5 |
Do not use / avoid:
select(0.6.1) — not formally deprecated, but effectively legacy: last release Mar 2025 (previous Dec 2022) and it still depends onhtml5ever 0.26, which the ecosystem lists as obsolete.scraper(0.27, html5ever 0.39) anddom_queryare the maintained paths. Read it for history; don't start new code on it. https://github.com/utkarshkukreti/select.rskuchiki— explicitly unmaintained on lib.rs (last release Aug 2020); it was the ancestor ofscraper. Do not use.- Raw
html5ever— use only if you need tree-builder hooks;scraper/dom_querygive you selectors without the boilerplate. reqwest::blockingin async code — panics inside a Tokio runtime. Use async, or keep blocking strictly in sync CLI tools/tests.spider(the full crawler framework) as your Phase 3 tool — it is excellent and you should read it, but adopting it now skips the primitives (scheduling, politeness, dedup) you need to demonstrate. Build the HTTP layer yourself first; graduate tospiderin Phase 5 when you need CDP fallback and distributed workers.
Open-Source Repos to Read
Ordered by reading value for this phase. Read actively: run their tests, trace one page from bytes to parsed struct.
| Repo | Link | What to read | Why it matters for Exa |
|---|---|---|---|
| spider-rs/spider | https://github.com/spider-rs/spider | spider/src/website.rs, scheduler, chrome/CDP fallback, spider_worker |
Production crawler architecture: streaming, budgeting, decentralized workers — the target shape for Phase 5 |
| cloudflare/lol-html | https://github.com/cloudflare/lol-html | src/rewritable_units, src/rewriter, benches |
How to parse HTML with bounded memory and emit before EOF; also a masterclass in tokenizer state machines |
| niklak/dom_smoothie | https://github.com/niklak/dom_smoothie | src/readability.rs, test-pages/ |
Readability port + a ready-made fixture corpus; content quality code search engines run |
| niklak/dom_query | https://github.com/niklak/dom_query | src/selection, matcher reuse, markdown feature |
Modern alternative to scraper; see :has/:contains and Markdown serialization for AI pipelines |
| rust-scraper/scraper | https://github.com/rust-scraper/scraper | src/selector.rs, src/element_ref.rs |
Small enough to fully understand; the exact API you use daily |
| seanmonstar/reqwest | https://github.com/seanmonstar/reqwest | src/async_impl/client.rs (pool, redirect, retry), tests |
Learn how production connection pooling and redirect policies actually behave |
| servo/html5ever | https://github.com/servo/html5ever | src/tokenizer, src/tree_builder |
The spec-grade parser under every Rust HTML tool; explains "browser-grade" parsing |
| servo/rust-url | https://github.com/servo/rust-url | url/src/parser.rs, Url::join tests |
WHATWG normalization rules you rely on; read before writing canonicalization policy |
| LukeMathWalker/wiremock-rs | https://github.com/LukeMathWalker/wiremock-rs | src/mock_server, matchers/ |
Patterns for testing async clients without the network |
| Folyd/robotstxt | https://github.com/Folyd/robotstxt | src/, tests/ |
Google's robots.txt parser faithfully ported; your Phase 4 groundwork |
| Smerity/texting_robots | https://github.com/Smerity/texting_robots | src/lib.rs, Common Crawl fuzz harness |
Robots parsing battle-tested against 34M real files; crawl-delay/sitemap handling |
| BLAKE3-team/BLAKE3 | https://github.com/BLAKE3-team/BLAKE3 | src/lib.rs, tree hashing design doc |
Content-hash dedup at scale; understand why it is fast and parallel |
Companion rationale: Exa's crawler posting asks for "distributed crawler at 100M+ pages/day", "crawl politeness", "JS rendering", and "extreme optimization" in Rust/C++. This phase covers the HTTP-first substrate all of that sits on: a pooled client that never hangs, typed extraction that never panics, canonical identity for every URL, content hashing for dedup, and streaming parsing that keeps memory flat. Browser/CDP fallback, per-host scheduling, and politeness are built directly on these primitives in the next phases.
Polite Crawling & URL Frontier Design
Exa runs a web-scale crawler (100M+ pages/day) and its crawler engineers are judged on whether the system is polite, resumable, and scheduler-smart at that scale — not merely fast. This phase is where you learn the contract between a crawler and the web: robots.txt/sitemaps, host-partitioned frontiers, adaptive rate limiting, conditional GETs, and durable crawl state. Everything here is Rust-first, with classic papers and other-language crawlers included because their designs transfer directly.
Start Here (core path)
If you only do six things, do these in order. Everything else in this file is reference material.
| # | Resource | Link | Why it's first | Time |
|---|---|---|---|---|
| 1 | RFC 9309 (Robots Exclusion Protocol) | https://www.rfc-editor.org/rfc/rfc9309.html | The normative robots.txt spec; you will implement it, so read it end to end once. | 45 min |
| 2 | Google: How Google interprets robots.txt | https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec | Fills the real-world gaps in RFC 9309 (line limits, error handling, caching expectations). | 20 min |
| 3 | IR Book Ch. 20 — Web crawling and indexes, esp. "The URL frontier" | https://nlp.stanford.edu/IR-book/html/htmledition/web-crawling-and-indexes-1.html | Canonical front-queue/back-queue frontier design and crawler architecture in one free chapter. | 2 h |
| 4 | Mercator: A Scalable, Extensible Web Crawler | https://marc.najork.org/papers/wwwmerc.pdf | The paper behind the frontier design; politeness, priorities, disk-backed queues, robots handling. | 1 h |
| 5 | Scrapy AutoThrottle + Architecture | https://docs.scrapy.org/en/latest/topics/autothrottle.html | A battle-tested adaptive rate-limiting algorithm you can port to Rust in an afternoon. | 45 min |
| 6 | Rust crates in the loop: texting_robots, governor, backoff, reqwest-retry |
https://docs.rs/texting_robots · https://docs.rs/governor · https://docs.rs/backoff · https://docs.rs/reqwest-retry | These four cover robots parsing, per-host token buckets, jittered retries, and Retry-After handling. | 4–6 h |
Context for the target role: Exa crawler postings and the team's public writing are at https://exa.ai/careers (postings) and https://exa.ai/blog (engineering posts). Read the careers page once to anchor your portfolio narrative.
Robots.txt, Sitemaps & Standards
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| RFC 9309 — Robots Exclusion Protocol | https://www.rfc-editor.org/rfc/rfc9309.html | Spec (HTML) | Free | Normative rules: user-agent matching, longest-match path rules, UTF-8, 500 KiB parse floor, ≤24 h caching. |
| Datatracker entry for RFC 9309 | https://datatracker.ietf.org/doc/html/rfc9309 | Spec mirror | Free | Errata and change history when your parser disagrees with the spec. |
| Google robots.txt spec interpretation | https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec | Docs | Free | Error handling, unsupported fields (e.g. crawl-delay), and real-world quirks Google codifies. |
| Google robots.txt guide | https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt | Docs | Free | What site owners expect a crawler to do with Allow/Disallow/Crawl-delay. |
| sitemaps.org Protocol | https://www.sitemaps.org/protocol.html | Spec (XML) | Free | <urlset>/<sitemapindex>, loc/lastmod, and the Sitemap: robots.txt directive. |
| sitemaps.org FAQ | https://www.sitemaps.org/faq.html | Docs | Free | Hard limits (50 000 URLs / 50 MB per file), gzip, cross-host sitemap rules. |
| Google sitemaps overview | https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview | Docs | Free | Which sitemap fields Google actually uses (lastmod yes; changefreq/priority ignored). |
| Bing: crawl rates & crawl-delay | https://blogs.bing.com/webmaster/october-2018/how-to-control-crawl-rates-using-the-crawl-delay-directive | Blog | Free | Proof that Crawl-delay is non-standard but still honored by Bing/Yandex/Common Crawl. |
| Common Crawl FAQ (robots behavior) | https://commoncrawl.org/faq | Docs | Free | How a production crawler interprets robots, obeys Crawl-delay, and applies adaptive backoff on 429/5xx. |
texting_robots (Rust) |
https://docs.rs/texting_robots | Crate docs | Free | Best-maintained Rust robots.txt parser; handles crawl-delay, sitemaps, Google/reppy test suite. |
robotstxt (Rust) |
https://docs.rs/robotstxt | Crate docs | Free | Older alternative parser; useful to diff behavior against texting_robots. |
Google robotstxt C++ parser |
https://github.com/google/robotstxt | Source | Free | Reference implementation with a large test corpus; use its tests to validate yours. |
Mozilla reppy |
https://github.com/seomoz/reppy | Source | Free | Another reference parser with edge-case tests; the crate texting_robots ports many of its tests. |
quick-xml (Rust) |
https://docs.rs/quick-xml | Crate docs | Free | Fast streaming parser for sitemap XML and sitemap indexes. |
feed-rs (Rust) |
https://docs.rs/feed-rs | Crate docs | Free | Optional: parse RSS/Atom feeds as an alternate URL-discovery channel. |
Key implementation rules to encode (verify each against RFC 9309 before shipping):
- Match the most specific
User-agentgroup (longest product-token match); fall back to*. - Path rules are prefix matches supporting
*and$; the longest matching rule wins, andAllowwins ties. Crawl-delayis not in RFC 9309 — honor it when present (many sites and Common Crawl rely on it), but never treat its absence as permission to hammer a host.- Cache parsed robots per host for at most 24 h; on 4xx (except 429) treat as allow-all; on 5xx treat as temporarily unreachable and back off while using the cached copy if available.
- Cap robots.txt reads at 500 KiB and fail closed on malformed input.
- Always send a descriptive
User-Agent, e.g.YourBot/0.1 (+https://yourdomain.example/bot).
Crawler Architecture & URL Frontier
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| IR Book Ch. 20: Web crawling and indexes | https://nlp.stanford.edu/IR-book/html/htmledition/web-crawling-and-indexes-1.html | Book chapter (HTML/PDF) | Free | Crawler must/should-have features, DNS resolution, crawler architecture, and frontier overview. |
| IR Book: Crawler architecture | https://nlp.stanford.edu/IR-book/html/htmledition/crawler-architecture-1.html | Book section | Free | The fetch → parse → link-extract → frontier loop, and distributing the crawler. |
| IR Book: DNS resolution | https://nlp.stanford.edu/IR-book/html/htmledition/dns-resolution-1.html | Book section | Free | Why DNS is a crawler bottleneck and how to cache it. |
| IR Book: The URL frontier | https://nlp.stanford.edu/IR-book/html/htmledition/the-url-frontier-1.html | Book section | Free | Front queues (prioritization) + back queues (politeness), host→queue table, disk-backed queues. |
| Mercator paper (Najork & Heydon) | https://marc.najork.org/papers/wwwmerc.pdf | Paper (PDF) | Free | Original design: pluggable prioritizer, ~3× back queues per thread, disk-buffered FIFOs, URL-seen test. |
| Najork: Web Crawler Architecture (survey chapter) | https://marc.najork.org/papers/eds2009a.pdf | Paper (PDF) | Free | Concise survey of frontier/prioritization/politeness tradeoffs, ideal as a design checklist. |
| Mercator SRC-173 tech report version | https://marc.najork.org/papers/SRC-173.pdf | Paper (PDF) | Free | Extra implementation detail (buffers, queue sizing) not in the published version. |
| "Search Engines: Information Retrieval in Practice" (Croft, Metzler, Strohman) | https://ciir.cs.umass.edu/downloads/SEIRiP.pdf | Book (PDF) | Free | Free edited 2015 edition; its crawling chapter frames politeness and freshness as a scheduling problem. |
| Modern crawler system-design walkthrough | https://systeminternals.dev/system-design/web-crawler | Article | Free | Current, practical frontier sizing, host-hash routing, Bloom filters, and persistence choices. |
| crawler-commons URL Frontier API | https://github.com/crawler-commons/url-frontier | Spec + source | Free | A production gRPC frontier API (put/get/acked) you can mirror in Rust for later distribution. |
| WARC specifications (IIPC) | https://iipc.github.io/warc-specifications/ | Spec | Free | If you archive fetched content, store it as WARC so Heritrix/Common Crawl tooling works. |
| Common Crawl index table format | https://github.com/commoncrawl/cc-index-table | Docs + source | Free | How a web-scale crawl exposes its URL/status index (CDX) for downstream consumers. |
Design summary to implement:
- Front queues implement priority (depth, URL quality,
lastmod, change rate). K priority levels, biased random selection so high-priority queues drain faster without starving the rest. - Back queues implement politeness: one queue per host, at most one in-flight request per host, and a per-host
next_allowed_at. - A host→queue table routes new URLs to the right back queue; a min-heap of next-allowed times picks the next eligible host.
- Back queues should outnumber workers (~3× per Mercator) so workers are never idle while a host cools down.
- Frontier size outgrows RAM: keep head/tail buffers in memory and spill the bulk to SQLite/RocksDB.
- In a distributed crawler, hash by host (not URL) so all state for a host stays on one node.
Rate Limiting, Backoff & Politeness
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| RFC 9110 §10.2.3 — Retry-After | https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after | Spec | Free | Both legal formats: delta-seconds and HTTP-date; semantics with 429/503/3xx. |
| RFC 6585 §4 — 429 Too Many Requests | https://www.rfc-editor.org/rfc/rfc6585.html | Spec | Free | The status code definition and its Retry-After hint. |
| AWS: Exponential Backoff and Jitter | https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter | Blog | Free | Full/equal/decorrelated jitter with simulations; pick full jitter and move on. |
| AWS Builders' Library: Timeouts, retries, backoff | https://builder.aws.com/content/3EumjoZascWd1oZiEgL8ORlv3qE/timeouts-retries-and-backoff-with-jitter | Article | Free | Why un-jittered backoff can be worse than none, and how to budget retries overall. |
| Scrapy AutoThrottle | https://docs.scrapy.org/en/latest/topics/autothrottle.html | Docs | Free | The latency/N adaptive algorithm: start delay, running average, non-200 only slows down. |
| Google: Reduce crawl rate & crawl budget | https://developers.google.com/crawling/docs/crawlers-fetchers/reduce-crawl-rate | Docs | Free | How 429/500/503 signals map to server-side crawl budgets; your crawler should mirror this. |
| Google: Crawl budget management | https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget | Docs | Free | Crawl capacity limit vs. crawl demand — the vocabulary Exa-facing engineers use. |
governor (Rust) |
https://docs.rs/governor | Crate docs | Free | GCRA-based direct and per-key rate limiters; DefaultKeyedRateLimiter is a per-host bucket almost out of the box. |
leaky-bucket (Rust) |
https://docs.rs/leaky-bucket | Crate docs | Free | Simpler leaky-bucket alternative when you want explicit token semantics. |
backoff (Rust) |
https://docs.rs/backoff | Crate docs | Free | Randomized exponential backoff with Error::retry_after(...) for 429 — exactly the crawler use case. |
reqwest-retry (Rust) |
https://docs.rs/reqwest-retry | Crate docs | Free | Middleware that classifies transient errors and honors Retry-After; less code you must own. |
Tower limit module |
https://docs.rs/tower/latest/tower/limit/ | Crate docs | Free | RateLimit/ConcurrencyLimit middleware if you want HTTP-layer politeness. |
| OneUptime: Retry-After vs exponential backoff (2026) | https://oneuptime.com/blog/post/2026-08-14-retry-after-vs-exponential-backoff/view | Article | Free | Practical rule: delay = max(local_jittered_backoff, Retry-After), bounded by a deadline. |
Politeness policy to implement:
- Per-host token bucket (
governorkeyed limiter or your own) with a conservative default (e.g. 1 rps) and per-host overrides fromCrawl-delay. - Adaptive AIMD: on 429/5xx multiply the host rate by ~0.5 (multiplicative decrease); after sustained success add back slowly (additive increase) up to a cap. Scrapy's latency/N is the same idea using response time as the signal.
- 429 handling: sleep at least
Retry-After(parse both formats); do not cap server instructions downward. If Retry-After exceeds your budget, durably reschedule the URL instead of hot-looping. - 5xx / connection errors: retry with capped exponential backoff plus full jitter (
sleep = rand(0, min(cap, base·2^attempt))), max attempts and max elapsed time both bounded. - 4xx: 404/410 are terminal — remove/mark URLs; 408/429 are retryable.
- Adaptive per-host concurrency: start at 1, grow only while latency and error rate stay healthy (Scrapy's target-concurrency model).
- Never retry until the retry storm is jittered — 1000 goroutines waking at the same instant is a self-inflicted DDoS.
Conditional GETs, Caching & Freshness
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| MDN: HTTP conditional requests | https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Conditional_requests | Guide | Free | End-to-end flow of validators, 304s, and cache revalidation. |
| MDN: ETag | https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag | Reference | Free | Strong vs. weak validators; what to store per URL. |
| MDN: If-None-Match | https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-None-Match | Reference | Free | The request header you send; takes precedence over If-Modified-Since. |
| MDN: If-Modified-Since | https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-Modified-Since | Reference | Free | Fallback validator when the server sends no ETag. |
| MDN: 304 Not Modified | https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304 | Reference | Free | What a valid 304 may contain and why there is no body. |
| RFC 9110 §8.8 / §13 (validators, preconditions) | https://www.rfc-editor.org/rfc/rfc9110.html#name-validator-fields | Spec | Free | The normative rules, including precedence when both validators exist. |
| RFC 9111 (HTTP Caching) | https://www.rfc-editor.org/rfc/rfc9111.html | Spec | Free | Cache-Control/Expires semantics if you add a local response cache. |
| Cho & Garcia-Molina: Effective Page Refresh Policies | https://www2003.org/cdrom/papers/refereed/p650/p650-cho.htm | Paper | Free | Classic recrawl-scheduling work: refresh interval should follow observed change rate. |
http-cache-reqwest (Rust) |
https://docs.rs/http-cache-reqwest | Crate docs | Free | RFC-compliant client cache with revalidation; study or use instead of rolling your own. |
http-cache (Rust, repo) |
https://github.com/06chaynes/http-cache | Source | Free | Read the revalidation logic if you implement validators yourself. |
reqwest docs |
https://docs.rs/reqwest | Crate docs | Free | Set request headers, timeouts, redirect policy, and (optionally) a custom DNS resolver. |
Freshness policy to implement:
- Store
etagandlast_modifiedper URL; sendIf-None-Matchand, when absent,If-Modified-Since. - On 304: update
last_fetched_at, do not touch content, and treat it as a success signal for rate adaptation. - On 200: update validators,
content_hash, and change counters used for scheduling. - On 404/410: schedule deletion/removal after N consecutive failures.
- Adaptive recrawl interval: if a URL changed, shorten its interval; after repeated 304s, lengthen it geometrically (cap it). Seed initial intervals from sitemap
lastmodwhen available. - 304s also count against politeness — a 304 is still a request; keep per-host limiting in force.
Persistent Crawl State (SQLite/Postgres in Rust)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
rusqlite |
https://github.com/rusqlite/rusqlite | Source + docs | Free | Synchronous embedded SQLite with bundled SQLite; the simplest durable frontier store for one node. |
rusqlite API docs |
https://docs.rs/rusqlite/latest/rusqlite/ | Crate docs | Free | Copy-fast bulk inserts, prepared statements, transactions. |
deadpool-sqlite |
https://docs.rs/deadpool-sqlite | Crate docs | Free | Async connection pool over rusqlite so Tokio tasks can query without blocking a runtime thread. |
SQLx (now under transact-rs; formerly launchbadge) |
https://github.com/transact-rs/sqlx | Source + docs | Free | Async, compile-time-checked SQL for SQLite and Postgres — the migration path to multi-node. |
| SQLx docs | https://docs.rs/sqlx/latest/sqlx/ | Crate docs | Free | Pools, query! macros, offline mode for CI, migrations. |
| SQLite WAL mode | https://sqlite.org/wal.html | Docs | Free | The setting that makes crash-safe concurrent reads alongside one writer viable. |
| Scrapy: Jobs — pause and resume crawls | https://docs.scrapy.org/en/latest/topics/jobs.html | Docs | Free | A proven model of persisting scheduler state per spider job; copy the semantics, not the code. |
| Crawlee: Request storage guide | https://crawlee.dev/js/docs/guides/request-storage | Docs | Free | Request queue with dedup, locking, and retry counts — a good schema/behavior reference. |
| Apify: Request queue concept | https://docs.apify.com/academy/concepts/request-queue | Docs | Free | Vocabulary for queue states (pending/in-progress/done) and handledRequestCount. |
hickory-resolver |
https://docs.rs/hickory-resolver/latest/hickory_resolver/ | Crate docs | Free | DNS caching with TTLs and negative caching; plug into reqwest or call directly. |
reqwest custom DNS |
https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html | Crate docs | Free | hickory_dns(true) / custom resolver hooks to stop resolving the same host per request. |
moka (Rust) |
https://github.com/moka-rs/moka | Source + docs | Free | TTL/LRU caches for parsed robots.txt, DNS, and per-host limiter state that need not be durable. |
Minimal frontier schema that survives kill -9:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE hosts (
host TEXT PRIMARY KEY,
next_allowed_at INTEGER NOT NULL DEFAULT 0, -- unix millis, monotonic-ish
rate_per_sec REAL NOT NULL DEFAULT 1.0,
robots_state TEXT NOT NULL DEFAULT 'unknown', -- ok|allow_all|unreachable
robots_fetched_at INTEGER
);
CREATE TABLE urls (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
host TEXT NOT NULL REFERENCES hosts(host),
priority INTEGER NOT NULL DEFAULT 5,
state TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|done|failed|gone
next_fetch_at INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
etag TEXT,
last_modified TEXT,
last_status INTEGER,
content_hash TEXT
);
CREATE INDEX urls_due ON urls(state, next_fetch_at, priority);
CREATE INDEX urls_host ON urls(host, state);
Operational rules: one writer connection (SQLite allows only one at a time — batch enqueues in transactions), N reader connections via deadpool-sqlite; claim work with UPDATE ... WHERE state='pending' AND next_fetch_at <= ? LIMIT 1 RETURNING * inside a transaction; checkpoint hosts.next_allowed_at after every fetch; keep in_progress rows with a lease timestamp so a crashed worker's claims can be reclaimed. Move to Postgres via SQLx only when a second crawler node appears.
Open-Source Crawlers to Study
| Repo | Link | Lang | What to look at |
|---|---|---|---|
| spider-rs/spider | https://github.com/spider-rs/spider | Rust | Rust-native reference: per-domain controls, robots enforcement, scheduling, retries, Chrome fallback. |
| apify/crawlee | https://github.com/apify/crawlee | TypeScript | Request queue persistence, autoscaled concurrency, session/retry handling; read packages/core queue storage. |
| apify/crawlee-python | https://github.com/apify/crawlee-python | Python | Same architecture in Python — easier to read; RequestQueue, BasicCrawler, retry/backoff policy. |
| firecrawl/firecrawl | https://github.com/firecrawl/firecrawl | TypeScript | Production crawl API: sitemap-first discovery, job state, batching, per-page status reporting. |
| scrapy/scrapy | https://github.com/scrapy/scrapy | Python | Architecture of record: scheduler/dupefilter, AutoThrottle, retry middleware, jobs persistence. |
| gocolly/colly | https://github.com/gocolly/colly | Go | Tiny, readable per-domain delay/concurrency limits and a storage interface — a great weekend read. |
| internetarchive/heritrix3 | https://github.com/internetarchive/heritrix3 | Java | Archival-grade politeness and frontier engineering; see engine/ queue-assignment classes and docs. |
| apache/nutch | https://github.com/apache/nutch | Java | Host DB, fetch scheduling/backoff, and the crawler Common Crawl historically built on. |
| apache/stormcrawler | https://github.com/apache/stormcrawler | Java | Distributed politeness/backoff over a streaming topology — previews Phase 6 problems. |
| crawler-commons/url-frontier | https://github.com/crawler-commons/url-frontier | Java/API | A frontier-as-a-service contract (put/get/acked) worth copying as your distributed interface. |
| Common Crawl / CCBot | https://commoncrawl.org/ccbot | Docs | Scale notes: dedicated IPs, reverse DNS verification, robots + crawl-delay + adaptive backoff policy. |
Note on "crawlee-rust": there is no official Crawlee port for Rust as of September 2026 (the apify/crawlee-rust repository does not exist and no crawlee crate is published). The Rust ecosystem analogues are spider-rs/spider and Apify's Rust component impit (https://github.com/apify/impit), which is the browser-impersonation client Crawlee's TS stack itself uses. Study Crawlee's design, implement it on spider/Tokio.
Recommended Order (weeks 7–8, with time estimates)
Assumes the Rust/Tokio/HTTP phases are done. Budget ~14 h/week; all "deliverable" items are concrete files in your crawler repo.
Week 7 — robots, discovery, frontier
| Day | Focus | Hours | Deliverable |
|---|---|---|---|
| 1 | RFC 9309 + Google spec; wire up texting_robots |
2 | robots.rs: fetch (with cache), parse, allowed(url), crawl_delay() + unit tests |
| 2 | Sitemap discovery and parsing (quick-xml) |
2 | sitemap.rs: robots Sitemap: → sitemap index → URL list, gzip support, dedup |
| 3 | IR Book Ch. 20 + Mercator paper; sketch frontier on paper | 3 | frontier-design.md with front/back queues, host table, sizing |
| 4 | Implement host-partitioned frontier (in-memory first) | 3 | frontier.rs: priority levels + one queue per host + next-allowed heap |
| 5 | Token bucket per host (governor) + Crawl-delay floor |
2 | limiter.rs with keyed limiter and tests |
| 6–7 | Catch-up, read Colly/Scrapy AutoThrottle source | 2 | Notes + fixes; frontier exercise passing |
Week 8 — politeness hardening, freshness, persistence
| Day | Focus | Hours | Deliverable |
|---|---|---|---|
| 1 | 429 / Retry-After + 5xx full-jitter backoff (backoff or own) |
2 | retry.rs with deterministic tests (injectable RNG/clock) |
| 2 | Conditional GETs + validator storage | 2 | revalidate.rs: If-None-Match/If-Modified-Since, 304 accounting |
| 3 | SQLite persistence: WAL, schema, claim/lease queries | 3 | store.rs + migration; restart test after kill -9 |
| 4 | DNS caching + AIMD adaptive rate; metrics | 2 | dns.rs (hickory-resolver), per-host stats in logs |
| 5 | Capstone polite crawl of 5 real sites; write run report | 3 | crawl-report.md: requests/host, 429s, 304s, delays, robots decisions |
| 6–7 | Buffer: fix flaky tests, record 3-minute demo | 2 | Repo tagged phase4-complete, README updated |
Hands-on Exercises
Each exercise builds on the previous one in a single polite-crawler repo. Acceptance criteria are testable in CI without live network access unless stated.
1. Robots gatekeeper (2–3 h)
Build RobotsCache::new(host) that fetches /robots.txt once per host, parses with texting_robots, and answers allowed(url).
Acceptance: passes a fixture suite covering wildcard/$ paths, longest-match and Allow-wins-ties, case sensitivity, missing file (allow all), 5xx (disallow + backoff), 429 (retry), >500 KiB truncation, and 24 h cache expiry — all with a mocked HTTP client.
2. Sitemap-first URL discovery (2–3 h)
Given a domain, discover seed URLs via (a) Sitemap: lines in robots.txt, then (b) /sitemap.xml fallback; recurse through sitemap indexes; parse lastmod.
Acceptance: on a fixture site with a gzipped sitemap index, discovers every URL exactly once (normalized, fragments stripped, 50k-per-file limit enforced); stores lastmod for scheduling.
3. Host-partitioned URL frontier (4–6 h)
Implement front queues per priority and one back queue per host with a next_allowed_at heap. Workers pull only from hosts that are due; never two concurrent fetches to one host.
Acceptance: property test with a mocked clock: load 10 000 URLs across 50 hosts; assert zero host-overlap, per-host order preserved, and higher-priority URLs complete earlier (p95 latency lower) than low-priority.
4. Adaptive politeness limiter (3–4 h)
Start at 1 rps/host; apply Crawl-delay when present; on 429 parse Retry-After (both formats) and sleep max(backoff, Retry-After); on 5xx halve the rate; after 20 consecutive 2xx/304s, increase rate by 10% up to 5 rps.
Acceptance: against a local mock server that 429s above 2 rps, the crawler converges below 2 rps within 10 s; a Retry-After: 2 response is honored within ±100 ms; unit tests pin every transition.
5. Backoff correctness with full jitter (1–2 h) Wrap fetches in retry logic for 5xx/timeouts only; 404/410 are terminal; max 5 attempts or 60 s total. Acceptance: unit tests with seeded RNG assert each sleep ∈ [0, min(cap, base·2^n)]; 404 attempts == 1; a timeout sequence never exceeds the 60 s budget; all retries are recorded per host in stats.
6. Conditional GET + freshness scheduling (2–3 h)
Persist etag/last_modified; send validators on every re-crawl; on 304 bump freshness without refetching the body; implement recrawl interval adaptation (shorten on change, geometric lengthening on 304 streaks).
Acceptance: second pass over an unchanged fixture returns ≥90% 304s and zero body downloads; a URL modified between passes gets a 200 and its interval halves; intervals are visible in the DB and a report.
7. Persistent frontier in SQLite (4–5 h)
Port the frontier to SQLite with WAL, claim/lease semantics, and host rate state.
Acceptance: start a crawl, kill -9 mid-run, restart: no URL is fetched twice (verified by server-side request log), no duplicate enqueue, and total pending+done counts match the input URL count; write throughput ≥ 1 000 enqueues/s in a micro-benchmark.
8. Capstone: polite domain crawler + run report (3–4 h)
Crawl 5 small, crawl-friendly real sites (e.g. your own site, example.com, a docs site) to depth 3 with all features enabled; emit per-host stats.
Acceptance: zero robots violations, zero per-host rate violations (verified from the request log), ≥95% of fetches succeed or 304, all state persisted, Ctrl-C mid-crawl resumes cleanly, and crawl-report.md lists requests/host, 429/5xx counts, 304 rate, average delay, and top disallowed paths hit from links.
Common Pitfalls / What to Skip
- Don't build anti-bot evasion for this role. Stealth plugins, proxy rotation to dodge blocks, CAPTCHA solvers, and TLS fingerprint spoofing are Phase 5+ questions at best and ethically/legally fraught; Exa's crawler team hires for politeness, reliability, and scheduling at web scale. Getting blocked is a politeness failure, not a fingerprinting problem.
- Legality framing: robots.txt is a convention, not a law, but it is evidence of site intent. Terms of service, the CFAA (see hiQ v. LinkedIn), EU DSM Art. 4 TDM opt-outs, GDPR (personal data), and paywall/login boundaries all matter; don't crawl behind auth, don't bypass technical protections, and get review before crawling a new target class.
- A global semaphore is not politeness. Limiting total concurrency to 100 still hammers a single host with 100 requests. Politeness is per-host and time-based.
- Ignoring
Retry-Afteror clamping it down. Never retry sooner than the server asked; choosemax(backoff, Retry-After)and reschedule durably if it exceeds your budget. - Un-jittered exponential backoff. Thundering herds are the classic failure; use full jitter and bound total attempts and elapsed time.
- Resolving DNS per request. Cache with TTLs and negative results (
hickory-resolver); DNS becomes a bottleneck at scale. - Memory-only frontier. You will lose days of crawl on restart; persist from day one (SQLite WAL, single writer).
- Weak URL canonicalization. Fragments, tracking params, trailing slashes, percent-encoding, and
http→httpsvariants all cause duplicates; build the canonicalizer early with fixture tests. - Spider traps. Calendars, infinite pagination, and session IDs can explode the frontier; cap depth and URLs-per-host and detect near-infinite patterns.
- Treating
Crawl-delayas universal. Google ignores it; Bing/Yandex/Common Crawl honor it. Honor it anyway, and never use its absence as license. - Confusing 5xx with "disallow". For robots.txt, 5xx means temporarily unreachable — back off and reuse cache; for resources, retry with backoff, don't drop the URL.
- What to skip for now: building a distributed queue (Redis/Kafka/RocksDB) before a single-node crawler is correct; Postgres before multi-node; implementing your own jitter math before reading AWS's article; reading all of IR Book; browser rendering (Phase 5).
Communities
| Community | Link | Format | Cost | Best for |
|---|---|---|---|---|
| r/webscraping | https://www.reddit.com/r/webscraping/ | Forum | Free | Practical crawling/scraping questions; robots and politeness debates show up regularly. |
| r/rust | https://www.reddit.com/r/rust/ | Forum | Free | Rust crate recommendations and async pitfalls from working engineers. |
| Rust Users Forum | https://users.rust-lang.org/ | Forum | Free | Help with Tokio/reqwest/borrow-checker issues in your crawler. |
| Official Rust Discord | https://discord.gg/rust-lang | Chat | Free | Fast interactive help in #help; find collaborators for Phase 6. |
| Scrapy Community | https://scrapy.org/community/ | Portal | Free | Mailing list/Discord around the framework whose scheduler and throttle designs you're copying. |
| Crawlee Discord | https://discord.gg/jyEM2PRvMU | Chat | Free | Request-queue, retry, and scaling design discussions from the Apify team. |
| Spider Discord | https://discord.spider.cloud | Chat | Free | Direct line to the maintainers of the leading Rust crawler. |
| HTTP WG | https://httpwg.org/ | Standards group | Free | Follow RFC 9309/9110 errata and HTTP semantics discussions at the source. |
| awesome-web-scraping | https://github.com/lorien/awesome-web-scraping | List | Free | Curated crawler/scraper tooling by language when you want to compare implementations. |
JS Rendering, Browsers & CDP (HTTP-First, Browser-Fallback)
Exa crawls and parses tens of billions of pages, refreshing the index continuously (see exa-d), so the crawler team's job is coverage per dollar: fetch everything you can over HTTP, and spend a browser only where the content truly is not in the response. Browser rendering costs 10-50x a plain fetch (CPU, RAM, latency, per-request overhead), so the routing decision — "does this URL need a browser?" — is the highest-leverage code you will write. This phase gets you fluent in CDP, Playwright, Rust browser crates, and a hybrid architecture where the browser is a fallback for dynamic content, never an anti-bot tactic.
Version snapshot: verified September 2026. chromiumoxide 0.9.1, headless_chrome 1.0.22, fantoccini 0.22.1, playwright-rs 0.18.0, thirtyfour 0.37.5, spider 2.53.9.
Start Here (core path)
Read in this order; everything here is free.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Getting Started with CDP | https://github.com/aslushnikov/getting-started-with-cdp | Guide + tiny JS client | Free | The single best CDP primer: targets, sessions, flatten, JSON-RPC framing, and why you should usually use Playwright instead of raw CDP |
| Chrome DevTools Protocol home | https://chromedevtools.github.io/devtools-protocol/ | Reference | Free | Canonical protocol home, versions (tot / 1.3 / v8), HTTP endpoints, and domain index |
| Playwright Network guide | https://playwright.dev/docs/network | Docs | Free | The industry-baseline way to route/block/mock requests; maps 1:1 onto CDP Fetch |
| Playwright Auto-waiting | https://playwright.dev/docs/actionability | Docs | Free | Exact actionability checks behind locators — the correct replacement for sleep() and networkidle |
| Playwright Python — Library | https://playwright.dev/python/docs/library | Docs | Free | Python sync/async API; the fastest path to a working render-and-extract script |
| Andrew Chan — Crawling a billion pages in 24h | https://andrewkchan.dev/posts/crawler.html | Blog post | Free | HTTP-first crawler at Exa-like scale, with a candid section on how much of the web is still crawlable without JS (and the rendering elephant) |
| ExaSearchBot crawler policy | https://crawler.exa.ai/ | Web page | Free | Exa's own published crawler behavior: identification, robots.txt, rate limiting — the framing your portfolio should mirror |
| Exa — exa-d data framework | https://exa.ai/blog/exa-d | Blog post | Free | Why heterogeneous content (HTML, JS apps, PDFs) and frequent updates make "render only when required" a systems problem at Exa |
StormCrawler JsRenderingDetector |
https://github.com/apache/stormcrawler | Java source + docs | Free | Production-grade SPA-shell detection + DelegatorProtocol routing: the exact hybrid pattern you will reimplement in Rust |
ketch SPA detector (detect.go) |
https://github.com/1broseidon/ketch/blob/main/extract/detect.go | Go source | Free | Readable heuristic detector: visible-text scan, hydration markers, script/text ratio, static / likely_shell / ambiguous |
A 30-line Python probe that answers "does this page need a browser?" from the raw HTTP response: https://dev.to/0012303/a-30-line-probe-that-tells-you-if-a-page-needs-a-browser-1pj
Chrome DevTools Protocol Fundamentals (docs + guides)
CDP is Chromium's native instrumentation protocol: JSON-RPC over a WebSocket, organized into domains of methods and events. Chrome DevTools itself, Puppeteer, and Playwright's Chromium path all speak it. It is Chromium-only (Firefox and WebKit have their own debug protocols), and it has stable plus experimental surfaces.
Core mental model (from Getting Started with CDP):
- Start Chrome with
--remote-debugging-port=9222(or=0for a random port; the WS URL is printed to stderr and written toDevToolsActivePort). - HTTP endpoints:
GET /json/version(browser WS URL),GET /json/list(targets),GET /json/protocol(full protocol JSON),PUT /json/new?{url},WS /devtools/page/{targetId},WS /devtools/browser/{id}. - Commands are
{id, method, params}; responses echoid; events have noid. Message order is meaningful (e.g.,Target.setDiscoverTargetsreports all current targets before its response). - Targets are pages, workers, iframes, service workers, the browser itself. You attach with
Target.attachToTarget {targetId, flatten:true}and get asessionId; every subsequent message for that target carriessessionId. Flattened sessions are the only mode worth learning; non-flat is deprecated. - Sessions form a hierarchy: the WebSocket connection is an implicit root browser session; page sessions are children and close when the parent detaches.
- Protocol state is per session (
Network.enable,Runtime.enable,Page.enablemust be called on the session you listen on). - Stable vs experimental: prefer stable; expect experimental churn. High-level libraries exist partly to absorb that churn.
Domains that matter for crawling:
| Domain | Link | Key methods/events | Use for |
|---|---|---|---|
| Target | https://chromedevtools.github.io/devtools-protocol/tot/Target/ | getTargets, attachToTarget, createTarget, setAutoAttach, attachedToTarget |
Discovering and attaching to pages, tabs, workers |
| Page | https://chromedevtools.github.io/devtools-protocol/tot/Page/ | navigate, lifecycleEvent, loadEventFired, captureScreenshot, printToPDF, addScriptToEvaluateOnNewDocument, getLayoutMetrics |
Navigation, lifecycle-based waiting, screenshots/PDF |
| Network | https://chromedevtools.github.io/devtools-protocol/tot/Network/ | enable, requestWillBeSent, responseReceived, loadingFinished, getResponseBody, setUserAgentOverride |
Observing requests, capturing XHR/JSON payloads, timing, UA control |
| Runtime | https://chromedevtools.github.io/devtools-protocol/tot/Runtime/ | evaluate, consoleAPICalled, exceptionThrown |
Extracting rendered DOM/values (document.documentElement.outerHTML, returnByValue:true), console capture |
| Fetch | https://chromedevtools.github.io/devtools-protocol/tot/Fetch/ | enable, requestPaused, continueRequest, failRequest, fulfillRequest, getResponseBody |
Pause-and-decide interception: block resources, mock responses, capture bodies |
| Emulation | https://chromedevtools.github.io/devtools-protocol/tot/Emulation/ | setDeviceMetricsOverride, setGeolocationOverride, setTimezoneOverride |
Deterministic viewport/device/locale rendering |
| Browser | https://chromedevtools.github.io/devtools-protocol/tot/Browser/ | getVersion, close |
Version checks and clean shutdown |
| DOM / DOMSnapshot | https://chromedevtools.github.io/devtools-protocol/tot/DOM/ | getDocument, DOMSnapshot.captureSnapshot |
Serializing the live, post-JS DOM when Runtime.evaluate is not enough |
Fetch interception mechanics: Fetch.enable with patterns of {urlPattern, resourceType, requestStage} (wildcards * = zero or more, ? = one; stages Request or Response). Matching requests emit Fetch.requestPaused and stall until you call continueRequest, failRequest, or fulfillRequest. Response-stage interception (requestStage: "Response") is how you read bodies before the page gets them. This is the low-level version of Playwright's page.route().
Useful extra resources:
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Vanilla protocol viewer | https://vanilla.aslushnikov.com/ | Interactive reference | Free | Fast domain browsing vs. the paginated official docs; experimental bits highlighted |
| devtools-protocol repo | https://github.com/ChromeDevTools/devtools-protocol | Protocol JSON + TS types | Free | Canonical JSON/TypeScript definitions; npm devtools-protocol for typed clients |
| CDP contribution guidelines | https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/public/devtools_protocol/ | Spec/design doc | Free | Understand domains, agents, targets, sessions before designing your own CDP client |
| Chrome headless mode | https://developer.chrome.com/docs/automation-and-testing/headless | Docs | Free | --headless semantics and Puppeteer launch options |
| Removing old headless | https://developer.chrome.com/blog/removing-headless-old-from-chrome | Blog post | Free | Old headless is gone from the Chrome binary since 132; chrome-headless-shell is the standalone legacy binary |
| Chrome for Testing | https://googlechromelabs.github.io/chrome-for-testing/ | Downloads + API | Free | Pinned Chrome/chrome-headless-shell builds for reproducible workers |
| CDP vs WebDriver deep dive | https://dev.to/dreygur/browser-automation-protocols-cdp-vs-webdriver-deep-dive-5bmn | Blog post | Free | Side-by-side protocol comparison with capability and fingerprint notes |
| WebDriver vs CDP vs BiDi | https://substack.thewebscraping.club/p/webdriver-vs-cdp-vs-bidi | Article | Free | Where WebDriver BiDi fits and what it will/won't replace |
| WebDriver BiDi spec | https://w3c.github.io/webdriver-bidi/ | Standard | Free | The cross-browser, bidirectional future of WebDriver |
Protocol choice at a glance:
| Feature | WebDriver Classic | CDP | WebDriver BiDi |
|---|---|---|---|
| Standard | W3C Recommendation | Chromium-internal JSON-RPC | W3C Working Draft |
| Transport | HTTP request/response | WebSocket, bidirectional | WebSocket, bidirectional |
| Browsers | Chrome, Firefox, Safari (+ drivers) | Chromium only | Chrome + Firefox (Safari in progress) |
| Network interception | No | Yes (Fetch) |
Yes (spec'd) |
| Console/events | Polling only | Push events | Push events |
| Crawler fit | Cross-browser tests | Rendering, interception, extraction | Future cross-browser rendering |
Playwright & TypeScript/Python Baseline
Playwright is the industry baseline for browser automation and the right way to learn rendering behavior before touching Rust. It drives Chromium, Firefox, and WebKit with one API, ships first-class auto-waiting, and exposes network interception cleanly. For crawler work you will use the library API, not the test runner.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Playwright library (Node) | https://playwright.dev/docs/library | Docs | Free | Launch browser, navigate, extract — the core scraping loop in ~10 lines |
| Playwright Python library | https://playwright.dev/python/docs/library | Docs | Free | Sync and async Python APIs; REPL for interactive exploration |
| Playwright locators | https://playwright.dev/docs/locators | Docs | Free | Role/text/test-id locators that survive re-renders; strictness semantics |
| Playwright auto-waiting | https://playwright.dev/docs/actionability | Docs | Free | The five actionability checks; why locators beat manual sleeps |
| Playwright network | https://playwright.dev/docs/network | Docs | Free | route/continue/abort/fulfill, glob patterns, waitForResponse, WebSockets |
| Playwright mock APIs | https://playwright.dev/docs/mock | Docs | Free | HAR replay and API mocking; useful for deterministic extraction tests |
| Playwright screenshots | https://playwright.dev/docs/screenshots | Docs | Free | Full-page/element screenshots, masks, fullPage — debugging and QA |
| Playwright browsers | https://playwright.dev/docs/browsers | Docs | Free | Browser channels: chromium (new headless) vs chromium-headless-shell |
| Playwright trace viewer | https://playwright.dev/docs/trace-viewer | Docs + tool | Free | Post-mortem of a failed render: DOM snapshots, network, console per action |
| Playwright Python network | https://playwright.dev/python/docs/network | Docs | Free | Same interception recipes with page.route and route.request.resource_type |
| Puppeteer — request interception | https://pptr.dev/guides/network-interception | Guide | Free | The CDP-native blocking pattern, including cooperative intercept priorities |
Puppeteer API setRequestInterception |
https://pptr.dev/api/puppeteer.page.setrequestinterception | API reference | Free | Minimal abort-all-images example and stall semantics |
| Puppeteer repo | https://github.com/puppeteer/puppeteer | Source | Free | Read Connection.ts/CDPSession to see raw CDP done well |
| Playwright repo | https://github.com/microsoft/playwright | Source | Free | Internals of auto-waiting and Chromium interception |
| Playwright Python repo | https://github.com/microsoft/playwright-python | Source | Free | Python binding architecture and async examples |
| WebDriver vs CDP explainer (Chrome) | https://developer.chrome.com/blog/test-automation-evolution | Blog post | Free | Authoritative history of WebDriver, CDP, and BiDi |
Semantics to internalize before writing any Rust:
page.goto(url, waitUntil)supportscommit,domcontentloaded,load,networkidle.networkidleis discouraged even by Playwright's own docs — prefer waiting for a specific response/selector/locator state.- Locators auto-wait and auto-retry; a locator re-resolves on every action. Use
locator.waitFor({state: 'visible'}),page.waitForResponse,page.waitForFunctionfor explicit readiness. - Registration order matters for routes: handlers run in reverse registration order; use
route.fallback()to delegate rather than ending a request, androute.abort('blockedbyclient')(not bareabort()) so pages treat it as a content blocker rather than a network fault. - Resource types from
request.resourceType()(equivalently CDPNetwork.ResourceType):document,stylesheet,image,media,font,script,texttrack,xhr,fetch,eventsource,websocket,manifest,other. Blockimage/media/fontfor text extraction; teststylesheetbefore blocking (layout-dependent JS can break). - If routes appear to miss requests, a service worker is serving them; set
serviceWorkers: 'block'on the context. browser.newContext()is the isolation unit. One browser process can serve many contexts — reuse the process, isolate the contexts.connectOverCDP()attaches to an existing browser; useful when another component owns the browser lifecycle, but lower fidelity than Playwright's own protocol.
WebDriver vs CDP in one line: WebDriver is a cross-browser W3C standard that needs a driver binary and cannot intercept network traffic; CDP is Chromium-only, WebSocket-based, and exposes everything (interception, console, emulation, profiling). For crawling, CDP (via Playwright/Puppeteer/chromiumoxide) is the technology; WebDriver/BiDi only matters if you need non-Chromium engines.
Rust Browser Crates (chromiumoxide, fantoccini, headless_chrome, playwright) — comparison table
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| chromiumoxide 0.9.1 | https://github.com/mattsse/chromiumoxide | Crate (async CDP) | Free (MIT/Apache-2.0) | The default Rust choice: tokio-native, full generated CDP type coverage, launch or connect; used by spider-rs |
| headless_chrome 1.0.22 | https://github.com/rust-headless-chrome/rust-headless-chrome | Crate (sync CDP) | Free (MIT) | Blocking scripts and tests; Puppeteer-like API with request interception, screenshots, PDF, optional Chromium auto-download |
| fantoccini 0.22.1 | https://github.com/jonhoo/fantoccini | Crate (async WebDriver) | Free (Apache-2.0) | Cross-browser, standards-based automation when you need Firefox/geckodriver; no network interception |
| playwright-rs 0.18.0 | https://github.com/padamson/playwright-rust | Crate (Playwright bindings) | Free (MIT/Apache) | Full Playwright semantics (auto-waiting, cross-browser, traces) from Rust without hand-writing CDP |
| thirtyfour 0.37.5 | https://github.com/stevepryde/thirtyfour | Crate (async WebDriver) | Free (MIT/Apache) | More actively developed Selenium/WebDriver client if WebDriver is a hard requirement |
| spider 2.53.9 | https://github.com/spider-rs/spider | Crate (crawler + Chrome feature) | Free (MIT) | Reference Rust crawler with an optional chromiumoxide-backed rendering path — study, don't depend on |
| Lightpanda | https://github.com/lightpanda-io/browser | Zig headless browser | Free (self-host) | Watching/experimenting: CDP-compatible engine claiming ~9-16x lower memory than Chrome; WIP fidelity |
| Lightpanda site/benchmarks | https://lightpanda.io/ | Docs/benchmarks | Free/hosted | Vendor benchmark methodology for browser memory economics (treat numbers as vendor claims) |
Engineering notes (choose with these in mind):
- chromiumoxide — version 0.9.1 (Feb 2026). Async only, tokio-only. Every CDP domain is generated from the protocol PDL into
chromiumoxide_cdp(~60K lines), so expect slow first compiles and a large dependency tree.Page::executeaccepts any generatedCommand, so Fetch/Network/Emulation are all reachable even when no convenience wrapper exists. Best fit for a long-lived, high-throughput browser worker. - headless_chrome — version 1.0.22 (Jun 2026). Synchronous, thread-based; closest to Puppeteer ergonomically. Has request interception, JS coverage, element/page screenshots, PDF, headful mode, and a
fetchfeature that downloads a known-good Chromium. Known gaps (stated in its README): frames, file choosers, touch, network/CPU emulation, request timing, SSL inspection, EventSource/WebSocket inspection. Great for tools and tests; awkward when you need thousands of concurrent fetches in one async runtime. - fantoccini — version 0.22.1 (Feb 2026). WebDriver client; you run
geckodriver/chromedriveryourself on port 4444. Tokio-based, CSS/XPATH locators, forms, waits, andClient::sourcefor post-JS HTML. Because WebDriver has no interception or lifecycle-event API, your waiting strategy degrades to polling. Choose it when cross-browser compatibility matters more than depth. - playwright-rs — version 0.18.0 (Sep 2026), pre-1.0 but rapidly released. Follows Microsoft's binding architecture: it downloads the Playwright driver (Node 18+ required) and speaks to it, so you get Playwright's auto-waiting, contexts, cross-browser support, and traces with Rust ergonomics. Browser install is a separate
npx playwright@<matching> installstep, and the crate pins an exact driver version. Good choice if you value Playwright semantics over raw CDP control; expect API churn before 1.0. - Do not use the old
playwrightcrate (octaltree lineage) — unmaintained;playwright-rsis the current project. (crates.io names:playwright-rs, docs at https://docs.rs/playwright-rs.) - Recommendation for this roadmap: prototype in Playwright TS/Python, then build the Rust worker on chromiumoxide. Use headless_chrome for quick one-off scripts and fantoccini/thirtyfour only if a WebDriver requirement appears.
Crate pages: chromiumoxide · headless-chrome · fantoccini · playwright-rs · thirtyfour · spider
Hybrid Routing: When to Render vs HTTP Parse
The architecture that scales to 100M+ pages/day is boring on purpose: most URLs never touch a browser.
frontier ──► scheduler ──► HTTP fetcher pool (reqwest, high concurrency)
│ │
│ ▼
│ parse + extract (scraper/lol-html)
│ │
│ ▼
│ shell detector ──► static / parseable ──► index
│ │
│ └──► likely_shell ──► browser queue (bounded)
│ │
│ ▼
│ browser worker pool (CDP)
│ render + wait + extract
│ │
└◄────────────── decision cache (per URL + per host) ◄──────┘
Rules that make it work:
- Always HTTP first. One cheap GET answers both "can I get the data?" and "does this host need a browser?". Only the parse result decides escalation.
- Try embedded data before a browser. A huge share of "JavaScript sites" are SSR frameworks that ship the data in the first response:
__NEXT_DATA__(Next.js),window.__NUXT__(Nuxt),window.__INITIAL_STATE__, JSON-LD. Parsing that JSON is a superset of rendering for extraction purposes and costs ~0 extra milliseconds. A browser is the last resort, not the second. - Prefer capturing the XHR/fetch API over the DOM when the SPA loads JSON. With Playwright,
page.waitForResponse('**/api/**')+response.json(); with CDP,Network.responseReceived+Network.getResponseBody(orFetchat response stage). Structured JSON is more stable than DOM selectors and cheaper than waiting for paint. - Cache the decision at host level. If 3 sampled URLs on
shop.exampleare all shells, route subsequentshop.exampleURLs to the browser queue directly; if a host is static, never render it again. Expire decisions (e.g., 7 days) and keep a small per-host confidence counter. - Keep one politeness budget. Browser-rendered pages fire dozens of subresource requests. Per-domain rate/token buckets must cover both HTTP and browser requests, or rendering will silently multiply your load by 10-30x.
ambiguousis notrender. Send ambiguous URLs to a sampled browser check, then decide for the batch — do not default to Chrome.
SPA-shell detection signals (cheapest first, short-circuit):
| Signal | Heuristic | Action |
|---|---|---|
| Visible text | Strip tags/scripts; < 200-500 chars on a non-trivial URL | Suspect |
| Empty hydration roots | <div id="root"></div>, #app, #__next, #nuxt with no children |
Suspect |
| Framework fingerprints | data-reactroot, ng-version=, __NEXT_DATA__, __NUXT__, data-svelte-h, data-vue-app, data-astro-cid, q:container, astro-island, __next_f |
Suspect (but if a data blob is present, parse it — don't render) |
<noscript> copy |
"enable JavaScript", "requires JavaScript", "JavaScript is disabled" | Suspect |
| Script-to-text ratio | Many <script> tags / large script bytes vs. little visible text |
Suspect |
| Needle test | Known expected value (price, title, ID) absent from raw text | Strongest — render |
| Outcome test | Render a sample, diff text length/fields vs. HTTP parse | Escalate the host if delta is large |
Reference implementations to mirror: ketch detect.go (text + markers + ratio, returns static/likely_shell/ambiguous), StormCrawler's JsRenderingDetector (fingerprints → noscript → empty root → thin-content fallback, stamps a routing key), and the 30-line Python probe (visible-text size, hydration blob, needle).
Render when: content is client-only; data arrives via authenticated/signed XHR you cannot reproduce; interaction (clicks, scroll, pagination) is required; login/session state matters; or you are sampling to validate your HTTP extractor.
Do not render when: the data is already in the HTML or an embedded JSON blob; a public JSON API/feed/sitemap provides it; you only need head metadata; the page is a bot wall (rendering won't change the outcome and attempting to defeat it is out of scope); or robots.txt/ToS disallow the fetch in the first place.
Cost, Latency & Browser Pool Operations
Numbers worth memorizing (with sources): a browser fetch is typically 10-50x slower than an HTTP fetch and is concurrency-limited by RAM (StormCrawler); a headless Chrome tab runs ~200-500MB and takes ~1-5s to start; blocking image/media/font cut transfer by 67% on an image-heavy catalogue but can add latency because every request passes through a handler (measure medians, not one run). Hosted browser APIs land around $6-18 per 1,000 usable pages at medium volume once proxies/ops are included; self-hosting wins on steady high volume but only if you can staff the fleet. Lightpanda claims ~20MB/instance and 9-16x less memory than Chrome by skipping graphical rendering — treat as vendor benchmarks and validate on your targets.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Playwright browser contexts | https://playwright.dev/docs/browser-contexts | Docs | Free | Isolation unit: reuse one browser process, one context per job |
Playwright connectOverCDP |
https://playwright.dev/docs/api/class-browsertype | API reference | Free | Attaching to a browser owned by another process — the core of a browser-as-a-service pool |
| Browserless repo | https://github.com/browserless/browserless | Source (self-host/cloud) | OSS + paid cloud | Production pool/API reference: session lifecycle, health, autoscaling, Docker-in-VPC |
| Steel browser repo | https://github.com/steel-dev/steel-browser | Source (self-host/cloud) | OSS + paid cloud | Open-source browser API with sessions/CDP endpoints; Rust SDK pairs with chromiumoxide |
| Steel Rust/Go SDKs | https://steel.dev/blog/rust-go-sdks | Blog + SDKs | Free SDK | Concrete pattern: session create → hand CDP URL to chromiumoxide → release |
| Steel docs | https://docs.steel.dev/ | Docs | Free/paid | Session/profile/replay API design to copy for your own worker pool |
| Browserless docs | https://www.browserless.io/docs/ | Docs | Free/paid | Hosted/browser pool operational docs and connection patterns |
| Lightpanda architecture analysis | https://martianlee.github.io/posts/2026-03-13-lightpanda-architecture | Deep-dive | Free | Why a render-less engine cuts memory: arena allocators, V8 snapshot, CDP compat layer |
| Scrapeless blocking measurements | https://www.scrapeless.com/en/blog/block-resources-playwright-scraping-browser | Benchmarks | Free | Real byte/time tables for resource blocking — shows where blocking helps and where it doesn't |
| Cloud fleet cost benchmark | https://webscraper.cloud/benchmark-headless-browser-fleet-costs-across-cloud-provider | Benchmark | Free | Cost-per-usable-page math across self-hosted vs hosted at 50K/500K+ pages/month |
Pool design checklist:
- Fixed number of browser processes; each process hosts N contexts, one job per context. Never
launch()per URL. - Acquire/release semantics with a semaphore and a bounded queue; backpressure beats crashing.
- Recycle by
maxUses(e.g., 25-50 pages) andmaxAge(e.g., 5-10 minutes); recreate on crash or missed health check (ping viaBrowser.getVersionor a blank navigation with timeout). - One watchdog per process (
kill -9+ respawn), and a global cap on concurrent browsers derived from RAM (instances ≈ available_RAM / 400MB). - Launch flags:
--headless(new headless; old headless was removed from Chrome 132),--disable-dev-shm-usagein containers, explicit--user-data-dirper process for isolation. Keep--no-sandboxout of production unless the container is the sandbox. - Metrics: render ratio, queue wait p95, render p50/p95, pages per browser-hour, crashes/recycles per 1K pages, bytes per page with/without blocking, extraction yield after render.
- Resource blocking is a cost tool first; keep it honest as a behavioral consideration second (see next section).
- One renderer can often be replaced by one JSON capture: always test the XHR path before scaling browsers.
Legal/Ethical Framing (responsible rendering, not anti-bot bypass)
Browser rendering exists here to see dynamic content, not to defeat access controls. Write this distinction into your README and portfolio — it is exactly how Exa frames its own crawler.
- Identify yourself. Use a product token (e.g.,
YourBot/1.0 (+https://your.site/bot, contact@)) and a real contact page. Exa’s ExaSearchBot policy is the model to imitate: purpose stated, identification documented, robots.txt respected, rate-limited per site. - robots.txt is crawl guidance, not authorization. Per RFC 9309: longest-match wins; 4xx = unrestricted, 5xx = treat as fully disallowed until retried; cache up to 24 hours. A
Disallowis a strong stop signal; anAllowdoes not override ToS, auth, or rate limits. - Stop signals are stops. 429/403/CAPTCHA/paywall/login walls mean back off or stop. Do not rotate IPs, swap fingerprints, or automate CAPTCHA solving to continue. A rendered challenge page is not "coverage".
- Rendering increases load. A browser session multiplies requests per URL; account for subresources in the same per-host budget and honor
Retry-AfterandCrawl-delayacross both HTTP and browser paths. - Public visibility is not permission. hiQ v. LinkedIn and the CFAA line of cases are fact-specific, not blanket clearance; ToS and copyright still apply. Get counsel for consequential collection.
- Keep anti-bot circumvention out of scope. Stealth plugins, fingerprint spoofing, proxy rotation to evade blocks, and CAPTCHA solving are a different project with a different risk profile; nothing in this roadmap requires them.
- Prefer the sanctioned channel. API, sitemap, feed, partner agreement — every one of these beats rendering, both ethically and on cost.
Recommended Order (weeks 9-10, with time estimates)
Assumes Phases 1-4 done (Rust, Tokio, HTTP scraping, polite crawling). Total: ~38-42 hours over two weeks, ~2-3h weekdays and a 4-6h weekend block.
Week 9 — Learn the stack and measure rendering (≈18-20h)
| Day | Focus | Do this | Time |
|---|---|---|---|
| 1 | CDP fundamentals | Read Getting Started with CDP; launch Chrome with --remote-debugging-port=9222; curl /json/version, /json/list, /json/protocol; read Target/Page/Network/Fetch domain indexes |
2h |
| 2 | Raw CDP | Exercise 1: hand-roll a CDP client (targets → attach → navigate → screenshot); read Network.getResponseBody and Fetch.requestPaused docs |
3h |
| 3 | Playwright baseline | Install Playwright TS; Exercise 2 (static vs /js/ page); read actionability + locators |
3h |
| 4 | Interception | Exercise 3 (block image/media/font, measure bytes and median time over 3 runs); read Playwright network + service workers note | 2-3h |
| 5 | Detection | Exercise 4 (Rust shell detector with fixtures); read ketch detect.go and StormCrawler’s detector notes |
3h |
| 6-7 | Rust + embedded data | chromiumoxide hello world (launch, navigate, Runtime.evaluate outerHTML); Exercise 5 (parse __NEXT_DATA__ without rendering) |
5-6h |
Week 10 — Build the hybrid crawler and browser pool (≈20h)
| Day | Focus | Do this | Time |
|---|---|---|---|
| 8 | HTTP-first pipeline | Wire Rust fetcher + extractor + detector into the Phase 4 crawler; log decision reasons per URL | 3h |
| 9 | Browser fallback | Exercise 6: bounded browser worker + decision cache + shared per-host budget | 3-4h |
| 10 | Pool hardening | Exercise 7: recycling, health checks, crash recovery, metrics | 3h |
| 11 | API capture path | Exercise 8: capture XHR JSON instead of DOM; compare latency and stability | 3h |
| 12 | Cost accounting | Bytes/pages per phase; render ratio; p50/p95; write the numbers into the README | 2-3h |
| 13-14 | Polish | Demo recording, portfolio write-up, optional Lightpanda comparison on the same CDP interface | 4h |
Hands-on Exercises
Each exercise is self-contained and builds toward the final hybrid crawler. Test targets: https://quotes.toscrape.com/ (static) and https://quotes.toscrape.com/js/ (client-rendered), plus any Next.js/Nuxt site you are permitted to crawl.
1. Raw CDP client (no libraries). Launch Chrome headless with a debugging port; use the HTTP endpoints to find the page target; open a WebSocket; Target.attachToTarget (flatten:true); Page.enable; Page.navigate to example.com; wait for Page.loadEventFired; Page.captureScreenshot; decode base64 to PNG.
Acceptance: PNG opens and visibly shows the page; running twice produces two PNGs; logs correlate request ids to responses and show the sessionId on every page command; zero Playwright/Puppeteer/chromiumoxide imports.
2. Rendering delta measurement. Extract quotes from / and /js/ twice: once with HTTP-only (reqwest + scraper) and once with Playwright (page.goto + locator text).
Acceptance: HTTP-only finds 10/10 on / and 0/10 on /js/; Playwright finds 10/10 on both; a table records transport, wall ms, bytes, quotes found for all four runs; p50 of 3 runs each.
3. Resource blocking with honest measurement. Register context.route('**/*') blocking image/media/font with route.abort('blockedbyclient'); count blocked requests; measure transfer bytes and total wall time over 3 runs on 3 different pages.
Acceptance: Blocked counter > 0 on pages that have images; bytes table shows the reduction per page; a one-paragraph note identifies at least one page where blocking did not meaningfully reduce time, with numbers.
4. SPA shell detector in Rust. Implement classify(html: &str) -> Verdict (Static | LikelyShell | Ambiguous) using at least four signals from the detection table (visible text length, empty hydration root, framework fingerprint, noscript JS-required, script-to-text ratio). Build 12 fixtures: static blog, static docs, Next.js shell, Next.js SSR with __NEXT_DATA__, Nuxt, SvelteKit, Angular, Qwik, empty app root, noscript page, thin error page, real content page.
Acceptance: 12 fixtures compile into unit tests; ≥11/12 verdicts correct; the SSR-with-blob fixture returns Static (do not render); every decision prints the triggering reason string; cargo test green.
5. Embedded-data extraction (no browser). Pick a Next.js or Nuxt site you may crawl; extract structured data from __NEXT_DATA__ / window.__NUXT__ in the raw HTML. Separately load the same URL once in Playwright and extract the same fields from the DOM.
Acceptance: The extraction run provably never launches a browser (guard flag or assert no Chrome process); ≥95% field match on ≥20 records; README documents the blob markers and how your detector will recognize them.
6. Hybrid crawler that detects SPA shells and routes to a browser worker. Input: 100 mixed URLs (static pages, shells, SSR-with-blob). Pipeline: HTTP fetch → extract → detect → route shells to a bounded browser queue (Rust + chromiumoxide worker) → extract rendered DOM. Include per-URL and per-host decision caching and one shared per-host token bucket.
Acceptance: Browser renders ≤ (number of known shells + 10%); static and SSR pages have render count of exactly 0; all 100 URLs produce output with at least one non-empty extracted field; metrics line prints http_count, render_count, render_ratio, cache_hits, render_p50/p95; one command runs the whole crawl.
7. Browser pool hardening. Run 2 browser processes with context-per-job, acquire/release, maxUses=25, maxAge=5min, periodic health checks. Mid-run, kill -9 one browser.
Acceptance: Crawl of 300 URLs completes unattended; replacement spawns within 30s; counters report recycles, crashes, and timeouts; output rows == input rows (no dropped URLs); peak RSS and browsers-per-GB documented.
8. Capture the JSON API instead of the DOM. On an SPA that fetches JSON, use CDP Network.responseReceived + Network.getResponseBody (or Playwright waitForResponse) to capture the payload, and extract records from JSON.
Acceptance: Extracted record count equals rendered-DOM record count; JSON path median latency < DOM path median over 10 runs; extraction code contains no HTML selectors for the data fields; the captured endpoint and schema are documented.
Stretch: Run Exercise 6 with Lightpanda as a second CDP engine behind the same worker interface; compare memory per instance, startup time, and extraction yield, and write down where fidelity broke.
Common Pitfalls / What to Skip
- Launching a browser per URL. 1-5s startup and 200-500MB per instance. Reuse processes and contexts; recycle on a budget, not per request.
networkidleas a default wait. Playwright itself discourages it. Wait for a specific response, locator state, or lifecycle event.sleep()as a wait strategy. Race conditions and wasted seconds; use the framework's auto-waiting plus explicit conditions.- Blocking
scriptorstylesheetreflexively. Scripts are the content on SPAs; CSS can drive layout-dependent JS. Start withimage/media/font, teststylesheetper target. - Forgetting service workers. They can serve requests outside
page.route; setserviceWorkers: 'block'. - Bare
route.abort(). Preferroute.abort('blockedbyclient')so retry-on-error page logic doesn't loop. - Trusting
--headless=old. Removed from the Chrome binary in 132;chrome-headless-shellis the standalone legacy binary, not the new headless mode. - Using the unmaintained
playwrightcrate. Useplaywright-rs(padamson). - Expecting CDP cross-browser. CDP is Chromium-only; use WebDriver/WebDriver BiDi for Firefox/Safari.
- Rendering anti-bot challenges. Out of scope; treat 403/429/CAPTCHA as stop signals, not problems for Playwright to solve.
- Stealth/anti-detect stacks as a strategy.
puppeteer-extra-plugin-stealth, fingerprint spoofers, and proxy rotation to evade blocks add risk without improving indexing quality; skip them in this roadmap. - Screenshot-based extraction. Screenshots are for debugging/QA; extract from DOM or JSON.
- Leaving
Network.enable/Fetch.enableon with no consumer. They add per-request overhead; enable only while capturing or intercepting. - Counting renders as success. Track render ratio, bytes, and cost per usable page; a high render rate is a routing failure.
- Ignoring the subresource budget. Rendered pages generate 10-30x the requests; your politeness layer must count them.
Open-Source Repos to Read
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| apache/stormcrawler | https://github.com/apache/stormcrawler | Java | Free | JsRenderingDetector + DelegatorProtocol: the canonical hybrid HTTP→Playwright routing pattern |
| SaiSandeepKantareddy/hybrid_crawler_js | https://github.com/SaiSandeepKantareddy/hybrid_crawler_js | Python | Free | Small end-to-end hybrid crawler: heuristic scorer + conditional Playwright rendering |
| scrapy-plugins/scrapy-playwright | https://github.com/scrapy-plugins/scrapy-playwright | Python | Free | How a mature crawler integrates browser rendering as an opt-in download handler |
| mendableai/firecrawl | https://github.com/mendableai/firecrawl | TypeScript | Free (self-host) | Production OSS crawler with JS rendering, caching, and markdown extraction pipelines |
| spider-rs/spider | https://github.com/spider-rs/spider | Rust | Free | Rust crawler with a chrome feature showing chromiumoxide wired into a frontier/scheduler |
| microsoft/playwright | https://github.com/microsoft/playwright | TypeScript | Free | Auto-waiting, actionability, and interception internals — the reference semantics |
| puppeteer/puppeteer | https://github.com/puppeteer/puppeteer | TypeScript | Free | The cleanest raw CDP connection/session implementation to model your client on |
| ChromeDevTools/devtools-protocol | https://github.com/ChromeDevTools/devtools-protocol | JSON/TS | Free | Protocol definitions and generated types; the source of truth for your Rust bindings |
| aslushnikov/getting-started-with-cdp | https://github.com/aslushnikov/getting-started-with-cdp | JavaScript | Free | Minimal client that makes targets, sessions, and JSON-RPC click |
| lightpanda-io/browser | https://github.com/lightpanda-io/browser | Zig | Free | A second CDP engine and an extreme memory-efficiency reference |
| browserless/browserless | https://github.com/browserless/browserless | TypeScript | Free/paid | Pool lifecycle, health checks, and browser-as-a-service API design |
| steel-dev/steel-browser | https://github.com/steel-dev/steel-browser | TypeScript | Free/paid | Open-source session/profile/replay infrastructure; Rust SDK hands CDP URLs to chromiumoxide |
| 1broseidon/ketch | https://github.com/1broseidon/ketch | Go | Free | SPA shell detection with explainable verdicts (static/likely_shell/ambiguous) |
| krisgiyan/dynamic-source-code-detector | https://github.com/krisgiyan/dynamic-source-code-detector | TypeScript | Free | External detector that classifies csr/ssr/ssg and recommends fetch vs headless |
Distributed Crawlers & Observability
Exa runs a distributed crawl fleet over 500B+ webpages, targeting 100M+ pages/day, and its crawler team owns the unglamorous core: scheduling, queues, partition ownership, at-least-once delivery, and the telemetry that tells you why the frontier stopped draining at 3am. Phase 6 is where your single-machine crawler becomes a fleet and where you learn to prove it is healthy: queues and partitioning, idempotent writes, checkpointing and backpressure, metrics/tracing/dashboards, containerized deployment, and profiling. Everything else in your portfolio is judged through this lens — "can this person operate a web-scale crawl?"
How to use this file: work the Start Here path first, use the tables as a menu, and build the week-11 exercises at the bottom. Links were checked in September 2026; prefer docs.rs over crates.io (crates.io blocks automated checks).
Start Here (core path)
Do these in order. Everything else in this file is optional depth.
| # | Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|---|
| 1 | Exa — "How we're building the next generation of search" | https://exa.ai/blog/how-to-build-nextgen-search | Blog | Free | Grounds the phase: distributed crawl network, custom HTML parser, S3 storage, quality filtering |
| 2 | Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, March 2026) — stream-processing + distributed-systems chapters | https://dataintensive.net/ | Book | Paid | The mental model behind queues, partitions, offsets, replication, and failure handling |
| 3 | Hello Interview — Design a Web Crawler | https://www.hellointerview.com/learn/system-design/problem-breakdowns/web-crawler | Guide | Free | The best free end-to-end crawler design: frontier, DNS, politeness, retries, DLQ, dedup |
| 4 | NATS JetStream concepts | https://docs.nats.io/concepts/jetstream | Docs | Free | Pick one broker and learn it deeply — JetStream is the easiest to self-host and Rust client is first-class |
| 5 | async-nats docs (JetStream pull consumers) | https://docs.rs/async-nats/latest/async_nats/ | Docs | Free | The Rust API you will actually write: streams, durable consumers, acks |
| 6 | MainMatter — Telemetry for Rust APIs workshop | https://rust-exercises.com/telemetry | Workshop | Free | Hands-on tracing + metrics exercises; fastest route to production-grade observability |
| 7 | tower-resilience | https://github.com/joshrotenberg/tower-resilience | Repo | Free | Circuit breaker, bulkhead, retry budgets, fallback, adaptive concurrency — all as Tower layers |
| 8 | Docker Compose | https://docs.docker.com/compose/ | Docs | Free | Turn your crawler into a 4-service stack the whole team (and interviewer) can run |
Message Queues & Streams
The frontier is a queue — but the abstraction matters more than the broker. Design a Queue trait in your project so a reviewer sees you understand the tradeoffs, then implement one backend well.
Broker comparison (for a web-crawl frontier)
| Broker | Model | Delivery / ordering | Throughput & latency | Ops burden | Rust client | Best for this project |
|---|---|---|---|---|---|---|
| Apache Kafka | Partitioned append-only log | At-least-once; exactly-once via transactions; per-partition order | 1M+ msg/s/cluster; p50 5–15ms batched | High (brokers, ZK/KRaft, retention tuning) | rdkafka (librdkafka), rskafka (pure Rust) |
Industry-default resume keyword; long retention and replay; if you already run Kafka |
| NATS JetStream | Stream + durable consumers (built on RAFT) | At-least-once; exactly-once per stream; per-subject order | ~1M msg/s JetStream; sub-ms core, 2–5ms persisted | Very low — single binary, easy clustering | async-nats 0.50 (official, Tokio) |
Best learning default: quick to run in Compose, ack/replay semantics, KV + object store built in |
| Redis Streams | In-memory append-only log | At-least-once via consumer groups, PEL, XACK, XAUTOCLAIM | 500K+ msg/s; sub-ms | None if Redis already in stack; memory-bound retention | redis / redis-rs |
Lightweight frontier when you already have Redis; great for seeing PEL/claim mechanics |
| AWS SQS | Managed queue (+ FIFO variant) | At-least-once; per-message-group order (FIFO); visibility timeout = redelivery | Effectively unlimited (Standard); 3K msg/s per FIFO queue with batching; 1–20ms | None (managed); cost per request | aws-sdk-sqs |
Cloud-native deployments, simple retry/DLQ via visibility timeout, no broker ops |
Key crawler-specific tradeoffs: domain affinity beats uniform hashing (you need per-domain ordering/serialization for politeness), ack-after-side-effect is non-negotiable, replay (Kafka/NATS/Redis) lets you reprocess after a parser bug, and managed queues can't give you replay. Pulsar/RabbitMQ/Redpanda are out of scope for week 11 — don't learn a fourth broker.
Rust client resources
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
rdkafka docs |
https://docs.rs/rdkafka/latest/rdkafka/ | Docs | Free | Async Kafka producer/consumer (FutureProducer, StreamConsumer); the client production Rust shops use |
| rust-rdkafka repo + examples | https://github.com/fede1024/rust-rdkafka | Repo | Free | At-least-once consumer example and manual commit patterns you can copy |
rskafka |
https://github.com/influxdata/rskafka | Repo | Free | Pure-Rust minimal Kafka alternative — no C toolchain; no consumer groups, so you own offset tracking |
async-nats docs |
https://docs.rs/async-nats/latest/async_nats/ | Docs | Free | Official Tokio NATS/JetStream client: publish, pull consumers, KV, object store |
| nats.rs JetStream examples | https://github.com/nats-io/nats.rs/tree/main/async-nats/examples | Repo | Free | Runnable pull/push consumer code — start here for a worker loop |
| Redis Streams docs (Rust code tabs) | https://redis.io/docs/latest/develop/data-types/streams/ | Docs | Free | XADD/XREADGROUP/XACK/XAUTOCLAIM semantics, PEL recovery, trimming |
redis-rs docs |
https://docs.rs/redis/latest/redis/ | Docs | Free | Async/sync Redis client with first-class Streams + consumer-group commands |
| AWS SQS Developer Guide | https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html | Docs | Free | Visibility timeout, redrive policy, DLQ, FIFO semantics — the retry model you'll copy elsewhere |
aws-sdk-sqs docs |
https://docs.rs/aws-sdk-sqs/latest/aws_sdk_sqs/ | Docs | Free | The Rust SDK client for SQS |
| AWS SDK for Rust (overview) | https://awslabs.github.io/aws-sdk-rust/ | Docs | Free | Config/credentials setup for any AWS service, not just SQS |
| AWS Rust SQS code examples | https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/rustv1/examples/sqs | Repo | Free | Copy-paste send/receive/delete batch examples |
| Kafka vs Redis Streams vs NATS (2026) | https://dev.to/young_gao/real-time-event-streaming-kafka-vs-redis-streams-vs-nats-in-2026-34o1 | Blog | Free | Current written comparison to cite in your README/interview |
| NATS vs RabbitMQ (Synadia, 2026) | https://www.synadia.com/blog/nats-and-rabbitmq-compared | Blog | Free | When a broker (RabbitMQ) is the wrong shape for a crawl frontier |
Recommendation for week 11: NATS JetStream. One docker run nats -js, official async Rust client, at-least-once acks, replay, and KV for coordination. If you want the AWS interview signal, SQS maps 1:1 to the Hello Interview design; if you want the "distributed log" signal, Kafka + rdkafka.
Partitioning, Idempotency, Checkpointing, Backpressure
Domain partitioning / consistent hashing
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
rendezvous_hash docs |
https://docs.rs/rendezvous_hash | Docs | Free | Minimal HRW (highest-random-weight) implementation — 10 lines to use, ~1% key movement per node change |
| sile/rendezvous_hash repo | https://github.com/sile/rendezvous_hash | Repo | Free | Read the source to understand weighted rendezvous and why it avoids a ring |
simplehash (FNV/Murmur + rendezvous) |
https://github.com/cmackenzie1/simplehash | Repo | Free | RendezvousHasher plus fast non-crypto hashers for URL/domain keys |
hashring docs |
https://docs.rs/hashring | Docs | Free | Classic ring with virtual nodes if you specifically want ring semantics |
| Rendezvous hashing (Wikipedia) | https://en.wikipedia.org/wiki/Rendezvous_hashing | Reference | Free | Concise theory + why it's simpler than ring hashing for sticky assignment |
Crawler-specific rule: hash the registrable domain (e.g. news.ycombinator.com → ycombinator.com) to a worker, not the URL. That gives you (a) politeness/serialization per domain, (b) stable ownership across scale-ups, and (c) no two workers hammering the same host. URLs from one domain are then queued in order to that worker.
Idempotency (at-least-once is the only honest delivery guarantee)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Idempotency & Exactly-Once (Handbook) | https://hld.handbook.academy/curriculum/distributed-systems-theory/idempotency-exactly-once | Guide | Free | Why exactly-once delivery is impossible and idempotency keys + unique constraints are the fix |
| Idempotent Consumers in Event Streams (2026) | https://vabs.github.io/2026/06/14/idempotent-consumers-in-event-streams | Blog | Free | Inbox-table vs natural-idempotency patterns with concrete transaction mechanics |
| SQS Visibility Timeout | https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html | Docs | Free | The redelivery primitive: message becomes visible again if not acked — your crash-recovery testbed |
| rust-rdkafka at-least-once example | https://github.com/fede1024/rust-rdkafka/blob/master/examples/at_least_once.rs | Code | Free | Shows the exact "process, then commit offset" ordering that prevents message loss |
| NATS JetStream concepts (acks) | https://docs.nats.io/concepts/jetstream | Docs | Free | Explicit ack, max-deliver, and redelivery semantics for your worker loop |
Pattern to implement: canonicalize URL (strip fragments, normalize host/scheme) → url_hash = sha256(canonical) → message carries the hash → worker does INSERT ... ON CONFLICT (url_hash) DO NOTHING in the same transaction as the fetch-result write → ack the message last. Duplicates become no-ops; crashes become redeliveries.
Checkpointing
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
StreamConsumer docs (offsets) |
https://docs.rs/rdkafka/latest/rdkafka/consumer/struct.StreamConsumer.html | Docs | Free | Auto-commit vs manual offset store — the Kafka checkpoint API |
| Redis Streams (XACK / XPENDING / XAUTOCLAIM) | https://redis.io/docs/latest/develop/data-types/streams/ | Docs | Free | PEL-based crash recovery: reclaim idle pending messages instead of losing them |
| NATS JetStream concepts (durable consumers) | https://docs.nats.io/concepts/jetstream | Docs | Free | Consumer position survives restarts; replay from any sequence number |
| DDIA 2nd ed. — stream processing chapter | https://dataintensive.net/ | Book | Paid | The theory of offsets, checkpoints, and exactly-once effects |
Checkpoint at two levels: queue position (broker ack/offset) and crawl state (per-URL status: discovered / queued / fetched / parsed / dead, plus retry_count and next_attempt_at). Checkpointing only the broker offset means a parser bug forces a full re-fetch; checkpointing state lets you replay parsing from S3/WARC.
Backpressure & graceful degradation
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Tokio tutorial — Channels | https://tokio.rs/tokio/tutorial/channels | Tutorial | Free | Bounded mpsc as the backpressure primitive; await on send when downstream is slow |
tokio::sync::mpsc docs |
https://docs.rs/tokio/latest/tokio/sync/mpsc/fn.channel.html | Docs | Free | Why bounded channels apply backpressure and unbounded ones OOM |
tower::load_shed docs |
https://docs.rs/tower/latest/tower/load_shed/index.html | Docs | Free | Shed requests instead of queueing when a service isn't ready — instant overload protection |
| tower-resilience | https://github.com/joshrotenberg/tower-resilience | Repo | Free | Circuit breaker, bulkhead, adaptive concurrency, retry budget, fallback, hedge — one toolkit |
| "Async: What is blocking?" | https://ryhl.io/blog/async-what-is-blocking/ | Blog | Free | Why one blocking call on a Tokio worker stalls the whole crawl and destroys backpressure |
| Google SRE — Handling Overload | https://sre.google/sre-book/handling-overload/ | Book (free) | Free | Production overload strategies: queueing, shedding, and client-side throttling |
| Google SRE — Addressing Cascading Failures | https://sre.google/sre-book/addressing-cascading-failures/ | Book (free) | Free | How retries + shared resources take down whole systems, and what to do about it |
| Martin Fowler — CircuitBreaker | https://martinfowler.com/bliki/CircuitBreaker.html | Article | Free | The canonical explanation of closed/open/half-open and why it stops retry storms |
| AWS Builders' Library — Timeouts, retries, backoff with jitter | https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/ | Article | Free | Jitter math and retry budget reasoning from people who run at AWS scale |
backon docs |
https://docs.rs/backon/latest/backon/ | Docs | Free | Ergonomic async retry: ExponentialBuilder, jitter, when() predicates, notify hooks |
backoff docs |
https://docs.rs/backoff | Docs | Free | Mature alternative with transient/permanent error typing and sync + async support |
Phase-6 invariants (memorize these):
- Ack/delete the message after the fetch result is durably written (S3 + DB), never before.
- Every queue payload carries a deterministic idempotency key (canonical URL hash); consumers are idempotent.
- Checkpoint = broker ack + per-URL state transition; both must survive
kill -9. - All channels/queues are bounded; when full, the producer waits or sheds — never buffer to disk silently.
- Per-domain token bucket + global concurrency semaphore; deferred URLs go back to the queue with
next_attempt_at, not into a sleeping worker. - Retries use exponential backoff + jitter + a max attempt count; after that, DLQ and move on.
- Circuit-break domains that return repeated 5xx/429 so one broken host can't consume the fleet.
- Graceful shutdown: stop taking new work, drain in-flight, ack/flush, exit within a deadline.
Observability
If Phase 6 has one deliverable an interviewer will remember, it's a crawl that is legible: dashboards, traces, and a console that show queue depth, per-domain health, and where time goes.
Metrics
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| metrics-rs | https://github.com/metrics-rs/metrics | Repo | Free | The metrics facade (like log for numbers) — instrument once, export anywhere |
metrics-exporter-prometheus |
https://docs.rs/metrics-exporter-prometheus | Docs | Free | One-liner Prometheus scrape endpoint for a Rust service (0.18.x) |
| Prometheus — Overview | https://prometheus.io/docs/introduction/overview/ | Docs | Free | Pull model, exposition format, PromQL basics |
| Grafana — Getting started | https://grafana.com/docs/grafana/latest/getting-started/ | Docs | Free | Dashboards + alerting over Prometheus |
| Docker Compose Prometheus + Grafana example | https://github.com/Einsteinish/Docker-Compose-Prometheus-and-Grafana | Repo | Free | Working compose file to steal for your stack |
Crawler metrics worth emitting (name them like this): pages_fetched_total{status}, fetch_duration_seconds (histogram), bytes_downloaded_total, frontier_queue_depth, in_flight_requests, domain_rate_limit_wait_seconds, retries_total{reason}, dlq_total, dedup_hits_total, scheduler_lag_seconds, worker_heartbeat_timestamp, parse_errors_total. Metrics labels are only for bounded-cardinality dimensions — never label by URL (domain only if you accept the cardinality; it's acceptable in a portfolio with a known domain set).
Tracing, logs, and OpenTelemetry
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
tracing docs |
https://docs.rs/tracing/latest/tracing/ | Docs | Free | Structured, async-aware spans/events — the Rust standard; use it for logs too |
| tokio-rs/tracing | https://github.com/tokio-rs/tracing | Repo | Free | Ecosystem: subscribers, layers, JSON output, OpenTelemetry bridge |
tracing-subscriber |
https://docs.rs/tracing-subscriber | Docs | Free | EnvFilter (RUST_LOG), JSON formatter, span fields as log context |
| OpenTelemetry Rust docs | https://opentelemetry.io/docs/languages/rust/ | Docs | Free | Official entry point: current crate list, stability status, exporters |
| OTel Rust — Getting Started | https://opentelemetry.io/docs/languages/rust/getting-started/ | Tutorial | Free | End-to-end setup for traces/metrics/logs |
| OTel Rust examples | https://github.com/open-telemetry/opentelemetry-rust/tree/main/examples | Repo | Free | Runnable OTLP stdout/HTTP/gRPC examples to adapt |
opentelemetry-otlp docs |
https://docs.rs/opentelemetry-otlp | Docs | Free | The exporter you wire to the collector |
| OTLP basic HTTP example | https://github.com/open-telemetry/opentelemetry-rust/blob/main/opentelemetry-otlp/examples/basic-otlp-http/README.md | Code | Free | Minimal correct OTLP-over-HTTP configuration |
| OpenTelemetry Collector | https://opentelemetry.io/docs/collector/ | Docs | Free | Receive OTLP from workers, batch, route to Jaeger/Prometheus — the compose glue |
| Jaeger | https://jaegertracing.io/ | Tool | Free | Trace UI; run jaegertracing/all-in-one in Docker for local dev |
| Instrument Rust with OpenTelemetry (Jan 2026) | https://oneuptime.com/blog/post/2026-01-07-rust-opentelemetry-instrumentation/view | Tutorial | Free | Current, copy-pasteable Rust setup with tracing bridge |
| MainMatter Telemetry workshop | https://rust-exercises.com/telemetry | Workshop | Free | Guided exercises covering tracing + metrics + subscriber layers |
| OpenTelemetry Demo | https://github.com/open-telemetry/opentelemetry-demo | Repo | Free | See traces/metrics/dashboards done properly in a multi-service system |
| tokio-console | https://github.com/tokio-rs/console | Tool | Free | Live view of tasks, poll times, wakers, and async deadlocks in a running crawler |
console-subscriber docs |
https://docs.rs/console-subscriber | Docs | Free | The one-line instrumentation (console_subscriber::init()) plus tokio unstable flags |
| Announcing Tokio Console | https://tokio.rs/blog/2021-12-announcing-tokio-console | Blog | Free | What each console column means and how to spot starvation |
Spans to create: crawl.url (root per URL: url_hash, domain, depth), fetch (status, bytes, duration), parse (links found, content hash), enqueue (partition, queue), dedup_check. Propagate trace context through queue message headers so a single URL shows one trace across scheduler → worker → parser → indexer. Structured logs (tracing + JSON) with trace_id/span_id close the loop.
Minimal local observability stack (Compose services)
nats (or kafka/redis) + scheduler + worker × N + parser + prometheus + grafana + otel-collector + jaeger. Four of those are off-the-shelf images; only the crawler services are yours.
Deployment (Docker Compose, Kubernetes basics)
Compose is the week-11 target; Kubernetes is an optional half-day if you want the resume line. Do not let k8s delay the crawler.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Docker Compose docs | https://docs.docker.com/compose/ | Docs | Free | Single-file multi-service stack; --scale worker=4 is your poor-man's orchestrator |
| cargo-chef | https://github.com/LukeMathWalker/cargo-chef | Tool | Free | Cache Rust dependency builds in Docker — cuts image rebuilds from minutes to seconds |
| Official Rust Docker image | https://hub.docker.com/_/rust | Docs | Free | Build stage base image; pair with a slim/distroless runtime stage |
| rust-lang/docker-rust | https://github.com/rust-lang/docker-rust | Repo | Free | Dockerfiles behind the official images when you need to understand them |
| Distroless images | https://github.com/GoogleContainerTools/distroless | Repo | Free | Tiny, shell-less runtime images for Rust binaries (static/musl or glibc variants) |
| Testcontainers for Rust — Compose support | https://rust.testcontainers.org/features/docker_compose | Docs | Free | Integration tests that spin up your compose stack programmatically |
| kind (Kubernetes in Docker) | https://kind.sigs.k8s.io/ | Tool | Free | Local k8s cluster in one command — the fastest way to try deploying your crawler |
| K3s quick start | https://docs.k3s.io/quick-start | Docs | Free | Lightweight real Kubernetes on a VM/homelab if kind feels too fake |
| Kubernetes basics tutorial | https://kubernetes.io/docs/tutorials/kubernetes-basics/ | Tutorial | Free | Deployments, Services, scaling, rolling updates — enough k8s to be conversational |
Compose checklist: one compose.yml, service names as DNS, healthcheck + depends_on: condition: service_healthy, named volumes for broker data, .env for config, restart: unless-stopped for workers, and deploy.resources.limits so a runaway worker doesn't eat the laptop. If (and only if) that works: translate each service to a Deployment + Service in kind, add a liveness/readiness probe, scale workers with kubectl scale, and kill a pod to watch it recover.
Performance Profiling & Benchmarking Rust
Measure before you optimize, and benchmark the system, not just the function. On macOS, use samply for sampling (better macOS story); cargo-flamegraph works but rides xctrace and can be finicky.
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| samply | https://github.com/mstange/samply | Tool | Free | Sampling profiler with Firefox Profiler UI; on/off-CPU stacks on macOS; samply record ./target/profiling/crawler |
| cargo-flamegraph | https://github.com/flamegraph-rs/flamegraph | Tool | Free | One-command SVG flamegraphs for whole binaries and benchmarks; works on Linux/macOS/Windows |
| Criterion.rs | https://github.com/criterion-rs/criterion.rs | Tool | Free | Statistics-driven microbenchmarks (v0.8) with regression detection for parsers/hashers |
| Criterion user guide | https://bheisler.github.io/criterion.rs/book/ | Book | Free | How to write benches, read confidence intervals, avoid common pitfalls |
| hyperfine | https://github.com/sharkdp/hyperfine | Tool | Free | End-to-end command benchmarks (hyperfine './crawler --urls 1000') with warmups and outlier detection |
| pprof-rs (TiKV) | https://github.com/tikv/pprof-rs | Tool | Free | In-process CPU profiling for long-running services; expose /debug/pprof for production |
pprof docs |
https://docs.rs/pprof | Docs | Free | API for wiring pprof-rs into a Tokio server |
| The Rust Performance Book | https://nnethercote.github.io/perf-book/ | Book | Free | Practical optimization checklist: allocations, hashing, I/O, profiling profiles |
| Brendan Gregg — Flame Graphs | https://www.brendangregg.com/flamegraphs.html | Reference | Free | How to read flamegraphs without fooling yourself |
Canonical Cargo.toml for profiling (both samply and flamegraph need symbols):
[profile.profiling]
inherits = "release"
debug = true
Workflow: hyperfine the whole crawl for a baseline → samply record a realistic run to find the widest stacks (usually JSON/HTML parsing, DNS, or accidental blocking) → write a criterion bench for the exact hot function → fix → re-run both and commit the before/after graphs to the README. Also run tokio-console under load: if a task shows seconds of busy time in a loop, you found a blocking call or a spin.
System Design Reading (DDIA, crawler architecture references)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| DDIA official site (book + TOC) | https://dataintensive.net/ | Book | Paid | Kleppmann's canonical distributed-data systems text; read the replication, partitioning, and stream-processing chapters |
| DDIA 2nd edition announcement (March 2026) | https://martin.kleppmann.com/2026/03/24/designing-data-intensive-applications-2e.html | Blog | Free | What changed in the 2026 edition (Riccomini co-author) and which chapters to prioritize |
| Mercator: A Scalable, Extensible Web Crawler (Heydon & Najork, 1999) | https://marc.najork.org/papers/wwwmerc.pdf | Paper | Free | The foundational crawler architecture paper: frontier, DNS caching, politeness, extensibility |
| Stanford IR Book — Web Crawling and Indexes | https://nlp.stanford.edu/IR-book/ | Book (free) | Free | Chapters 19–20: crawler architecture, URL frontier, dedup, freshness |
| URL Frontier chapter (IR Book) | https://nlp.stanford.edu/IR-book/html/htmledition/the-url-frontier-1.html | Book chapter | Free | Front queues (priority) + back queues (politeness) — the design interviewers expect |
| Hello Interview — Web Crawler | https://www.hellointerview.com/learn/system-design/problem-breakdowns/web-crawler | Guide | Free | Staff-level deep dives: pipeline stages, exponential backoff, politeness locks, Bloom filters, crawler traps |
| AlgoMaster — Designing a Web Crawler | https://blog.algomaster.io/p/design-a-web-crawler-system-design-interview | Article | Free | 100M pages/day math, frontier components, partitioning, and dedup decisions |
| System Design Newsletter — Web Crawler & Search Engine (Feb 2026) | https://newsletter.systemdesign.one/p/web-crawler-system-design | Article | Free/Paid | Fresh 2026 walkthrough with orchestration tradeoffs and search-pipeline context |
| Hello Interview video — Design a Web Crawler | https://www.youtube.com/watch?v=krsuaUp__pM | Video | Free | Watch a Staff engineer drive the interview; good for interview pacing |
| System Design video — Distributed Web Crawler | https://www.youtube.com/watch?v=bBCFc8x1hG4 | Video | Free | Frontier, politeness, dedup, DNS caching, robots.txt in one deep dive |
| Google SRE Book — Handling Overload | https://sre.google/sre-book/handling-overload/ | Book (free) | Free | Overload semantics and client-side throttling for the crawler fleet |
| Google SRE Book — Addressing Cascading Failures | https://sre.google/sre-book/addressing-cascading-failures/ | Book (free) | Free | Retry-related collapse, load shedding, and graceful degradation patterns |
| AWS Builders' Library | https://aws.amazon.com/builders-library/ | Articles | Free | Short, battle-tested essays on timeouts, retries, jitter, and backpressure |
| Papers We Love | https://paperswelove.org/ | Community | Free | Paper-reading community if you want to go deeper than blog posts |
Reading priority for week 11: Hello Interview crawler (design vocabulary) → Mercator + IR Book frontier (architecture) → DDIA stream processing / partitioning chapters (why the primitives exist) → SRE overload chapters (operational realism). Skip the rest until the project runs.
Recommended Order (week 11, with time estimates)
Total ≈ 18h core + 2h optional k8s stretch.
| Day | Time | Focus | Output |
|---|---|---|---|
| Mon | 2h | Read Exa post, Hello Interview crawler, Mercator/IR frontier; skim DDIA stream-processing | Component diagram of scheduler/frontier/workers/parsers/metrics with failure modes annotated |
| Tue | 3h | Stand up NATS JetStream in Docker; implement Queue trait; publish/consume URL messages with durable pull consumer + manual ack |
Worker loop that fetches a URL and acks only after write; duplicate-delivery test fails → fix next |
| Wed | 3h | Rendezvous-hash domains to workers; idempotent URL-state table (unique index on canonical hash); checkpoint on ack | kill -9 a worker mid-crawl: no lost URLs; duplicate publishes → exactly one state row |
| Thu | 3h | Backpressure: bounded channels, per-domain token bucket, fetch semaphore, backon retries with jitter, DLQ, circuit breaker on bad hosts |
Slow-parser load test: RSS bounded, one dead domain doesn't stall the fleet, poison URLs land in DLQ |
| Fri | 3h | Observability: metrics-rs + Prometheus endpoint, tracing spans, JSON logs, OTLP → Jaeger, Grafana dashboard, tokio-console session | Dashboard (throughput, errors, latency, queue depth) + one end-to-end trace across services |
| Sat | 2h | Docker Compose: scheduler/workers/frontier/parsers/broker/prometheus/grafana, healthchecks, --scale worker=4 |
docker compose up brings up the whole crawl; killing a worker container doesn't stall progress |
| Sun | 2h | Profile: hyperfine baseline, samply run, criterion bench, one fix; write README | Portfolio repo with architecture diagram, screenshots, tradeoffs, and before/after profile |
| Stretch | 2h | kind cluster deploy of the same services | Pods Ready, kill-a-pod self-heal, kubectl scale observed |
Hands-on Exercises (5-8)
Each builds on your Phase 5 crawler. Acceptance criteria are the definition of done.
Split the monolith into a Compose fleet. Services:
scheduler(produces URL jobs),frontier/broker,worker(fetch),parser(extract links + text),metrics(Prometheus scrape), plus Prometheus/Grafana. Acceptance:docker compose up -dstarts everything healthy;docker compose up -d --scale worker=3triples fetch throughput in the dashboard;docker compose kill workerdrops throughput but the crawl resumes with no lost URLs; README shows the architecture diagram.Prove at-least-once + idempotency. Publish every URL twice, and add a test hook that crashes a worker after fetch but before ack. Acceptance: a
dedup_hits_totalmetric/row count increments on the duplicate; the URL-state table contains exactly one row per canonical URL; the crashed URL is redelivered and succeeds; a test (#[tokio::test]) asserts duplicate processing produces identical state.Domain partitioning with minimal reshuffling. Implement rendezvous hashing over registrable domains with a worker list from config. Acceptance: unit test maps 10k sample domains across 8 workers and asserts < 2% variance in load; removing one worker remaps only its ~1/8 share; two workers never fetch the same domain concurrently (assert via per-domain lock/logs); per-domain fetch order is preserved.
Checkpoint/resume after hard kill. Run a 1k-URL crawl,
kill -9the scheduler and a worker midway, restart the stack. Acceptance: zero URLs reported as permanently lost; pending (unacked) message count returns to zero after drain; a run report shows discovered → fetched → parsed counts reconcile; no double-fetch beyond the duplicate window allowed by at-least-once.Backpressure and graceful degradation under a slow consumer. Make the parser sleep (env-configurable latency) until queues saturate; simultaneously point the crawler at a host that always 503s. Acceptance: worker RSS stays under a configured bound (e.g. 512MB) because channels are bounded; the crawler slows instead of OOMing; the bad domain's circuit breaker opens and the rest of the crawl continues; retries show jittered delays and a retry budget cap; over-capacity fetch requests are load-shed with a 429/503 counted in metrics.
Build the crawl dashboard and one full trace. Emit the metric set above; add spans
crawl.url,fetch,parse,enqueueand propagate trace context in queue headers. Acceptance: Grafana dashboard JSON committed with throughput, error rate, p50/p95 fetch latency, frontier depth, and retries by reason; Jaeger shows a single trace spanning scheduler → worker → parser for one URL;tokio-consoleconnects and shows no task with multi-second busy time.Profile and optimize one hot path. Baseline with
hyperfinefor end-to-end and acriterionbench for HTML link extraction. Acceptance:samplyflamegraph produced for a release+debug run; one identified hotspot improved ≥20% on the criterion bench with before/after output committed; flamegraph SVG/README note explains the fix and what you deliberately did not optimize.(Stretch) Deploy the fleet to kind. Translate each compose service to a Deployment + Service; add readiness probes and resource limits. Acceptance:
kubectl get podsall Ready; deleting a worker pod self-heals;kubectl scale deployment worker --replicas=4increases throughput;kubectl port-forwardreaches Grafana; a short README section lists what k8s added over Compose and what it cost.
Common Pitfalls / What to Skip
Pitfalls (and the fix):
- Chasing exactly-once delivery. It doesn't exist over a network; at-least-once + idempotent writes does. Stop reading about transactional outboxes for a crawler and add a unique index.
- Unbounded channels.
mpsc::unboundedturns a slow parser into an OOM kill. Use bounded channels and let backpressure propagate. - Acking too early. Ack before the durable write and a crash loses the URL silently. Always: write → fsync/confirm → ack.
- Partitioning by URL hash. You get no per-domain ordering and can hammer one host from N workers. Hash domains.
- Putting page bodies in queue messages. Queues are not blob stores; pass IDs/keys, store bytes in S3 or on disk. (Hello Interview calls this out explicitly.)
- Retry storms. Retries without jitter, budgets, or circuit breakers amplify outages and get your IP range banned. Add jitter, caps, and DLQ from day one.
- A single hot domain. One giant host can saturate a partition; cap per-domain in-flight requests and let other domains flow around it.
- Visibility timeout too short. Slow fetches get redelivered while still running, causing duplicate work. Size timeouts to p99.9 fetch latency (SQS) or use heartbeats/
InProgressacks. - Profiling debug builds. Results are meaningless; always
--profile profiling(release + debug symbols). - Regex-parsing HTML. Use an HTML parser; the CPU and correctness costs are real.
- Observability as an afterthought. If it isn't on a dashboard, it didn't happen. Emit metrics and spans while building each stage, not after.
Skip for now (do not spend week 11 here):
- Writing your own broker, WAL, or Raft; benchmark-driven custom lock-free frontier — use Redis/NATS/Kafka.
- Pulsar, RabbitMQ, ksqlDB, Flink, Spark — a fourth broker/stream processor adds no signal at this stage.
- Kubernetes operators, Helm charts, service mesh, ingress controllers, multi-region — Compose plus a
kindcameo is plenty. - Prometheus federation/Thanos/Loki clusters; OpenTelemetry sampling deep dives; custom tracing backends.
- Bloom filters and HyperLogLog for dedup before a plain unique index proves insufficient; crawler traps solved by max-depth.
- Browser rendering/CDP in this phase — that was Phase 5; the distributed system is the point here.
- Autoscaling in the cloud, spot fleets, and cost optimization — interesting in the interview, out of scope for the portfolio week.
Communities
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Rust Users Forum | https://users.rust-lang.org/ | Forum | Free | Async/Tokio and crate questions with maintainer-grade answers |
| r/rust | https://www.reddit.com/r/rust/ | Forum | Free | Project feedback, crate news, "how do I structure this" threads |
| Tokio Discord | https://discord.gg/tokio | Chat | Free | Real-time async Rust help from the Tokio team and ecosystem authors |
| NATS community | https://nats.io/community/ | Chat/Forum | Free | JetStream operations questions; Slack invite from this page |
| Apache Kafka community | https://kafka.apache.org/community/ | Mailing lists/Chat | Free | Kafka semantics, consumer-group tuning, performance questions |
CNCF Slack (#opentelemetry-rust) |
https://slack.cncf.io/ | Chat | Free | OpenTelemetry Rust questions and exporter/collector help |
| Papers We Love | https://paperswelove.org/ | Community | Free | Distributed-systems paper reading groups and talks |
| Exa careers page | https://exa.ai/careers | Jobs | Free | Check current crawler/infrastructure roles and team language before applying |
If you want interview signals beyond code: post your Compose stack and a Grafana screenshot in r/rust, write up your idempotency test on your blog, and read one distributed-systems paper per week with Papers We Love. For Exa specifically, be ready to talk about the frontier, domain partitioning, politeness at fleet scale, and how you'd detect a stuck crawl from metrics alone.
Search & Retrieval for Crawled Data
Exa's Web Crawling Engineer role sits on a pipeline whose value is only realized at retrieval: crawl -> parse -> index -> search. This file covers the retrieval half — BM25/full-text search with Tantivy, embeddings and vector search in Rust, boilerplate removal and chunking, snippets/highlights, ranking evaluation, and Exa's own technical write-ups so your add-on mirrors how they think about search quality.
Start Here (core path)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Tantivy crate docs (quickstart) | https://docs.rs/tantivy/latest/tantivy/ | Docs | Free | The official ~40-line schema -> index -> search example; get BM25 search working over your crawled pages today. |
Tantivy basic_search.rs example |
https://github.com/quickwit-oss/tantivy/blob/main/examples/basic_search.rs | Code | Free | Runnable end-to-end example; clone it and swap the sample docs for your crawl corpus. |
| tantivy-cli + tutorial | https://github.com/quickwit-oss/tantivy-cli | Code/CLI | Free | Indexes English Wikipedia and serves search via CLI/REST; fastest way to see a real Tantivy service end-to-end. |
| IIR, free online book (Ch. 1, 6, 8, 11, 20) | https://nlp.stanford.edu/IR-book/information-retrieval-book.html | PDF/HTML | Free | Inverted index, TF-IDF/vector space, BM25, evaluation, web crawling chapters — the canonical free IR text. |
| fastembed-rs | https://github.com/Anush008/fastembed-rs | Code | Free (local) | Local ONNX embeddings + cross-encoder reranking in Rust with no Python and no GPU required. |
| Qdrant Rust client quickstart | https://docs.rs/qdrant-client/latest/qdrant_client/ | Docs | Free (OSS) | Create collection -> upsert -> query; the canonical Rust vector-DB workflow you will plug chunks into. |
| text-splitter | https://docs.rs/text-splitter/latest/text_splitter/ | Docs | Free | Token/character-aware semantic chunking in Rust (plain text, Markdown, code); the chunking crate to standardize on. |
| Exa: How we built a web-scale vector database | https://exa.ai/blog/building-web-scale-vector-db | Blog | Free | Truncation, binary quantization, clustering, filters, reranking — the exact design vocabulary Exa interviews for. |
| Exa: Serving BM25 with 50% memory reduction | https://exa.ai/blog/bm25-optimization | Blog | Free | Why lexical retrieval is still core inside a neural search engine, and what scale does to a postings list. |
| Exa Search API reference (for agents) | https://exa.ai/docs/reference/search-api-guide-for-coding-agents | Docs | Free | Exact response contract (results[].text/highlights/score, contents nesting, latency tiers) your add-on should imitate. |
Core path in order: (1) run Tantivy basic_search on 20 of your crawled pages, (2) read IIR Ch. 1 + 6 + 11 skim, (3) read Exa's vector-DB and BM25 posts, (4) run fastembed-rs + Qdrant quickstart, (5) chunk with text-splitter, (6) expose POST /search with Tantivy highlights, (7) evaluate and add hybrid + reranking.
IR Fundamentals (free books/papers)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| Introduction to Information Retrieval — whole book (online reading PDF) | https://nlp.stanford.edu/IR-book/pdf/irbookonlinereading.pdf | Free | The full Manning/Raghavan/Schütze text with hyperlinks; use as reference, not cover-to-cover. | |
| IIR HTML edition | https://nlp.stanford.edu/IR-book/html/htmledition/irbook.html | HTML | Free | Browser-friendly chapters when you want to search inside the text quickly. |
| IIR Ch. 1 — Boolean retrieval | https://nlp.stanford.edu/IR-book/pdf/01bool.pdf | Free | The inverted index, postings lists, and Boolean query processing explained from first principles. | |
| IIR Ch. 6 — Scoring, term weighting & the vector space model | https://nlp.stanford.edu/IR-book/pdf/06vect.pdf | Free | TF-IDF, cosine similarity, and why ranked retrieval replaced Boolean retrieval. | |
| IIR Ch. 8 — Evaluation in IR | https://nlp.stanford.edu/IR-book/pdf/08eval.pdf | Free | Precision/recall, MAP, and the evaluation vocabulary you will later apply to nDCG/MRR. | |
| IIR Ch. 11 — Probabilistic IR (BM25) | https://nlp.stanford.edu/IR-book/pdf/11prob.pdf | Free | Full derivation of Okapi BM25, k1, b, and the probability ranking principle. |
|
| IIR Ch. 19 — Web search basics | https://nlp.stanford.edu/IR-book/pdf/19web.pdf | Free | Crawl/index scale, spam, and the economics that shaped every modern web search engine. | |
| IIR Ch. 20 — Web crawling and indexes | https://nlp.stanford.edu/IR-book/pdf/20crawl.pdf | Free | Connects your crawling phase to distributed indexes and freshness — the seam this phase lives on. | |
| BM25 explained (build-up from TF-IDF) | https://arpitbhayani.me/blogs/bm25 | Blog | Free | Modern, intuition-first BM25 walkthrough with saturation, length normalization, and parameter tuning. |
| Elastic: Practical BM25, Part 2 | https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables | Blog | Free | Lucene implementation details of k1/b and IDF smoothing — same math Tantivy uses. |
| HNSW paper (Malkov & Yashunin) | https://arxiv.org/abs/1603.09320 | Paper | Free | The ANN graph algorithm behind usearch, Qdrant, pgvector, and LanceDB indexes. |
| BEIR benchmark | https://github.com/beir-cellar/beir | Code/Paper | Free | Zero-shot retrieval benchmark suite and the standard harness for comparing retrieval methods. |
| MTEB leaderboard | https://huggingface.co/spaces/mteb/leaderboard | Leaderboard | Free | Choose embedding models by retrieval score before you download one. |
Tantivy (full-text search in Rust)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| tantivy repo (v0.26.x) | https://github.com/quickwit-oss/tantivy | Code | Free (MIT) | Read the source of a production Lucene-class engine in Rust; BM25 scoring lives here. |
| Tantivy docs.rs | https://docs.rs/tantivy/latest/tantivy/ | Docs | Free | Schema (TEXT, STORED, STRING), IndexWriter, QueryParser, TopDocs — the API you will use daily. |
| Basic search example | https://github.com/quickwit-oss/tantivy/blob/main/examples/basic_search.rs | Code | Free | Copy-paste starting point for indexing and querying your crawl output. |
| Snippet example | https://github.com/quickwit-oss/tantivy/blob/main/examples/snippet.rs | Code | Free | Shows SnippetGenerator producing HTML-highlighted snippets from hits. |
tantivy::snippet module docs |
https://docs.rs/tantivy/latest/tantivy/snippet/index.html | Docs | Free | SnippetGenerator::create, set_max_num_chars, snippet.to_html() — the highlight API. |
| Tokenizer module source | https://github.com/quickwit-oss/tantivy/tree/main/src/tokenizer | Code | Free | How tokenization pipelines work; where you would add custom analyzers/stemming. |
| Quickwit (distributed engine on Tantivy) | https://github.com/quickwit-oss/quickwit | Code | Free (AGPL) | See what Tantivy looks like with sharding, replication, and a search service around it. |
| tantivy-py | https://github.com/quickwit-oss/tantivy-py | Code | Free | Python bindings — useful for scripting evaluation sets against the same indexes. |
| rust-stemmers | https://docs.rs/rust-stemmers/latest/rust_stemmers/ | Docs | Free | The stemmer Tantivy's language tokenizers use; swap it in for recall experiments. |
bm25 crate |
https://docs.rs/bm25/latest/bm25/ | Docs | Free | Tiny standalone BM25 implementation to read when you want the scoring math without engine internals. |
| Exa: Optimizing BM25 at billions of docs | https://exa.ai/blog/bm25-optimization | Blog | Free | Real-world postings-list encoding, WAND pruning, zstd, and why every bit matters. |
Embeddings & Vector Search (Rust tooling + vector DBs)
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| fastembed-rs (v6.x) | https://github.com/Anush008/fastembed-rs | Code | Free (Apache-2.0) | Dense + sparse (SPLADE) + BGE-M3 + cross-encoder reranking, all local ONNX; the default Rust embedding choice. |
| fastembed crate docs | https://docs.rs/fastembed/latest/fastembed/ | Docs | Free | TextEmbedding, SparseTextEmbedding, caching, batch sizes, and model list in one page. |
fastembed TextRerank |
https://docs.rs/fastembed/latest/fastembed/struct.TextRerank.html | Docs | Free | Cross-encoder reranking API (e.g., BGE reranker) to rescore BM25/vector candidates. |
| Candle | https://github.com/huggingface/candle | Code | Free (Apache-2.0) | Hugging Face's Rust-native ML framework when you want model internals instead of a black-box ONNX session. |
| Candle BERT example | https://github.com/huggingface/candle/tree/main/candle-examples/examples/bert | Code | Free | Minimal BERT embeddings + cosine similarity in pure Rust; the shortest path from weights to vectors. |
| ort (ONNX Runtime bindings) | https://github.com/pykeio/ort | Code | Free (MIT/Apache) | Lower-level ONNX Runtime access used by fastembed; read when you need custom models or execution providers. |
| ort docs/tutorial | https://ort.pyke.io/ | Docs | Free | Setup, linking, and session/feed patterns for ONNX inference in Rust. |
| USearch (HNSW, Rust bindings) | https://github.com/unum-cloud/usearch | Code | Free (Apache-2.0) | Embed a single-file HNSW index in-process when a full vector DB is overkill. |
| usearch crate docs | https://docs.rs/usearch/latest/usearch/ | Docs | Free | Index options, metrics, f16/i8/binary vectors, save/load — everything for an in-process ANN index. |
| hnswlib | https://github.com/nmslib/hnswlib | Code | Free (Apache-2.0) | The reference C++ HNSW implementation; read for algorithm clarity, benchmark against USearch. |
| Qdrant Rust client | https://github.com/qdrant/rust-client | Code | Free (Apache-2.0) | Official gRPC client; Qdrant::from_url, collections, filters, payloads, hybrid queries. |
| Qdrant Rust client examples | https://github.com/qdrant/rust-client/tree/master/examples | Code | Free | Copy-paste examples for query, upsert, filters, and quantization — fastest way past gRPC boilerplate. |
| Qdrant local quickstart | https://qdrant.tech/documentation/quickstart/ | Docs | Free (self-host) | Docker one-liner + all-language client snippets, including Rust gRPC on port 6334. |
| Qdrant hybrid queries | https://qdrant.tech/documentation/concepts/hybrid-queries/ | Docs | Free | Query API with prefetch + fusion (RRF/DBSF) — the exact hybrid pattern to copy. |
| Qdrant indexing concepts | https://qdrant.tech/documentation/concepts/indexing/ | Docs | Free | HNSW m/ef_construct, quantization, and payload indexes; the knobs for recall/latency/memory tradeoffs. |
| LanceDB (v0.38.x) | https://github.com/lancedb/lancedb | Code | Free (Apache-2.0) | Embedded, serverless vector DB in Rust built on the Lance columnar format; versioned data on disk or S3. |
| LanceDB Rust SDK docs | https://docs.rs/lancedb/latest/lancedb/ | Docs | Free | connect, Arrow schema with FixedSizeList<Float32>, IVF_PQ indexes, nearest_to queries. |
| LanceDB full-text search docs | https://docs.lancedb.com/search/full-text-search | Docs | Free | Native FTS + vector hybrid in one embedded engine — a second hybrid option without running a server. |
| pgvector (Postgres extension) | https://github.com/pgvector/pgvector | Code/Docs | Free (PostgreSQL license) | Vector search next to relational data; HNSW/IVFFlat indexes, filters, iterative scans, halfvec/bit types. |
| pgvector-rust | https://github.com/pgvector/pgvector-rust | Code | Free (MIT/Apache) | Vector type for postgres, SQLx, and Diesel; the bridge for a Postgres-backed search add-on. |
| pgvector-rust examples | https://github.com/pgvector/pgvector-rust/tree/master/examples | Code | Free | End-to-end examples including Candle embeddings and hybrid search with Reciprocal Rank Fusion. |
| Exa: How we built a web-scale vector database | https://exa.ai/blog/building-web-scale-vector-db | Blog | Free | Matryoshka truncation, binary quantization, lookup-table dot products, k-means clustering, filters — required reading. |
Parsing, Boilerplate Removal & Chunking
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
| dom_smoothie | https://github.com/niklak/dom_smoothie | Code | Free (MIT) | Mozilla-Readability port for Rust with the best F1/latency profile among Rust extractors; use it to strip nav/ads before indexing. |
| dom_smoothie docs | https://docs.rs/dom_smoothie/latest/dom_smoothie/ | Docs | Free | Readability config, Article fields, and scoring options when default extraction is too aggressive. |
| Article Extraction Benchmark | https://github.com/scrapinghub/article-extraction-benchmark | Code/Data | Free | Objective F1/recall comparison of Rust and Python extractors, including dom_smoothie and readability; pick your parser with numbers. |
| Mozilla Readability | https://github.com/mozilla/readability | Code | Free (Apache-2.0) | The reference algorithm behind Firefox Reader View; read the scoring heuristics once. |
| readability (Rust crate) | https://docs.rs/readability/latest/readability/ | Docs | Free | Long-lived Rust port used by LangChain-Rust and Kalosm; simple extract API. |
| readabilityrs | https://docs.rs/readabilityrs/latest/readabilityrs/ | Docs | Free | Faithful Mozilla Readability port with ParseOptions/ExtractOptions if you need closer JS parity. |
| text-splitter | https://github.com/benbrandt/text-splitter | Code | Free (MIT) | Recursive semantic splitting by Unicode boundaries, tokens (HF/Tiktoken), Markdown, or tree-sitter code. |
| text-splitter docs (v0.32) | https://docs.rs/text-splitter/latest/text_splitter/ | Docs | Free | TextSplitter/MarkdownSplitter/CodeSplitter, ranges, overlap, and ChunkSizer — chunking API to standardize on. |
| chunkedrs (rust-chunker) | https://github.com/goliajp/rust-chunker | Code | Free (MIT) | Token-accurate chunker with header ancestry, byte offsets, and token spans for late chunking. |
| Pinecone: Chunking strategies | https://www.pinecone.io/learn/chunking-strategies/ | Blog | Free | Canonical overview of fixed, recursive, and document-specific chunking for retrieval. |
| Chroma: Evaluating chunking for retrieval | https://research.trychroma.com/evaluating-chunking | Paper/Code | Free | Token-level precision/recall experiments showing chunk size dominates strategy; use its method for your own ablation. |
| Anthropic: Contextual Retrieval | https://www.anthropic.com/news/contextual-retrieval | Blog | Free | Prepending chunk context + hybrid search + reranking cut retrieval failures ~49%; a concrete accuracy playbook. |
| Late Chunking (Jina) | https://arxiv.org/abs/2409.04701 | Paper | Free | Embed full document first, then pool token embeddings into chunks; fixes context loss at boundaries. |
Snippets, Highlights & Ranking Evaluation
| Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|
Tantivy SnippetGenerator docs |
https://docs.rs/tantivy/latest/tantivy/snippet/index.html | Docs | Free | Generate query-aware snippets with <b> highlights and a character budget from your top hits. |
| Tantivy snippet example | https://github.com/quickwit-oss/tantivy/blob/main/examples/snippet.rs | Code | Free | The working code path from query -> snippet -> HTML string. |
| Exa: Dynamic Highlights | https://exa.ai/blog/dynamic-highlights | Blog | Free | Token-view snippets with a shared budget across results; 95% token reduction — the frontier of highlight design. |
| Exa Contents API guide | https://exa.ai/docs/reference/contents-api-guide | Docs | Free | text, highlights, summary, maxCharacters, dynamic — the exact contents feature surface to imitate. |
| Pinecone: Evaluation measures in IR | https://www.pinecone.io/learn/offline-evaluation/ | Blog | Free | Clear definitions and Python implementations of precision@K, recall@K, MAP, MRR, and nDCG. |
| Weaviate: Retrieval evaluation metrics | https://weaviate.io/blog/retrieval-evaluation-metrics | Blog | Free | Compares when each metric (P@K, R@K, MAP, MRR, nDCG@K) is the right optimization target. |
| SBERT cross-encoders | https://www.sbert.net/examples/cross_encoder/applications/README.html | Docs | Free | Why cross-encoders beat bi-encoders for reranking and where they fail to scale — the standard explanation. |
| RRF for hybrid search (OpenSearch) | https://opensearch.org/blog/introducing-reciprocal-rank-fusion-hybrid-search/ | Blog | Free | The 1/(k+rank) fusion formula, k=60, and NDCG@10 benchmarks vs score normalization. |
| BEIR | https://github.com/beir-cellar/beir | Code | Free | Benchmark your BM25 vs dense vs hybrid stack on standard datasets with nDCG@10 and recall. |
| Criterion | https://docs.rs/criterion/latest/criterion/ | Docs | Free | Rust benchmarking harness for scoring/indexing microbenchmarks (p50/p95, regression detection). |
| oha | https://github.com/hatoo/oha | Code | Free | Load-test your search HTTP API (-c 50 -z 30s) to report real latency and error-rate numbers. |
| axum | https://docs.rs/axum/latest/axum/ | Docs | Free | Build the POST /search API layer in Tokio/axum; ergonomic JSON extractors for query + contents options. |
Exa's Public Research (what to read)
Read in this order; each post maps directly to a subsystem the role touches.
| # | Resource | Link | Format | Cost | Best for |
|---|---|---|---|---|---|
| 1 | Research hub (index of technical posts) | https://exa.ai/research | Index | Free | See the whole technical narrative — crawling, BM25, vector DB, evals, serving — before diving in. |
| 2 | How we built a web-scale vector database | https://exa.ai/blog/building-web-scale-vector-db | Blog | Free | Their custom vector DB: Matryoshka truncation (20x), binary quantization (16x), SIMD dot products, 100k clusters, filters, rerank recovery. |
| 3 | Serving BM25 with 50% memory reduction | https://exa.ai/blog/bm25-optimization | Blog | Free | Frequency-bucketed postings, delta+varint doc IDs, zstd, consolidated buffers, WAND pruning at billions of docs. |
| 4 | How we do evals at Exa | https://exa.ai/blog/evals-at-exa | Blog | Free | "Open evals," LLM graders, pointwise vs pairwise vs listwise, nDCG aggregation, and the MS MARCO false-negative critique. |
| 5 | State-of-the-art web search API (API evals) | https://exa.ai/blog/api-evals | Blog | Free | SimpleQA + MS MARCO LLM-as-judge methodology and how they beat other search APIs. |
| 6 | Composing a Search Engine (Canon) | https://exa.ai/blog/composing-a-search-engine | Blog | Free | Search as a typed DAG: parallel retrieval, racing indexes, RRF fusion, rerank, snippet, and full-request tracing. |
| 7 | exa-d: Data Framework to Process the Web | https://exa.ai/blog/exa-d | Blog | Free | Incremental derived columns (embeddings, parsed text) over Lance fragments; how parsed data stays fresh at petabyte scale. |
| 8 | How we're building the next generation of search | https://exa.ai/blog/how-to-build-nextgen-search | Blog | Free | The end-to-end architecture in plain language: crawl -> parse -> embed -> serve, and the Bitter Lesson argument. |
| 9 | Dynamic Highlights | https://exa.ai/blog/dynamic-highlights | Blog | Free | Token-level retrieval for snippets, shared budgets across documents, and agent token-efficiency evals. |
| 10 | Search API reference (coding agents) | https://exa.ai/docs/reference/search-api-guide-for-coding-agents | Docs | Free | The exact JSON contract, deprecated params, and latency tiers (instant ~250ms to deep-reasoning 12-40s). |
| 11 | Contents API guide | https://exa.ai/docs/reference/contents-api-guide | Docs | Free | text/highlights/summary extraction options — copy these into your own API. |
| 12 | exa-code | https://exa.ai/blog/exa-code | Blog | Free | Code-example extraction and reranking; a template for domain-specific retrieval over crawled pages. |
| 13 | WebCode evals | https://exa.ai/blog/webcode | Code/Blog | Free | Open-sourced search evals for coding agents — steal the eval harness structure. |
| 14 | Publications search (SOTA benchmark) | https://exa.ai/blog/publications-search | Blog | Free | Current (2026) example of building a vertical index and measuring it. |
| 15 | Company search benchmarks | https://exa.ai/blog/company-search-benchmarks | Blog | Free | Vertical retrieval evals over crawled company data; useful if your corpus is entity-centric. |
| 16 | Evals landing page | https://exa.ai/evals | Index | Free | Ongoing benchmark posts, including people/company search. |
| 17 | exa-py / exa-js SDKs | https://github.com/exa-labs/exa-py | Code | Free | Mirror the SDK shape (search(query, contents={...})) so your API feels like Exa's. |
| 18 | Exa agent skills | https://github.com/exa-labs/agent-skills | Code | Free | Their own machine-readable API guides; read skills/build-with-exa/references/search.md. |
Recommended Order (after week 12 / parallel, with time estimates)
Assumes ~8-10 focused hours/week. Do the reading in parallel with coding; everything here builds on your crawl+parse corpus.
| Week | Focus | Hours | Deliverable |
|---|---|---|---|
| 13 | IR fundamentals + Tantivy | 8-10 | Read IIR Ch. 1/6/11 + arpitbhayani BM25; run Tantivy basic_search; index 10k crawled pages with a title/body/url schema. |
| 14 | Parsing + chunking | 8-10 | Add dom_smoothie extraction and text-splitter chunking to the crawler output; store chunk IDs, byte offsets, and source URL. |
| 15 | Embeddings + vector DB | 8-10 | fastembed-rs embeds chunks; load into Qdrant (or LanceDB); ship /search/semantic; compare against BM25 on 25 queries. |
| 16 | Hybrid + rerank + evaluation | 10-12 | RRF-fuse BM25 + vector, rerank top-50 with fastembed TextRerank; report recall@10, MRR@10, nDCG@10 in a table. |
| 17 | Search API + latency | 8-10 | axum POST /search with contents.text/highlights; p95 < 150ms on 10k docs; document with criterion + oha numbers. |
| Ongoing | Exa research (2 posts/week) | 2-3/wk | Written notes mapping each Exa post to a decision in your project (index layout, eval method, highlight budget). |
If you only have two weeks: Week A = Tantivy + evaluation; Week B = fastembed + Qdrant + RRF hybrid + API. Skip reranking if time runs out; never skip evaluation.
Hands-on Exercises
All exercises end with a number you can put on a resume: throughput, latency, and/or retrieval quality.
Tantivy BM25 over your crawl (acceptance: index + query, measured p50/p95). Build an
IndexWriterwithtitle(TEXT | STORED),body(TEXT | STORED),url(STRING | STORED),crawled_at(DATE). Index 10,000 parsed pages. Acceptance: single-run index build < 60s on a laptop;QueryParsertop-10 query p50 < 20ms and p95 < 50ms over 100 real queries; empty-query and single-char-query cases return 400, not a panic.Tokenizer/stemming ablation (acceptance: measurable recall delta). Hand-label 20 queries with their relevant URLs from your corpus. Run three index configs: default tokenizer, lowercased + stopwords, lowercased +
rust-stemmersEnglish. Acceptance: a table of recall@10 and MRR@10 per config; at least one config change improves recall@10 by >= 5 points, or you can explain with examples why not.Snippet/highlight endpoint (acceptance: highlight correctness + budget). Use
SnippetGenerator::create(&searcher, &query, body_field),set_max_num_chars(240), andto_html(). Acceptance: for the top-10 hits of 10 queries, every snippet <= 240 chars, every matched query term is wrapped in<b>(or documented stemming mismatch), and snippet generation adds < 5ms to p95 total query latency; HTML output is escaped so crawled content cannot inject markup.Boilerplate removal + chunking pipeline (acceptance: reduction + stable offsets). For 100 pages, run raw-HTML vs dom_smoothie extraction, then chunk both with text-splitter at 256 tokens / 32-token overlap using a Hugging Face tokenizer. Acceptance: dom_smoothie reduces text volume by >= 40% on average with no missing article paragraphs in a 10-page manual audit; every chunk carries
{url, chunk_index, start_byte, end_byte, token_count}; re-running the pipeline is deterministic (identical hashes for identical input).Embeddings + vector search (acceptance: throughput + ANN recall check). Embed 1,000 chunks with fastembed's default BGE-small (384d) into Qdrant (or LanceDB). Build an HNSW index. Acceptance: embedding 1,000 chunks < 60s on CPU; end-to-end semantic query p95 < 50ms; on 50 queries, ANN recall@10 >= 0.95 versus brute-force cosine; a 20-query side-by-side shows at least 3 queries where vector search wins and 3 where BM25 wins (proves both are worth keeping).
Hybrid retrieval + RRF + cross-encoder rerank (acceptance: leaderboard table). Fuse BM25 top-50 and vector top-50 with RRF (
k=60), then rerank the fused top-50 with fastembedTextRerankand return top-10. Acceptance: nDCG@10, MRR@10, and recall@10 reported for four systems (BM25 only, vector only, RRF hybrid, hybrid + rerank) on 25+ labeled queries; hybrid + rerank beats the best single retriever on nDCG@10; rerank latency p95 documented (target < 300ms for 50 candidates on CPU).Exa-shaped
POST /searchAPI (acceptance: contract + load test). Build an axum service returning{requestId, results:[{title,url,score,text,highlights}], costDollars}withcontents: {text, highlights}and asourcefield to switch retrieval modes (bm25|vector|hybrid). Acceptance: conformance to the Exa response shape in the docs (including contents nesting);oha -c 50 -z 30sreports 0 errors and p95 < 150ms on the 10k-doc corpus; malformed JSON returns 422 with an error body; a README records the exact benchmark command and numbers.Eval harness, Exa-style open eval (acceptance: reproducible scores + LLM judge agreement). Write a Rust or Python harness that runs a query set (JSONL: query + optional expected facts), computes recall@k/MRR/nDCG from manual labels, and separately runs an LLM-as-judge with a pointwise 0-1 rubric (see Exa's eval post). Acceptance: 25 queries labeled; harness reruns deterministically; judge vs your manual labels agree on >= 80% of query-level rankings; results committed as a CSV; publish the harness as a crate/script in your repo so any later change can be regression-tested.
Common Pitfalls / What to Skip
- Don't build your own vector database or ANN graph. Use Qdrant/LanceDB/USearch; Exa's post is inspiration, not a beginner task. Only after shipping search should you read HNSW internals.
- Don't index raw HTML. Without boilerplate removal you index nav/footer/ad text, and BM25 eagerly returns it. Parse first (dom_smoothie), keep the raw HTML only as a fallback.
- Don't use fixed character chunking and call it done. Measure token counts, prefer text-splitter's semantic levels, and validate with a chunking ablation (Chroma's method).
- Don't compare BM25 and cosine scores numerically. Fuse with RRF; score distributions are incompatible and normalization is fragile.
- Don't rerank before retrieval quality is solid. Reranking fixes ordering within candidates, not missing candidates. Get recall first, then precision.
- Don't evaluate on 5 cherry-picked queries. Build 25+ labeled queries with an expected-facts field before optimizing; log every run.
- Don't skip snippets/highlights. They are the user-visible product and Exa's token-efficiency differentiator; a search result without a highlight looks broken.
- Don't commit after every document. Tantivy commits are segment flushes; batch (or commit on a time/count threshold) and let merges run.
- Don't chase distributed search. Tantivy explicitly excludes it; one machine handles millions of docs. Scale out only with measured need (Quickwit exists for that).
- Don't train or fine-tune embedding models yet. Use MTEB-scored off-the-shelf models; training is a Phase 8+ project with far worse ROI than better chunking and evaluation.
- Don't copy Exa's API blindly. Their
type,category, and synthesis features are product surface; implementquery+contents.text/highlights+numResultsfirst. - Skip: Elasticsearch/Meilisearch deployments, JVM Lucene, building a snippet library from scratch, FAISS bindings (USearch covers Rust), and any vector DB that requires the cloud to test.
Open-Source Repos to Read
Read in this order; each is small enough to navigate but real enough to teach production patterns.
| Repo | Link | Language | Cost | Best for |
|---|---|---|---|---|
| quickwit-oss/tantivy | https://github.com/quickwit-oss/tantivy | Rust | Free (MIT) | The single most relevant codebase: schema, tokenizer, postings, BM25, collectors, snippets. Start with examples/ then src/snippet and src/query. |
| quickwit-oss/quickwit | https://github.com/quickwit-oss/quickwit | Rust | Free (AGPL) | Distributed search service around Tantivy: splits, metastore, search fan-out. |
| meilisearch/meilisearch | https://github.com/meilisearch/meilisearch | Rust | Free (MIT) | A full search product in Rust: typo tolerance, ranking rules, incremental indexing, REST API design. |
| qdrant/qdrant | https://github.com/qdrant/qdrant | Rust | Free (Apache-2.0) | Production vector DB internals: HNSW, quantization, filters, hybrid query planning. |
| unum-cloud/usearch | https://github.com/unum-cloud/usearch | Rust/C++ | Free (Apache-2.0) | Minimal ANN engine you can actually read; how HNSW + SIMD distance kernels are structured. |
| lancedb/lancedb | https://github.com/lancedb/lancedb | Rust | Free (Apache-2.0) | Embedded vector DB over Arrow/Lance; columnar storage, versioning, FTS + vector hybrid. |
| Anush008/fastembed-rs | https://github.com/Anush008/fastembed-rs | Rust | Free (Apache-2.0) | Clean reference for tokenizers + ort + pooling + reranking in a library API. |
| pgvector/pgvector-rust | https://github.com/pgvector/pgvector-rust | Rust | Free (MIT/Apache) | Smallest possible vector integration; read all of it in one sitting, including Candle/RRF examples. |
| benbrandt/text-splitter | https://github.com/benbrandt/text-splitter | Rust | Free (MIT) | Semantic chunking implemented over icu_segmenter/pulldown-cmark; good architecture for a pipeline stage. |
| niklak/dom_smoothie | https://github.com/niklak/dom_smoothie | Rust | Free (MIT) | Readability scoring in Rust; see the benchmark repo for why it beats alternatives. |
| paradedb/paradedb | https://github.com/paradedb/paradedb | Rust | Free (AGPL) | Real-world hybrid search: Postgres-native BM25 + vector indexes + SQL query integration. |
| exa-labs/agent-skills | https://github.com/exa-labs/agent-skills | Markdown/Rust-free | Free | Exa's own machine-readable API guidance; the closest public artifact to their product requirements. |
Exa: Company Research, Application & Portfolio Strategy
Compiled Sep 2026. All links verified with live fetches/API calls on 2026-09-11 unless marked "verify". Job listings change weekly — re-check the Ashby API before applying.
Exa Deep Dive (products, crawler role, tech signals, links)
What Exa is
Exa (Exa Labs Inc.) is an applied AI research lab building a web-scale search engine for AI agents — embedding-based retrieval over its own crawl and index, sold as an API. Founded 2021 by Harvard roommates Will Bryk (CEO, ex-first engineer at Cresta) and Jeff Wang (ex-Plaid data/web infra), out of Y Combinator Summer 2021.
| Fact | Detail | Source |
|---|---|---|
| Funding | $17M Series A (Jul 2024, Lightspeed/NVIDIA/YC) → $85M Series B (Sep 2025) → $250M Series C led by a16z, May 20 2026, $2.2B valuation (~$362M total). Job posts say "raised $350m from Lightspeed, Benchmark, and a16z." | Series C post, Series B post |
| Scale | Crawlers track 500B+ URLs, crawl billions of documents/day; index plans to hundreds of billions of pages; serves 10k+ QPS vector search | About, Series C |
| Team | ~100 people, mostly SF in-person, plus Singapore; recent hires include Head of Retrieval Infra from Meta, Head of Search Backend from Yandex, and a research team out of Google | Series C, About |
| Customers | Cursor, Cognition, HubSpot, OpenRouter, monday.com, Firefox; 400K+ developers, 5,000+ companies | About, Firefox post |
| Compute | "Exacluster": 18 nodes / 144 H200 GPUs, custom-built; trains its own embedding models | Meet the Exacluster |
Products (Sep 2026): Search API (Instant sub-200ms, Fast, Deep, Deep Max agentic tiers), Contents API (crawl/contents retrieval), Agent API, Monitors API, Websets (deep research list-building), Exa Connect (data providers), MCP Server.
Why the crawler team matters to Exa's thesis
Will Bryk's framing: a perfect search engine needs (1) discovery + crawling, (2) neural preprocessing/indexing, (3) ultra-fast serving. Google does these with thousands of people; Exa does each with ~"one or two people" — hence the high-agency hiring bar. Crawling is the top of Exa's funnel, and the crawler role explicitly says "Basically build Google-scale crawling!" (Latent Space interview, How we're building the next generation of search).
Tech stack signals (from official sources)
| Signal | Evidence |
|---|---|
| Rust | Vector DB "written in rust of course 🦀"; highlights server migrated Python → Rust for 4x throughput; Exa's Rust-focused repos/forks (lance, milvus-sdk-rust); team bio jokes about "convert the world into rust" |
| C++/systems | Crawler JD asks for "high performance language (C++, Rust, etc.)" |
| TypeScript + Playwright + CDP | Crawler JD explicitly names all three |
| Data infra | exa-d framework: S3 + Lance + Ray Data, dependency-DAG columns, fragment-level patching; ClickHouse, Kafka/Flink, lakehouse in Data Systems JD; Nix/K8s/Ray in Infra JD |
| Retrieval research | Embeddings, clustering, Matryoshka, binary quantization, BM25 optimization, neural PageRank/link-prediction training objective, "Canon" search orchestrator |
| Crawler people | Tyler Killian ("building state-of-the-art crawling systems"), Mark Pekala (data systems), Hubert Yuan (exa-d), Tom An (ex-BM25/Google), Ben Chan (distributed algorithms) |
Open-source presence (github.com/exa-labs)
The org has 107 repos but mostly SDKs, demos, and forks — crawler code is closed-source. Public entry points:
- exa-mcp-server — 5k stars, TypeScript; Exa MCP for search/crawl.
- exa-py / exa-js — official SDKs; good place for a credible first PR.
- agent-skills — official skills for the Exa API (active, updated Sep 2026).
- zed-exa-mcp-extension — Rust; low-stakes Rust contribution surface.
- company-researcher, exa-hallucination-detector — demo apps that show how Exa wants its API used.
Founder media (crawling, search quality, culture)
| Resource | Why read it |
|---|---|
| Latent Space — "Beating Google at Search with Neural PageRank and $5M of H200s" (Jan 2025) | Will on the three-part architecture (crawl → index → serve), quality vs. comprehensiveness, link-prediction as neural PageRank |
| Sacra interview (Sep 2024) | Why Exa crawls the "highest-quality subset," garbage-in/garbage-out for agents, learned quality models |
| jxnl.co session writeup (Sep 2025) | Concrete engineering notes: petabytes of crawl data, SIMD-level vector search, test-time compute |
| a16z Deep Dives (Jun 2026) | Newest full-stack strategy interview post-Series C |
| SuperAI 2026 talk (Jun 2026) | "Perfect Search for a World of Agents" — latest product/research framing |
| Knuckle Up podcast + writeup (Aug 2026) · substack | Hiring culture: ship something important in week 1, "nothing should take a month," no process, no "NPC" hires — interview prep gold |
| MIT Tech Review profile (Dec 2024) | Independent validation of the "web as a database" framing |
Current Open Roles & Requirements (table, with links, as of Sep 2026)
The SF crawler role (published 2025-07-23) and Singapore crawler role (published 2025-12-10) are both still live as of 2026-09-11 per the Ashby posting API. Built In shows the SF role "reposted 10 days ago" — Exa refreshes it, meaning it is actively sourced and hard to fill. 55 roles total on exa.ai/careers.
| Role | Location | Comp | Status | Link |
|---|---|---|---|---|
| Software Engineer, Web Crawling | San Francisco, on-site | $180K–$350K + equity | Live (published Jul 2025, refreshed) | Ashby · Built In mirror |
| Software Engineer, Web Crawling | Singapore, on-site | SGD 90K–300K + equity | Live (published Dec 2025) | Ashby · Built In mirror |
| Software Engineer, Infrastructure | SF / Singapore | $180K–$350K / SGD 90K–300K + equity | Live | SF · SG |
| Software Engineer, Distributed Data Systems | SF | $180K–$350K + equity | Live | Ashby |
| Software Engineer, Backend | SF / Singapore | — | Live | SF · SG |
| Research Engineer, Generalist / Evals / ML | SF / Singapore | — | Live | Generalist SF · Evals · ML SF |
| Forward Deployed Engineer | SF / NY / London / Singapore | — | Live | SF |
Note: Ashby pages render via JavaScript; if a direct fetch returns blank, use the URL in a browser or the Built In mirror. The API JSON above always returns plain text and is the most reliable way to check status/pay.
Verbatim crawler-job requirements (SF; Singapore identical except location/comp)
Who you are
- "You have extensive experience building and scaling web crawlers, or would be excited to ramp up very quickly"
- "You have experience with some high performance language (C++, Rust, etc.)"
- "You are familiar with TypeScript, Playwright, modern web design, CDP (Chrome DevTools Protocol)"
- "You're comfortable optimizing a system to an exceptional degree"
- "You care about the problem of finding high quality knowledge and recognize how important this is for the world"
What you could do
- "Build a distributed crawler that can handle 100M+ pages per day"
- "Optimize crawl politeness and rate limiting across thousands of domains"
- "Design systems to detect and handle dynamic content, JavaScript rendering, and anti-bot measures"
- "Create intelligent crawl scheduling and prioritization algorithms for maximum coverage efficiency"
Logistics: fully in-person; visa sponsorship for STEM OPT/OPT/H1B/O1/E3 (SF); international sponsorship (SG); premium healthcare, fertility benefits, 16 weeks parental leave, wellness stipend.
Read this carefully: the JD names politeness, rate limiting, and quality first. Present yourself as someone who runs largescale crawls within site constraints, not someone who defeats them. The "anti-bot measures" line is about detection/handling systems, and Exa's public posts frame crawl quality and content selection as the research problem.
Exa Engineering/Research Reading List (blog posts with links)
These are the posts to read before any interview/outreach; reference 2–3 of them specifically in your application. All links verified.
| Post | Date | Why it matters |
|---|---|---|
| exa-d: Data Framework to Process the Web | Jan 2026 | How Exa stores/updates 100s of billions of pages: typed column DAG, Lance on S3, Ray Data, fragment-level patching, backfills |
| How we built a web-scale vector database | Dec 2024 | Rust, clustering, Matryoshka embeddings, binary quantization, SIMD — direct source for "search/retrieval quality" credibility |
| Scaling our highlights server | Feb 2024 | Python → Rust migration, 4x throughput; proof of Rust-in-production culture |
| Serving BM25 with 50% memory reduction | May 2025 | Memory/encoding optimization at billions-doc scale; exactly the kind of tradeoff you should benchmark in your portfolio |
| Composing a Search Engine (Canon) | Apr 2026 | Pipeline orchestrator for search; "crawl-to-search" architecture in production form |
| How Search Quality Shapes RL Outcomes | May 2026 | Why retrieval quality is the product; evaluation-driven mindset |
| How we're building the next generation of search | Mar 2025 | Will's end-to-end architecture essay: crawl network across machines/IPs, custom HTML parser, S3, vector DB |
| A Perfect Search Engine | Jan 2025 | The "neural database" thesis; useful for framing your project against company strategy |
| We need superknowledge before superintelligence | Jul 2024 | Mission framing; use sparingly in cover letters |
| Dynamic Highlights | Aug 2026 | Latest retrieval/agent product evolution |
| SOTA Search Over Academic Publications | Jul 2026 | How Exa evaluates verticals; model for your own eval writeup |
| WebCode: Search Evals for Coding Agents | Mar 2026 | Open-source eval methodology — steal this structure for your benchmark project |
| Serving Exa Instant (sub-200ms) | Feb 2026 | Latency engineering priorities |
| Meet the Exacluster | May 2025 | GPU cluster/training infra context |
| Company Search / People Search benchmarks | 2026 / 2025 | How Exa designs evals and reports accuracy |
| Exa is now powering search in Firefox | Aug 2026 | Newest scale/distribution milestone |
| Evals page | live | Current public benchmark numbers (FRAMES, Tip-of-Tongue, Seal0) and competitor comparisons |
Also worth mining: docs.exa.ai (API behavior), Exa changelog (ship velocity), status.exa.ai.
How Hiring Works at AI Startups Like Exa
The market mechanics (2026 data)
| Channel | Response/Conversion | Source |
|---|---|---|
| Warm referral from a current employee | 40–70% response; referrals ≈6% of applications but 37% of hires, ~11x conversion of inbound, 55% faster time-to-hire | Apollo Technical via Standout, InsideTrack |
| Founder/hiring-manager direct outbound (email or X DM) | 15–35% response | JobsByCulture |
| Outbound off GitHub/OSS signal | 10–25% response | JobsByCulture |
| Cold LinkedIn InMail | 0.3–1% response (platform enforces 13% floor on recruiters) | Yander |
| Cold portal application | 0.1–2% offer rate | Standout |
At startups under ~200 people, 50–70% of hires come through referrals/founder networks — a cold Ashby application at Exa is the bottom of the funnel. Startup interview load: ~15 applicants interviewed per hire, 18 for technical roles (Ashby 2026 Startup Hiring Report, cited by Standout).
What AI-startup interview loops look like (verified examples)
- Firecrawl (closest analog to Exa): application review ("We care about what you've shipped, not where you went to school") → ~45 min technical chat on a real problem (crawling at scale, ranking quality, cost/query) → paid 1–2 week work trial at contractor rate. Search Engineer JD
- Perplexity: responds within 2 weeks; technical assessment; offer decision within a week of final. Careers
- Tavily: initial call → technical assessment → team conversations; 2–4 weeks end-to-end. Careers
- Exa-specific signals: expect deep questioning on your crawl project, systems tradeoffs, and (per Will's interviews) a bias for people who ship fast. Exa runs automated evals internally and makes decisions from data; bring numbers, not adjectives. Expect questions like "walk me through what breaks first at 100M pages/day" and "how would you schedule a recrawl of 1B URLs under per-domain budgets?" Prepare to whiteboard a crawl architecture (frontier, politeness state, dedup, rendering escalation, storage) in one session.
Signals that actually matter at Exa
- Proof-of-work > credentials — a running crawler repo with benchmarks and a writeup beats a resume. Firecrawl says it out loud; Exa's own culture ("ship something important in week 1") implies it.
- Scale reasoning — 100M+ pages/day = ~1,157 pages/sec sustained; know your bottleneck math (connections, CPU per page, GB/day, storage cost).
- Optimization evidence — "comfortable optimizing a system to an exceptional degree" = show before/after numbers with a profiler (flamegraph, criterion).
- Quality/retrieval awareness — they want people who care about what is worth crawling, not just throughput.
- Rust + TypeScript fluency — Rust for the hot path; TS/Playwright/CDP for rendering. Show both.
- Autonomy — tiny crawl team; you must demonstrate you scope and ship without a PM.
Outreach & Application Playbook (templates, channels)
Ranked channel strategy
- Referral/intro — best ROI. Map every first/second-degree contact at Exa on LinkedIn; use the lead investor network (a16z, Benchmark, Lightspeed portfolio ops) if reachable; YC S21 alumni network.
- Contribute first, then message — open a real PR to exa-py, exa-js, agent-skills, or exa-mcp-server. A merged PR is the warmest cold outreach that exists.
- X/Twitter DM to the crawler-adjacent engineer or Will — reference their post/talk specifically. 15–35% response when specific.
- Email the hiring manager/engineer (Tyler Killian for crawling per About; Will/technical staff for eng). Email format:
firstname@exa.aiis standard but confirm via public sources — send from your real address, not a scheduler. - Apply via Ashby, then immediately send the outreach note referencing the application. Never only apply.
- Exa Discord — join discord.gg/HCShtBqbfV, answer questions, share your crawl benchmark. Founder-adjacent employees read it.
Rules (from 2026 outbound data)
- 4–6 sentences / 80–120 words; 101–150 words is the measured sweet spot; over that, reply rate collapses (Yander).
- One specific hook (their post/issue/talk), one proof link, one clear ask ("20 minutes"), no résumé dump.
- 2–3 touches over ~2 weeks; each touch adds something new (result, demo, relevant news). Then stop.
- Timing: Tue–Thu, mid-morning recipient time; never Monday or Friday afternoon.
- Send-on-behalf-of (a real human, no scheduler signature) lifts replies ~50% (Yander).
Template 1 — Cold email to crawler team (under 120 words)
Subject: Politeness-aware Rust crawler — 1.2M pages/hr benchmark
Hi [Name],
Your exa-d post on fragment-level updates stuck with me — I built a
Rust crawler with a per-host token-bucket + RFC 9309 robots cache to
see what politeness actually costs at scale: 1.2M pages/hr across
40k domains, p99 host delay honored, repro script + criterion numbers
here: [link].
I'm applying for the Web Crawling role, but I'd value 20 minutes on
where your crawl scheduling hurts most — whether or not it becomes a
job conversation.
[Name]
[GitHub] · [blog post]
Why it works: references an actual Exa post, gives a measurable artifact, soft ask, one link.
Template 2 — X DM (shorter)
Hey [Name] — loved the [SuperAI talk / exa-d post] point on [specific detail].
I built [one-line crawler project + number]. Applying to the crawling role,
would love 15 min to ask what your frontier prioritization looks like at
500B URLs. Demo: [link]. Either way, keep shipping.
Template 3 — Referral ask (to a 2nd-degree contact or alum)
Hi [Name] — [one line on shared context]. I'm applying to Exa's Web
Crawling role; my crawler project + benchmarks are here: [link].
Would you be open to forwarding it to whoever owns crawling, or
telling me who does? Happy to make it one click — short summary
attached.
Template 4 — Follow-up (day 5, then day 12)
Quick add: I ran a 3-way rendering comparison (HTTP-only vs CDP vs
Playwright) on 10k JS-heavy pages — RSS and cost per successful
extraction here: [link]. Still happy to chat for 20 min if useful.
Application package checklist
- One-page résumé with the keyword map (next section).
- Portfolio landing page: 4 projects, each with a 60-second README, architecture diagram, benchmark table, demo GIF/video.
- 1 flagship writeup (the crawl benchmark) linked at the top.
- GitHub pinned repos; clean commit history; a
bench/directory with repro commands. - 2–3 minute demo video per project; links in README (Loom/asciinema).
Portfolio Projects: How to Present Each (from the 4-project list)
Universal README skeleton (keep it skimmable — recruiters read 60 seconds, engineers 5 minutes):
# <Project name> — <one-line positioning with a number>
Badges: build | license | benchmarks
[60-sec Loom/asciinema link]
## What it does (3 bullets)
## Architecture (Mermaid diagram inline)
## Results (table with real numbers + hardware fingerprint + repro command)
## How it works (the interesting 2–3 engineering decisions)
## What I'd do next / known limitations
## Reproduce: `cargo run --release -- ...`
Diagram tools: Mermaid (renders in GitHub), Excalidraw (hand-drawn system diagrams), diagrams.net. Put the Mermaid source inline in README so it renders on GitHub.
(a) Polite distributed Rust crawler
- Positioning: "A distributed Rust crawler that honors RFC 9309 at 1M+ pages/hr across 40k domains." The politeness engine is the feature, not an afterthought.
- Must-have modules visible in the repo: URL frontier with priority queues; per-host token-bucket rate limiter; robots.txt fetcher/parser with 24h cache and longest-match semantics (RFC 9309); DNS cache; dedup (URL normalization + content hashing); checkpoint/resume; Prometheus metrics; seed list; polite User-Agent with contact URL.
- The interesting decisions to explain: scheduler fairness (round-robin vs. per-host deficits), frontier backpressure, crash recovery, exactly-once content storage, cost of robots fetches at scale (one robots fetch can gate thousands of URLs).
- Benchmark to publish: pages/sec per vCPU, host-delay compliance rate (should be 100%), frontier throughput (URLs dispatched/sec), memory per 1M queued URLs, recovery time after kill -9.
- Demo: asciinema of a live crawl of 5k URLs with metrics + robots compliance log.
- Exa hook: "Optimize crawl politeness and rate limiting across thousands of domains" — mirror the JD phrase, then show numbers.
(b) Hybrid HTTP/browser crawler
- Positioning: "HTTP-first crawler with cost-aware escalation to CDP; 92% of JS pages extracted at 1/6th the browser cost."
- Modules:
reqwest/hyperfast path;chromiumoxide(Rust CDP) or Playwright sidecar; heuristics for escalation trigger (content-length heuristics, empty body, framework fingerprints); browser pool with health checks; extraction vialol-htmlstreaming rewriter (no full DOM when possible); per-page budget (time/bytes/renders); fallback ladder HTTP → headless → full browser. - The interesting decisions: when not to render (70–80% of pages don't need JS), browser instance reuse/TTL, crash isolation of renderers, honoring
robotsand ToS on the rendered path, memory ceiling per browser. - Benchmark to publish: cost per successful extraction (HTTP vs headless vs full browser), p50/p95/p99 latency per path, peak RSS per concurrent browser, escalation accuracy (how often heuristics choose right).
- Demo: side-by-side Loom of a JS-heavy SPA extracted via HTTP fallback vs. CDP.
- Exa hook: JD's "TypeScript, Playwright, CDP" + "dynamic content, JavaScript rendering." Show Playwright and a Rust CDP client; show cost discipline.
- Framing rule: call this "render escalation" or "browser rendering for dynamic content." Never "anti-bot bypass" (see Legal section).
(c) Crawl-to-search pipeline
- Positioning: "Crawl → extract → index → query: hybrid BM25 + embedding retrieval over 1M docs, NDCG@10 = X, with an eval harness." This is Exa's core loop in miniature.
- Modules: crawl handoff from project (a)/(b); text extraction + boilerplate removal; dedup/near-dup (SimHash/MinHash);
tantivyBM25 index; embedding model (candle/ort + a small open model) into an HNSW index (hnsw_rs/usearch); hybrid fusion (RRF or weighted); simple rerank; a versioned eval set with queries/judgments. - The interesting decisions: doc freshness/recrawl policy, index schema, incremental updates (mirrors exa-d's "surgical update" idea), hybrid weighting, query latency vs. recall tradeoff.
- Benchmark to publish: build/index throughput (docs/sec), index size on disk, recall@k / NDCG@10 / MRR vs. a keyword-only baseline, p50/p99 query latency at 1M docs, incremental update cost.
- Demo: query the index live; show ranking differences keyword vs. semantic.
- Exa hook: "finding high quality knowledge" and "search/retrieval quality." This project proves you understand why the crawler exists.
(d) Crawler benchmark writeup
- Positioning: "What politeness costs: a reproducible benchmark of a Rust crawler at 1M pages/hr" — or your strongest measured claim, stated honestly.
- Structure: question → system under test → workload (seed domains, page mix, JS %) → metrics + tooling → results tables → threats to validity → reproduce script → what surprised you.
- Include: hardware fingerprint (CPU, RAM, kernel, rustc, tool versions), warmup + run counts, p50/p90/p99 (not just mean), error budget, and a one-command repro (
docker run ...ormake bench). - Publish on: personal site (canonical), cross-post dev.to and Hashnode for reach; Medium if your audience is there; submit to r/rust and Hacker News.
- Exa hook: Exa's own blog is benchmark-driven (evals, BM25, vector DB). Matching that genre signals culture fit more than any cover letter.
Time budget by project: (a) 3 weeks, (b) 2.5 weeks, (c) 3 weeks, (d) 1.5 weeks, buffer 2 weeks.
Benchmarking & Writeup Methodology
Tool selection
| Tool | Use for | Link |
|---|---|---|
| Criterion.rs | Statistical micro-benchmarks; regression detection; HTML reports; CI gates. Use black_box, BenchmarkGroup, Throughput::Bytes |
github.com/bheisler/criterion.rs |
| Divan | Lighter alternative for quick iteration bench tables | github.com/nvzqz/divan |
| hyperfine | Whole-binary end-to-end wall-clock; compares before/after; --warmup 3 --runs 10; JSON export |
github.com/sharkdp/hyperfine |
| cargo-flamegraph | Find CPU hot spots; ship a screenshot in the README | github.com/flamegraph-rs/flamegraph |
| DHAT / heaptrack | Heap profiling; peak RSS and allocation churn per page | DHAT · heaptrack |
| critcmp | Compare criterion baselines across commits | github.com/BurntSushi/critcmp |
| github-action-benchmark | Continuous benchmarking in CI with regression thresholds | benchmark-action/github-action-benchmark |
Rules that make a benchmark credible
- Never
Instant::now()alone. Criterion/divan for functions; hyperfine for binaries. (Microsoft Rust Engineering Book — Benchmarking) - Report distributions, not means. p50/p90/p99, stddev, and n. Latency averages hide bimodal HTTP-vs-browser behavior.
- Capture a hardware/version fingerprint (OS, arch, CPU, RAM, rustc, hyperfine ver, warmup/runs). Absolute numbers are not comparable across machines — say so in the writeup.
- Separate the layers: pure-CPU parsing benchmarks (criterion) vs. end-to-end CLI (hyperfine) vs. system resources (RSS, syscalls). See a strong real-world template: alint benchmark methodology.
- State threats to validity: GitHub-hosted CI has 5–30% wall-time variance; shared runners are noisy — publish from a fixed machine and label it.
- Publish the workload and repro command. Synthetic corpus generator with fixed seed; exact command; raw JSON results checked in.
- Compare against a baseline (e.g., HTTP-only vs. rendered; keyword vs. hybrid; single-thread vs. tokio multi-thread) — a number with no baseline is a claim, not a result.
- Gate regressions in CI at ~10% for hot paths; keep thresholds in the README so readers know you understand noise floors.
Writeup structure (steal this)
# Title with the headline number
TL;DR (3 bullets)
1. The question and why it's hard
2. System under test + architecture diagram
3. Workload: corpus composition, seeds, JS %, concurrency
4. Method: tools, warmups, runs, fingerprint, what we controlled
5. Results: tables with p50/p90/p99 + throughput + RSS + cost
6. Analysis: what the numbers mean; where the bottleneck moved
7. Threats to validity / what we did NOT measure
8. Reproduce: one command + raw results link
9. What I'd do next
Reference-quality examples: Exa's own exa-d and BM25 posts; Spider's Rust vs Python rewrite (concrete tables); fastCRW's build writeup (publishes harness + raw data).
Resume Keyword Map (Exa language -> your evidence)
Mirror the JD's exact vocabulary in your résumé bullets; back each with a metric. Never claim a keyword you can't demo.
| Exa language (from JD/blog) | Your evidence to show |
|---|---|
| "distributed crawler ... 100M+ pages per day" | Project (a): frontier + workers design; measured pages/hr; extrapolation math with assumptions stated (connections, page size, CPU) |
| "crawl politeness and rate limiting across thousands of domains" | Project (a): RFC 9309 parser, per-host token buckets, 100% host-delay compliance metric, 40k-domain run |
| "dynamic content, JavaScript rendering, CDP" | Project (b): CDP client or Playwright pool, escalation heuristic, cost/latency tables per render path |
| "anti-bot measures" (detection/handling) | Project (b)/(d): rendering fidelity + detection-signal logging (JS challenge encountered, HTTP 403/429 handling with backoff) — framed as resilience, not evasion |
| "intelligent crawl scheduling and prioritization" | Project (a): priority score, freshness/recrawl policy, budget allocation across domains; describe a scheduling experiment and its outcome |
| "high performance language (C++, Rust, etc.)" | Rust projects; criterion/flamegraph wins; PyO3 if you bridge Python |
| "TypeScript" | Playwright/CDP sidecar or CLI written in TS; typed API client |
| "optimizing a system to an exceptional degree" | Project (d): before/after numbers, CPU profile, memory reduction (e.g., "cut parse allocs 43%, throughput +2.1x") |
| "high quality knowledge" / "search/retrieval quality" | Project (c): NDCG@10/recall@k vs. keyword baseline; a quality-scoring heuristic or classifier for pages |
| "web-scale" / "petabytes" | Storage math for your corpus; exa-d-style incremental update experiment ("patch 1 column on 100k fragments without rewrite") |
| Rust ecosystem | tokio, reqwest/hyper, tower, lol-html, tantivy, candle/ort, serde |
| "fully in-person" / high agency | Résumé line showing self-directed shipped projects; be explicit you're ready to be in SF (or SG) |
Legal & Ethical Framing
The legal landscape (as of Sep 2026)
| Development | Holding / status | What it means for you |
|---|---|---|
| RFC 9309 — Robots Exclusion Protocol (IETF, Sep 2022) | Standardizes robots.txt parsing/matching/caching: longest match, Allow/Disallow, 5 redirects, ≥500 KiB parse limit, 24h cache guidance, 5xx = complete disallow; explicitly states "These rules are not a form of access authorization" |
Implement it faithfully and cite it in your README — it is the universal politeness contract |
| hiQ v. LinkedIn (9th Cir. 2019, reaffirmed 2022) | Accessing publicly available data is not CFAA "without authorization" — "no gates to lift or lower"; Van Buren (2021) "gates-up-or-down" reinforces it | Public pages aren't a federal crime to read; but see contract/privacy below |
| hiQ final outcome (Nov–Dec 2022) | hiQ lost on breach of contract (ToS + fake accounts) and settled: permanent injunction, delete data, pay $500K | ToS and logged-in/fake-account conduct are the real risk surface |
| Meta v. Bright Data (N.D. Cal., Jan 2024) | Meta's ToS didn't bar scraping logged-out public data | Stay logged out; never scrape behind a session |
| Ziff Davis v. OpenAI (2025) | robots.txt is essentially a request, not a DMCA §1201 access control ("keep off the grass" sign) | Ignoring robots.txt isn't automatically illegal — but it's evidence of bad faith in contract/trespass claims. Honor it. |
| Google v. SerpApi (N.D. Cal., filed Dec 19 2025) | July 20, 2026: court dismissed Google's §1201 claims — anti-bot "SearchGuard" guards ad revenue, not a copyrighted work; narrow Knowledge Panel theory allowed with leave to amend. Amended complaint Aug 10, 2026; new motion to dismiss set for hearing Sep 29, 2026. | The dominant new theory (DMCA anti-circumvention vs. scraping) took a major hit; but the case is live |
| Reddit v. SerpApi, Oxylabs, AWMProxy, Perplexity (S.D.N.Y., filed Oct 22 2025) | Ongoing; invokes SearchGuard circumvention and ToS theories | Platform litigation risk remains real in 2026 |
| DMCA §1201 "trafficking" theory | Plaintiffs increasingly target the tooling layer (CAPTCHA solvers, challenge tools, proxy services) — even tools can be "circumvention technology" | Avoid building/shipping anything whose README reads like an evasion toolkit |
Rules for your portfolio and interviews
Do:
- Honor robots.txt per RFC 9309; show it in code and metrics.
- Rate-limit politely (token buckets,
Crawl-delaywhere present, back off on 429/503). - Use a descriptive User-Agent with a contact URL (RFC 9309 §2.2.1).
- Crawl logged-out public pages only; never fake accounts, never a session cookie.
- Prefer official APIs/sitemaps where available; honor structured data.
- Minimize personal-data retention; think GDPR/CCPA if you touch profiles (Exa publishes GDPR/CCPA/HIPAA handling — security page).
- Take compliance seriously as an engineering constraint: log and count robots denials, rate-limit violations, and C&D processing.
Don't:
- Don't say "anti-bot bypass," "CAPTCHA solver," "stealth," "fingerprint spoofing," or "proxy rotation" in résumés/READMEs. Those exact terms are what §1201(a)(2) trafficking claims target, and they read as red flags to legal-adjacent reviewers. (Even Zyte, a scraping company, frames its browser work as "the hardest targets can't tell it apart from a real user" — as a browser stack problem.)
- Don't ignore a cease-and-desist: post-C&D scraping is where CFAA exposure revives.
- Don't scrape logged-in areas or personal data for training; separate privacy law applies regardless of CFAA.
- Don't republish copyrighted content; store/process for indexing with fair-use-aware handling.
- Don't put a target site's data or name in your benchmark if its ToS prohibits it — use your own test corpus or public datasets.
Framing vocabulary: "politeness-aware," "compliance-aware crawl scheduling," "rendering fidelity," "crawl budget allocation," "quality-aware crawling," "robots-respecting." This is also more impressive to Exa: their JD leads with politeness and quality, not evasion.
Backup Companies & Job Boards (table)
All verified active in 2026 unless noted. "Stack signal" = languages from the JD/products, not exhaustive.
| Company | What they do | Crawler-relevant roles / status | Stack signal | Careers link |
|---|---|---|---|---|
| Perplexity | Answer engine; crawler, search & infra teams | MTS Backend Search Crawler (Belgrade) + SF Infra roles posted Sep 2026; "high-QPS Rust and Go services" | Rust, Go, Python, K8s, AWS | Careers · Crawler JD |
| Tavily | Search API for agents | Software Engineer – Web Crawling (Israel, Mar 2026); Web Rendering Engineer (Israel/Remote EU); NY roles | Playwright/Puppeteer/CDP, Docker, K8s | Jobs |
| Firecrawl | Scrape/crawl/search API; open-source; 1.25M+ devs | Search Engineer ($235–260K, SF, Jul 2026), Product Engineer–Scrape, Research Engineer; paid 1–2 wk work trial | TypeScript/Node, Playwright | Careers · Search JD |
| ElevenLabs | Audio AI; crawling team for training data | Research Engineer – Web Crawlers (worldwide remote, Aug 2026): "billions of pages," dedup, politeness, freshness | Distributed systems; ML data | Careers |
| Reflection AI | Open frontier models; Data team | MTS – Web Crawl Engineer (SF, 2026): URL frontier, scheduling, recrawl, PB-scale | Ray/Spark/Flink, browser automation | Careers |
| Mistral AI | Frontier models (EU) | Web Crawling Engineer: Go/Rust/Zig, CDP, queues (Redis/Aerospike), K8s; Paris/London + remote EU | Go, Rust, Zig | Careers |
| Wynd Labs (Grass) | Distributed crawler via bandwidth-sharing network | Research Crawling Engineer (fully remote, $150–225K, posted 2026): billions of URLs/day | Go, Rust, Python, Java, C++ | Ashby JD |
| Zyte (ex-Scrapinghub) | Scrapy, Zyte API; ethical scraping pioneer (EWDCI co-founder) | Python crawler devs + Browser Stack Engineer (C++) + Senior Reverse Engineer; 100% remote, ~260 people | Python (Scrapy), C++ | Browser Stack JD · Scrapy dev JD |
| Apify | Scraping platform + Actor marketplace; Crawlee framework | Platform engineering; occasionally crawling/actor roles; Prague/remote | TypeScript/Node | apify.com/careers (verify) |
| Bright Data | Proxies + web data; strong 2026 litigation track record | Senior C++ Browser Engine Engineer, backend Node.js roles (SF/remote/IL) | C++, Node.js, K8s | Careers |
| Common Crawl Foundation | Nonprofit open crawl archive; ~2.1B pages/month (Aug 2026) | Jobs page active; Rust tooling (cc-downloader stable release with Rust lib + Python bindings) |
Python, Java, Rust | Jobs · cc-downloader |
| Brave Search | Independent search index (Tailcat roots) | Careers page active; search/crawler infra roles appear periodically | C++, Rust (browser/engine), Go | Careers |
| Spider | Rust crawler + cloud (spider.cloud) | Small team; watch for roles; strong open-source Rust crawler crate | Rust | spider.cloud · GitHub |
| SerpApi | SERP data API; won Google DMCA dismissal Jul 2026 | Engineering roles when posted; company is actively litigating/operating | Ruby/backend | serpapi.com · blog |
| Diffbot | Knowledge graph + auto-extraction | Crawler/data roles periodically | Java/C++/Python | diffbot.com/careers (verify) |
| Browserbase | Browser infra/headless browsers for agents | Infra roles; adjacent to crawler/rendering work | TypeScript, Go | browserbase.com/careers (verify) |
| Lightpanda | Zig headless browser built for AI/crawling (low memory) | Small; strong fit if you like low-level rendering | Zig, Rust-adjacent | lightpanda.io |
| You.com | AI search API | Search/infra roles | Python, Go | you.com/careers (verify) |
Rust-heavy job boards & communities (all verified active)
| Board | Focus | Link |
|---|---|---|
| RustJobs.dev | Exclusive Rust listings + talent network; remote-heavy | rustjobs.dev |
| RustyBoard | General Rust job aggregation | rustyboard.com |
| Rustler | Remote Rust roles, direct-to-EM | rustler.in |
| Rust Jobs (letsgetrusty) | Curated, refreshed daily | jobs.letsgetrusty.com |
| rustjobs.fyi | No-crypto Rust jobs, PR-driven | rustjobs.fyi |
| rustjobs.net | Aggregated Rust roles incl. systems | rustjobs.net |
| Arc.dev | Remote Rust roles | arc.dev/remote-jobs/rust |
| YC Work at a Startup | Filter for "Rust" + "crawler" at YC companies (Exa is YC S21) | workatastartup.com |
| Wellfound | Startup roles incl. Exa's mirrors (shows funding + tech tags) | wellfound.com |
12-Week Portfolio Timeline to Application
Assumes the Rust + crawling roadmap already planned; this lays the presentation + application track on top.
| Week | Build | Present / Apply |
|---|---|---|
| 1 | Crawler core: frontier, fetcher, parser, storage; tokio; metrics scaffold | Set up personal site + repo template (README skeleton, license, CI) |
| 2 | Politeness engine: RFC 9309 parser, token buckets, User-Agent, 429/5xx backoff; checkpoint/resume | Publish Project (a) README v1 with architecture Mermaid |
| 3 | Scale-out: multi-worker, shared frontier, dedup, DNS cache; 10k-domain test run | Draft résumé v1 with keyword map; start LinkedIn/X cleanup |
| 4 | Finish (a): benchmark runs (criterion + hyperfine), flamegraph, first asciinema demo | Publish Project (a) + short writeup; pin on GitHub |
| 5 | (b) HTTP path hardened; extraction with lol-html; escalation heuristics | Build target list: Exa + 20 backup companies; map 2nd-degree contacts |
| 6 | (b) CDP/Playwright renderer pool; cost/latency instrumentation; browser health/restart | Publish (b) README; demo video (Loom) |
| 7 | (b) benchmarks: cost per successful extraction per path; RSS per browser | Read Exa reading list; write notes on 3 posts you'll reference |
| 8 | (c) index build: tantivy BM25 + embeddings + hybrid fusion; eval set creation | Publish (c) README skeleton; draft crawl→search diagram |
| 9 | (c) evals: NDCG@k, recall, latency; incremental update experiment | Publish (c); cross-post writeup to dev.to/Hashnode |
| 10 | (d) Benchmark hub: consolidate methodology, harness, raw JSON; CI regression gate | Publish flagship benchmark writeup; personal-site landing page |
| 11 | Polish: docs, tests, CI badges, CONTRIBUTING; record 2–3 min project videos | Apply to Exa via Ashby; send outreach (email/X/DM) day 1; open 1 small PR to an exa-labs repo |
| 12 | Interview prep: whiteboard crawl architecture, scaling math, profiling stories, legal/ethics Q&A | Follow-up touch 2 (day 5) and 3 (day 12); apply to top 5 backups with tailored notes |
Scaling math to have memorized for interviews:
- 100M pages/day = 1,157 pages/sec sustained = ~4.2M pages/hour; at 50 KB avg HTML ≈ 5 TB raw/day.
- Politeness: 1 rps/host across 40k active hosts = 40k rps cap — the frontier, not the network, is usually the bottleneck.
- Cost: at $0.10/GB egress, 5 TB/day ≈ $500/day just to move bytes; dedup and conditional GETs (ETag/If-Modified-Since) directly cut it.
- Rendering: headless Chrome ~100–300 MB RSS/instance; only 20–30% of pages need it — cost discipline is the feature.
Communities & Networking
| Community | Why join / use |
|---|---|
| Exa Discord | Official; ask real crawl/search questions, share benchmarks; employees present |
| github.com/exa-labs | Contribution surface (agent-skills, exa-mcp-server, SDKs); merged PR → warm intro |
| @ExaAILabs / @WilliamBryk | Company + founder updates; reply substantively to crawl/search threads |
| r/rust | Post your Rust crawler benchmark; recruiters and engineers read it |
| r/webscraping | Crawling practices + industry war stories (read-only for signal; avoid evasion talk) |
| users.rust-lang.org | High-quality Rust systems questions; searchable reputation |
| Tokio Discord | Async runtime help from maintainers; tokio is Exa-relevant |
| Scrapy community + Crawlee Discord | Crawler-specific networks across Python/TS ecosystems; Zyte/Apify engineers present |
| Common Crawl Discord | Web-scale data/archiving crowd; useful for corpus and benchmark sourcing |
| This Week in Rust | Job section + ecosystem pulse; submit your project as a "Project of the Week" candidate |
| Hacker News — Who is Hiring | Monthly thread; AI infra companies post here first |
| Latent Space | The podcast that covered Exa deeply; community has AI infra engineers |
| AI Engineer World's Fair | SF conference where Exa presents; the physical place to meet the team |
| RustConf / Rust Nation | Rust-specific conferences; hallway track = referrals |
| Local: SF Rust meetups, AGI House, YC alumni events | Exa is in SF's Mission District and fully in-person; local presence compounds |
30-day networking cadence
- Week 1: join Exa Discord + one Rust community; introduce yourself with a project link (no ask).
- Week 2: comment substantively on 3 Exa posts (blog/X); open one small PR.
- Week 3: DM 5 crawler-adjacent engineers across target companies (templates above); ask for 15–20 min, no job ask.
- Week 4: ask 2 contacts for a referral or forward; apply to Exa + top 5 backups in the same week so loops run in parallel.
End of guide. Re-verify Exa listings via curl -s "https://api.ashbyhq.com/posting-api/job-board/exa?includeCompensation=true" before submitting — Exa's crawl team is small and roles close fast.
09 — Portfolio Project Specs: 4 Buildable Projects for a Web Crawling Engineer Role at Exa
Audience: Rust beginner-to-intermediate on macOS, following a 12-week Rust + web-crawling roadmap.
Date: September 2026. Rust stable 1.98.x, edition 2024 (default since 1.85). All crate versions below verified Sep 2026 via crates.io / docs.rs sequential checks during spec writing (tokio, reqwest, tantivy, chromiumoxide, scraper) cross-checked against the local toolchain audit 13-toolchain-verified-2026.md (audit date 2026-09-11). Where the audit is newer than a search result, the audit value is used and noted.
Constraint honored: this file is the only file written. No existing files modified.
0. How to use this spec + Exa JD mapping
Each project (A–D) is specified to the same template so a builder can go from zero to a reviewable repo without design decisions left open:
- Goal and why Exa cares — 2. Scope (MVP vs stretch, timeboxed) — 3. Architecture (components, data flow, crate choices with pinned versions) — 4. Data model — 5. Key modules with Rust function signatures — 6. Acceptance criteria + test plan — 7. Benchmark plan (exact commands, metrics) — 8. README requirements — 9. Common failure modes.
Exa job-description language this portfolio must echo (use these exact phrases in READMEs and your CV): 100M+ pages/day, crawl politeness, JavaScript rendering, CDP/Playwright, crawl scheduling, high-performance Rust, frontier queue, dedupe, freshness/recrawl, extraction quality, snippet/highlight, latency/throughput, error budget.
| Exa JD phrase | Proved by | Where reviewer sees it |
|---|---|---|
| 100M+ pages/day | Per-domain concurrency + backpressure math, load test numbers | A README results table, D benchmark |
| crawl politeness | robots.txt enforcement + per-host rate limit + crawl-delay + 429/503 backoff | A code (politeness.rs), A tests, A metrics |
| JavaScript rendering | HTTP-first with browser fallback, rendered-DOM parity check | B code + B results table |
| CDP/Playwright | chromiumoxide CDP session mgmt (primary); Playwright sidecar noted as alternative | B architecture + code |
| crawl scheduling | Frontier with priority/next-fetch-at/score, recrawl policy | A + C scheduling sections |
| high-performance Rust | Tokio, zero-copy where it matters, criterion benches, RSS/throughput numbers | A + D |
| extraction quality | Clean-text pipeline, boilerplate removal, snippet/highlight eval | C |
Shared repo conventions (apply to all four): one repo per project (polite-crawler, hybrid-crawler, crawl-search, crawler-bench), Rust edition 2024, cargo fmt --check + cargo clippy -- -D warnings green in CI, tracing + tracing-subscriber::EnvFilter logging, thiserror/anyhow error policy (libraries thiserror, binaries anyhow), cargo nextest acceptable but plain cargo test is the documented baseline. macOS: install Chrome/Chromium for project B via brew install --cask chromium (or google-chrome); headless works without extra drivers for CDP.
Pinned working dependency set (verified Sep 2026 — use these version = strings):
# verified: tokio 1.53.1 latest (LTS 1.51.x until Mar 2027, MSRV 1.71) — checked crates.io Sep 2026
tokio = { version = "1", features = ["full"] }
# verified: reqwest 0.13.x current major (0.13.4 on crates.io front page; 0.13.5 per 2026-09-11 audit) — use "0.13"
reqwest = { version = "0.13", features = ["json", "gzip", "brotli", "deflate", "stream"] }
# verified: scraper 0.27.0 (2026-05-11) — needs `atomic` feature for Send across Tokio tasks
scraper = { version = "0.27", features = ["atomic"] }
url = "2.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# politeness / resilience
texting_robots = "0.2" # robots.txt parser (no release in 3+ yrs but still best-maintained Rust option)
governor = "0.10" # GCRA per-host rate limiter; alternative: leaky-bucket 1.x
backon = "1" # recommended retry builder (ExponentialBuilder + jitter); backoff 0.4 stable but untouched since 2021
# persistence
rusqlite = { version = "0.40", features = ["bundled"] } # MVP embedded store
deadpool-sqlite = "0.14" # async pool over rusqlite for Tokio
sqlx = { version = "0.9", features = ["postgres", "runtime-tokio", "migrate"] } # stretch: Postgres
blake3 = "1" # content hash dedupe
# browser (project B)
chromiumoxide = "0.9" # verified 0.9.1 (2026-02-25), Tokio-only async CDP
# search (project C)
tantivy = "0.26" # verified 0.26.x (0.26.1 crates.io front page; 0.26.2 per 09-11 audit)
axum = "0.8" # search API layer
# observability
metrics = "0.24"
metrics-exporter-prometheus = "0.18"
# dev/test/bench
wiremock = "0.6"
criterion = "0.8"
Notes: (a)
texting_robots 0.2.2API:Robot::build_from_str(&str)/ matchercheck_access(url, user_agent)semantics — confirm exact method names on docs.rs at build time (crate version pinned, API surface small). (b)governor 0.10key API:DefaultKeyedRateLimiter/RateLimiter::keyed(quota)+until_ready_with_jitter. (c)chromiumoxide 0.9canonical pattern isBrowser::launch(BrowserConfig::builder().build()?)returning(Browser, Handler)+browser.new_page(url). A maintained forkchromey(spider-rs) exists if upstream CDP lags Chromium — do not adopt unless blocked; document the decision.
Project A — Polite distributed-ready Rust crawler
A.1 Goal and why Exa cares
Goal: build an HTTP crawler that can run for hours against thousands of domains without getting blocked, losing URLs, or duplicating work — and prove it with metrics.
Why Exa cares: Exa's JD centers on web-scale crawling (100M+ pages/day) where crawl politeness is a hard requirement, not a nicety: robots.txt compliance, per-domain rate limiting, crawl-delay honoring, and disciplined backoff on 429/403/5xx. A crawler that is fast but impolite is a liability (IP bans, legal/reputational risk, poisoned data). This project is the portfolio's load-bearing proof that you understand the politeness contract and can implement it in high-performance Rust (Tokio + reqwest) with a durable frontier queue, dedupe, persistence, and Prometheus metrics — the exact stack the role asks for.
A.2 Scope (strictly timeboxed: 3 weeks, ~30–40h)
MVP (must ship, week 1–2):
- Seed URLs → normalized URL frontier (SQLite-backed) with per-host queues.
- robots.txt fetch/cache/enforce (allow/disallow + crawl-delay) per host.
- Per-domain token-bucket rate limiting (default 1 rps/host, configurable) + global concurrency cap.
- Fetch via reqwest with timeouts (connect 5s, total 20s), redirect policy (max 5), UA string, conditional GET (ETag/Last-Modified).
- Retry with exponential backoff + jitter on 429/503/5xx and timeouts; honor
Retry-After; no retry on 4xx except 429; backoff state per host (circuit-softening, not full breaker — breaker is stretch). - HTML link extraction (scraper,
a[href]), same-crawl-scope policy (allowlist/blocklist, max depth, max pages). - Content dedupe: canonical URL normalization + blake3 content hash; skip exact-duplicate stores.
- Persistence: SQLite (rusqlite bundled + deadpool-sqlite pool): urls, fetches, robots_cache.
- Prometheus metrics endpoint (
/metrics): pages_fetched_total{status}, fetch_latency histogram, robots_blocked_total, queue_depth gauge, per-host in-flight. - Graceful shutdown (Ctrl-C drains in-flight, checkpoints frontier) + resume from DB.
Stretch (only if MVP done, week 3 — pick at most 2):
- Postgres backend via sqlx migrations (same schema,
FOR UPDATE SKIP LOCKEDclaim queries). - Sitemap.xml discovery (
quick-xml 0.42) as seed expansion. - Bloom pre-filter (
fastbloom 0.17 AtomicBloomFilter) in front of DB dedupe + benchmark of DB-hit reduction. - Multi-process sharding demo: rendezvous-hash (
rendezvous-hash 0.3) sharding by host across 2 workers sharing one Postgres, with dueling-worker test.
Explicitly out of scope: JS rendering (that's project B), full-text search (project C), distributed consensus/election, WARC output (mention as future, don't build).
A.3 Architecture
Components (single binary, modular):
seeds.txt ──▶ normalizer ──▶ frontier (SQLite) ──▶ scheduler (per-host queues + governor buckets)
▲ │ robots gate (texting_robots + moka cache)
│ ▼
link extractor ◀── parser (scraper) ◀── fetcher (reqwest, backon retry)
│ │
▼ ▼
dedupe (blake3) ──▶ store (SQLite) metrics (/metrics via metrics-exporter-prometheus)
Data flow (happy path): scheduler claims next-due URL for a host whose bucket has tokens → robots gate checks cached rules (fetch /robots.txt on miss, cache with TTL) → fetcher GETs with timeouts → on 200 HTML, parser extracts links → normalizer canonicalizes → dedupe checks (URL seen? content hash seen?) → new URLs inserted with next_fetch_at = now + host_delay → fetch row recorded → metrics updated. On 429/503: parse Retry-After, apply backon exponential delay, push host into cooldown map, requeue URL with next_fetch_at in future and attempts+1.
Crate choices (with versions): tokio 1, reqwest 0.13, scraper 0.27+atomic, url 2.5, texting_robots 0.2, governor 0.10 (or leaky-bucket 1.1 — governor preferred for keyed per-host limiters), backon 1, moka 0.12 (robots/DNS TTL cache), blake3 1, rusqlite 0.40 + deadpool-sqlite 0.14, metrics 0.24 + metrics-exporter-prometheus 0.18, tracing 0.1, clap 4 (CLI). Stretch: sqlx 0.9, quick-xml 0.42, fastbloom 0.17, rendezvous-hash 0.3.
Key config (config.toml):
user_agent = "PortfolioCrawler/0.1 (+https://example.com/contact)"
global_concurrency = 32
default_host_rps = 1.0
max_depth = 3
max_pages = 5000
fetch_timeout_secs = 20
respect_robots = true
retry_max_attempts = 4
A.4 Data model
SQLite DDL (also the Postgres migration baseline):
CREATE TABLE urls(
id INTEGER PRIMARY KEY,
url TEXT NOT NULL,
url_canonical TEXT NOT NULL UNIQUE,
host TEXT NOT NULL,
depth INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued', -- queued|inflight|done|failed|blocked_robots|skipped_dup
next_fetch_at INTEGER NOT NULL DEFAULT 0, -- unix millis
attempts INTEGER NOT NULL DEFAULT 0,
score REAL NOT NULL DEFAULT 0.0, -- scheduler priority
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_urls_status_next ON urls(status, next_fetch_at);
CREATE INDEX idx_urls_host ON urls(host);
CREATE TABLE fetches(
id INTEGER PRIMARY KEY,
url_id INTEGER NOT NULL REFERENCES urls(id),
fetched_at INTEGER NOT NULL,
status_code INTEGER,
error TEXT,
bytes INTEGER,
content_hash TEXT, -- blake3 hex
etag TEXT,
final_url TEXT,
latency_ms INTEGER NOT NULL
);
CREATE INDEX idx_fetches_url ON fetches(url_id);
CREATE INDEX idx_fetches_hash ON fetches(content_hash);
CREATE TABLE robots_cache(
host TEXT PRIMARY KEY,
body TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
ttl_secs INTEGER NOT NULL DEFAULT 86400
);
Core structs:
pub struct CrawlConfig { pub user_agent: String, pub global_concurrency: usize, pub default_host_rps: f64, pub max_depth: u32, pub max_pages: u64, pub fetch_timeout: Duration, pub respect_robots: bool, pub retry_max_attempts: u32 }
pub struct FrontierItem { pub id: i64, pub url: Url, pub host: String, pub depth: u32, pub attempts: u32, pub score: f64, pub next_fetch_at: Instant }
pub enum FetchOutcome { Fetched { status: u16, bytes: usize, content_hash: String, links: Vec<Url> }, BlockedByRobots, SkippedDuplicate, RetryableFailure(String), PermanentFailure(String) }
A.5 Key modules with Rust function signatures
src/
main.rs — CLI (clap), builds AppState, runs scheduler loop, /metrics server, Ctrl-C handling
config.rs — load/validate config.toml
normalize.rs — URL canonicalization
frontier.rs — durable queue ops
politeness.rs — robots + rate limit + backoff state
fetcher.rs — HTTP fetch + retry
parser.rs — HTML link extraction
dedupe.rs — URL + content-hash dedupe
store.rs — SQLite persistence
metrics.rs — Prometheus recorders
scheduler.rs — main loop joining all of the above
// normalize.rs
pub fn canonicalize(raw: &str) -> Result<Url, url::ParseError>;
pub fn canonicalize_with_base(base: &Url, raw: &str) -> Result<Option<Url>, url::ParseError>; // None = non-http(s)/fragment-only/data:
pub fn host_of(url: &Url) -> String;
// frontier.rs
pub async fn enqueue(pool: &Pool, items: &[NewUrl]) -> Result<usize>;
pub async fn claim_next(pool: &Pool, host: &str, now_millis: i64) -> Result<Option<FrontierItem>>;
pub async fn mark_done(pool: &Pool, id: i64, outcome: &FetchOutcome) -> Result<()>;
pub async fn requeue_with_delay(pool: &Pool, id: i64, next_fetch_at_millis: i64) -> Result<()>;
pub async fn queue_depth(pool: &Pool) -> Result<u64>;
// politeness.rs
pub async fn is_allowed(robots: &RobotsCache, url: &Url, user_agent: &str) -> bool;
pub async fn refresh_robots_if_stale(client: &reqwest::Client, db: &Pool, cache: &moka::future::Cache<String, CachedRobots>, host: &str) -> Result<CachedRobots>;
pub fn crawl_delay_for(host: &str, robots: &CachedRobots, default_rps: f64) -> Duration;
pub async fn acquire_host_permit(limiter: &DefaultKeyedRateLimiter<String>, host: &str) -> tokio::sync::OwnedSemaphorePermit; // or governor until_ready
pub fn backoff_for_attempt(attempt: u32, retry_after: Option<Duration>) -> Duration; // exponential + jitter, capped
// fetcher.rs
pub async fn fetch_once(client: &reqwest::Client, item: &FrontierItem, cfg: &CrawlConfig) -> Result<RawFetch, FetchError>;
pub async fn fetch_with_retry(client: &reqwest::Client, politeness: &Politeness, item: &FrontierItem, cfg: &CrawlConfig) -> FetchOutcome;
pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration>;
// parser.rs
pub fn extract_links(base: &Url, html: &str) -> Vec<Url>; // scraper a[href], filters mailto:/javascript:/fragments
pub fn is_html(resp: &reqwest::Response) -> bool; // content-type sniff
// dedupe.rs
pub fn content_hash(bytes: &[u8]) -> String; // blake3 hex
pub async fn is_duplicate_content(pool: &Pool, hash: &str) -> Result<bool>;
// store.rs
pub async fn record_fetch(pool: &Pool, url_id: i64, row: &FetchRow) -> Result<()>;
pub async fn get_robots(pool: &Pool, host: &str) -> Result<Option<CachedRobots>>;
pub async fn put_robots(pool: &Pool, host: &str, body: &str, ttl_secs: i64) -> Result<()>;
// scheduler.rs
pub async fn run_crawl(cfg: CrawlConfig, pool: Pool, seeds: Vec<Url>) -> Result<CrawlStats>;
NewUrl, RawFetch, FetchRow, CachedRobots, CrawlStats are plain structs/enums defined alongside the functions above.
A.6 Acceptance criteria and test plan
Acceptance (all must hold on a tagged v0.1.0):
- Crawls 1,000 pages across ≥20 hosts from a seed list without a single robots.txt violation (verified by offline robots audit test).
- Sustains configured per-host rate (e.g., 1 rps/host ±10% measured from fetch timestamps) under 32-way global concurrency.
- Zero duplicate stores: same content hash stored once; same canonical URL fetched once per crawl (reruns resume, not refetch done rows).
- On a chaos fixture (50% 503 with
Retry-After: 1, 10% timeouts), completes ≥98% of seed URLs within retry budget and records attempts in DB. /metricsexposes all documented counters/histograms; Grafana-lesscurl localhost:9000/metricsshows them.- Kill -9 / Ctrl-C mid-crawl → restart resumes from DB with <1% refetch of
donerows.
Test plan:
| Layer | Tool | Cases |
|---|---|---|
| unit | cargo test |
canonicalize (trailing slash, default ports, UTM strip, fragments, IDNA, relative resolve); Retry-After parse (seconds + HTTP-date); backoff monotonic + capped; link extractor fixture (10 hand HTML files incl. <base href>, malformed HTML) |
| integration | cargo test --test '*' + wiremock 0.6 |
robots allow/disallow/crawl-delay matrix (serve /robots.txt from mock, assert blocked URLs never fetched); 429+Retry-After respected (assert gap ≥ header); 503 flaky then 200 succeeds within budget; redirect chain ≤5 then stop; conditional GET sends If-None-Match |
| persistence | sqlite in :memory: / tmpfile |
crash-resume: insert 100 queued, mark 50 done, kill pool, reopen, assert claim order and no dup |
| load | release binary + local fixture | cargo run --release -- --seeds seeds-20hosts.txt --max-pages 1000 measuring pages/s, p50/p95 latency, RSS; assert rate-limit compliance from fetches timestamps |
| lint | clippy/fmt | cargo clippy --all-targets -- -D warnings && cargo fmt --check |
Exact commands:
cargo test
cargo test --test robots -- --nocapture
cargo clippy --all-targets -- -D warnings && cargo fmt --check
RUST_LOG=info cargo run --release -- --config config.toml --seeds seeds.txt --max-pages 1000
curl -s localhost:9000/metrics | grep -E 'pages_fetched|queue_depth|robots_blocked'
A.7 Benchmark plan
Capture on macOS, release profile, machine spec recorded (chip, RAM, Rust rustc --version):
# 1. throughput vs concurrency (local wiremock fixture, 200 pages, mixed 10–200ms latency)
/usr/bin/time -l cargo run --release -- --config bench.toml --seeds bench-seeds.txt --max-pages 200 2>&1 | tail -20
# vary global_concurrency 8/16/32/64 in bench.toml copies; record pages/s = pages/wall
# 2. rate-limiter compliance: query DB inter-arrival per host
sqlite3 crawl.db "SELECT host, AVG(delta) FROM (SELECT host, fetched_at - LAG(fetched_at) OVER (PARTITION BY host ORDER BY fetched_at) AS delta FROM fetches JOIN urls ON urls.id=fetches.url_id) GROUP BY host;"
# 3. memory: peak RSS via /usr/bin/time -l (macOS reports maximum resident set size)
/usr/bin/time -l cargo run --release -- --max-pages 2000 2>&1 | grep "maximum resident"
# 4. retry behavior: chaos fixture percentages vs completion rate + total 429 count from /metrics
Metrics table to publish: concurrency | pages | wall_s | pages/s | p50_ms | p95_ms | peak_RSS_MB | 429s | robots_blocked | dups_skipped.
A.8 README requirements (what an Exa reviewer must see in ≤3 min)
- One-paragraph pitch naming politeness as the headline feature.
- Architecture diagram (ASCII is fine — copy from A.3).
- Quickstart that works verbatim on macOS (install, seed, run, metrics curl) — under 5 min to first crawl.
- Results table from A.7 (real numbers, machine spec footnoted).
- Politeness section: UA, default rps, crawl-delay handling, Retry-After handling, robots cache TTL — with links to
politeness.rsline anchors. - Limits/future work (honest: single binary, no JS, no WARC) + what you'd do with 10× budget.
A.9 Common failure modes and handling
| Failure | Symptom | Handling in spec |
|---|---|---|
| robots.txt fetch fails (DNS/timeout/5xx) | Unknown permission | Fail closed for crawl-delay (use default 1 rps) but fail open for allow (proceed, log, short TTL 1h); never cache failures for 24h |
| 403 vs 429 confusion | Treating IP-ban 403 as retryable → ban escalation | 403 = permanent for that URL (log host-level counter; if host 403 rate >20%, auto-cooldown host 10 min) |
| Retry-After HTTP-date vs seconds | Panic/ignore | Parse both; clamp to [1s, 10min]; ignore malformed with warning + exponential fallback |
| Governor burst misconfig | 10× spike on start | Quota::per_second(1) with burst_size(1) default; document burst=1 rationale |
| SQLite lock contention | database is locked under 32 tasks |
Single writer discipline: all writes via one deadpool-sqlite pool with interact(); WAL mode (PRAGMA journal_mode=WAL); claim queries short-lived |
| URL explosion (calendars/facets) | Frontier grows unbounded | Per-host cap (e.g., 10k queued), depth cap, query-param budget (strip known tracking params, cap distinct query URLs/host), scope allowlist |
Scraper !Send HTML tree across await |
Compile error | Enable scraper/atomic feature (verified remedy per upstream README) |
| Clock skew for next_fetch_at | Starvation | Store millis UTC; scheduler uses monotonic Instant for waits, DB time only for ordering/resume |
Project B — Hybrid HTTP/browser crawler (HTTP-first, CDP fallback)
B.1 Goal and why Exa cares
Goal: crawl JS-heavy pages correctly without paying browser cost for every page: try HTTP first, detect JS-rendered gaps, and fall back to a real Chromium via CDP only when needed — then extract the same clean DOM both paths.
Why Exa cares: The role explicitly calls out JavaScript rendering and CDP/Playwright. At Exa scale the cost question dominates: browser rendering is 10–100× more expensive than HTTP fetch. Reviewers look for the judgment to build hybrid (not browser-everything), the ability to manage CDP sessions robustly (pooling, timeouts, crashes), and a measurable definition of "needs JS" rather than vibes.
B.2 Scope (strictly timeboxed: 3 weeks, ~25–35h)
MVP:
- Reuse project A's politeness/frontier primitives (import as path dependency or copy
politeness+normalizemodules — document which). - HTTP-first fetch (reqwest 0.13); JS-need classifier: content-length < threshold AND (
<div id="root">-style markers OR absence of expected selectors OR known SPA fingerprints like__NEXT_DATA__without rendered text). - CDP fallback pool (chromiumoxide 0.9): fixed pool (default 2 browser pages),
Page::gotowith navigation timeout 15s, wait-for-selector or network-idle (whichever first, capped),Page::content()extraction. - Unified extraction: same
extract_main(html)function runs on HTTP body and rendered HTML; recordrender_mode: http|browserper fetch. - Parity harness: for N sample URLs, diff HTTP-text vs rendered-text (word overlap ratio) to justify each fallback.
- Metrics:
render_fallback_total{reason}, browser navigation latency histogram, browser crashes/restarts, pages by render_mode. - macOS quickstart with system Chromium (no Docker required).
Stretch (pick ≤1):
- Playwright sidecar comparison: Node Playwright server + Rust HTTP client, one parity table HTTP vs CDP vs Playwright.
- Blocklists (images/fonts/media) + request interception to cut render cost; measure bytes/time saved.
- Screenshot-on-failure + DOM snapshot archive for debugging extraction regressions.
Out of scope: full Playwright migration, stealth/anti-bot evasion (document as non-goal for ethics + IP-reputation reasons), login/paywall flows.
B.3 Architecture
URL ──▶ http_fetch (reqwest) ──▶ needs_js?(classifier) ── NO ──▶ extract_main ──▶ store (+render_mode=http)
│
YES
▼
browser_pool (chromiumoxide, size 2–4)
├─ acquire page (semaphore + timeout)
├─ goto + wait (selector|network idle, 15s cap)
├─ content() ──▶ extract_main ──▶ store (+render_mode=browser)
└─ on crash/timeout: restart page, requeue with attempts+1, metric++
Crate choices: reuse A set + chromiumoxide 0.9 (verified 0.9.1). Supporting: tokio::sync::Semaphore for pool (no extra dep), regex 1 or once_cell/std::sync::OnceLock for SPA markers, jaro-winkler or hand-rolled token overlap for parity (prefer hand-rolled to avoid new dep — document). Playwright stretch uses playwright Node sidecar over HTTP, not a Rust CDP replacement, to keep the comparison honest.
Browser launch (macOS):
let (browser, mut handler) = Browser::launch(
BrowserConfig::builder()
.headless(true) // chromiumoxide builder API; verify flag name on docs.rs 0.9
.request_timeout(Duration::from_secs(15))
.build()?
).await?;
tokio::spawn(async move { while let Some(_) = handler.next().await {} });
If
Browser::launchcannot find Chromium on macOS, setCHROME_PATH=$(brew --prefix chromium --cask 2>/dev/null)/Chromium.app/Contents/MacOS/Chromiumor pass.chrome_executable(path). Document the exact env var in README.
B.4 Data model
Extend A's fetches with:
ALTER TABLE fetches ADD COLUMN render_mode TEXT NOT NULL DEFAULT 'http'; -- http|browser
ALTER TABLE fetches ADD COLUMN fallback_reason TEXT; -- thin-body|missing-selector|spa-marker|http-error
ALTER TABLE fetches ADD COLUMN js_heap_bytes INTEGER; -- optional stretch
pub enum RenderMode { Http, Browser }
pub enum FallbackReason { ThinBody, MissingSelector(&'static str), SpaMarker(&'static str), HttpError(u16) }
pub struct RenderedFetch { pub html: String, pub mode: RenderMode, pub reason: Option<FallbackReason>, pub nav_latency: Duration }
pub struct ParityReport { pub url: Url, pub http_words: usize, pub rendered_words: usize, pub overlap_ratio: f64, pub verdict: FallbackReason }
Classifier thresholds (defaults, tunable): thin_body_words < 50 + one SPA marker present → fallback; expected-selector list per seed domain (e.g., article, .product-price).
B.5 Key modules with Rust function signatures
src/
main.rs — CLI, pool sizing flags
http.rs — thin wrapper over A's fetcher (reuse)
classify.rs — needs_js decision
browser_pool.rs— CDP pool lifecycle
render.rs — goto+wait+content
extract.rs — shared extract_main (used by both paths)
parity.rs — HTTP vs rendered diff harness
// classify.rs
pub fn needs_js(http_body: &str, expected_selectors: &[&str]) -> Option<FallbackReason>;
pub fn word_count_visible_text(html: &str) -> usize;
// browser_pool.rs
pub struct BrowserPool { browser: Browser, sem: std::sync::Arc<tokio::sync::Semaphore>, nav_timeout: Duration }
impl BrowserPool {
pub async fn new(pool_size: usize, nav_timeout: Duration) -> Result<Self>;
pub async fn render(&self, url: &Url, wait_for: Option<&str>) -> Result<RenderedFetch>;
}
// render.rs
pub async fn goto_and_content(page: &Page, url: &Url, wait_for: Option<&str>, timeout: Duration) -> Result<String>;
// extract.rs — MUST be identical for both paths
pub fn extract_main(html: &str) -> CleanDoc; // title, text, links; strips nav/boilerplate (rule-based for B; C upgrades this)
pub struct CleanDoc { pub title: String, pub text: String, pub links: Vec<Url> }
// parity.rs
pub fn overlap_ratio(a: &str, b: &str) -> f64; // token-set Jaccard or containment
pub async fn parity_report(urls: &[Url], pool: &BrowserPool, client: &reqwest::Client) -> Vec<ParityReport>;
B.6 Acceptance criteria and test plan
Acceptance:
- On a 30-URL fixture (15 static, 15 JS-heavy local fixtures — see below), HTTP path handles all 15 static; fallback triggers on ≥12/15 JS-heavy; rendered extraction recovers ≥80% of expected probe strings.
- No browser use for static pages:
render_mode=browsercount == 0 on static-only seed run. - Browser crash injection (kill page mid-render in a test) → pool recovers, URL requeued, metric
browser_restarts_totalincrements, crawl completes. - p95 navigation latency recorded; pool exhaustion degrades to queued waits, never deadlocks (test with pool_size=1, 10 URLs).
- macOS fresh-clone quickstart works with only
brew install --cask chromiumas extra step.
JS-heavy fixtures (no external flakiness): serve locally via wiremock/axum-test: (a) empty <div id="root"> + inline script injecting <p>rendered-price $42</p> on load; (b) __NEXT_DATA__ JSON with content only after hydration script; (c) delayed fetch() populating article body (tests network-idle wait). Probe strings asserted in tests.
| Layer | Cases |
|---|---|
| unit | classifier true/false matrix (8 fixtures); overlap_ratio edge cases (empty, identical, disjoint) |
| integration (needs Chrome) | #[ignore]-gated CDP tests: render local fixture → assert probe string; timeout fixture (never-settling page) → error within nav_timeout+2s; pool test size 1 × 5 URLs sequential correctness |
| fallback policy | static site run → 0 fallbacks; SPA run → fallback reasons distribution matches expectations |
| load | 50 mixed URLs, pool 2: record http-vs-browser latency split + CPU/RSS delta |
cargo test classify parity
cargo test -- --ignored # CDP tests (requires Chromium)
CHROME_PATH="$(brew --prefix)/bin/chromium" cargo run --release -- --seeds seeds-mixed.txt --pool-size 2
B.7 Benchmark plan
# HTTP-only vs hybrid on same 50-URL mixed fixture
cargo run --release -- --mode http-only --seeds mixed50.txt 2>&1 | tee bench-http.log
cargo run --release -- --mode hybrid --seeds mixed50.txt --pool-size 2 2>&1 | tee bench-hybrid.log
# metrics to table: mode | pages | wall_s | pages/s | p50_nav_ms | p95_nav_ms | peak_RSS_MB | fallback_rate | probe_recovery_%
# cost argument: browser pages/s ÷ http pages/s = cost multiplier; publish it honestly (expect 5–50×)
/usr/bin/time -l cargo run --release -- --mode hybrid --seeds mixed50.txt 2>&1 | grep -E "maximum resident|real"
Publish the parity table (URL, http_words, rendered_words, overlap, verdict) as CSV in repo benches/parity.csv + summary in README.
B.8 README requirements
- Cost-first framing: "browser is fallback, not default" + measured multiplier.
- Diagram from B.3 + fallback-reason taxonomy.
- macOS Chromium setup (brew line + CHROME_PATH fallback) verbatim.
- Results: latency split table + parity CSV summary + when-to-render decision rule you'd defend in interview.
- Ethics note: no stealth/evasion, respects robots.txt even for rendered fetches (same gate as A).
B.9 Common failure modes and handling
| Failure | Handling |
|---|---|
| Chromium not found on macOS | Clear error printing expected paths + brew line; CHROME_PATH override |
Browser::launch handler task dropped |
Always spawn handler pump; document as must-do (silent hang otherwise) |
| Navigation never settles (infinite polling) | Cap wait with tokio::time::timeout(nav_timeout); treat as retryable, record reason |
| Page crash / target destroyed | Catch, restart page (new browser.new_page), increment restart metric, requeue once |
| Selector wait flakiness | Prefer network-idle OR content-stability (two consecutive 500ms polls same word count) over single selector; per-domain wait_for override |
| Memory growth over long runs | Recreate pages every N renders (default 25); record RSS; document |
| HTTP/browser text divergence breaking downstream | Shared extract_main + parity harness in CI (--ignored CDP test on fixtures) catches drift |
Project C — Crawler-to-search pipeline (crawl → clean text → index → search API)
C.1 Goal and why Exa cares
Goal: turn crawled pages into a searchable product: crawl → parse clean text → snippets/highlights → Tantivy index (BM25) with optional embeddings lane → HTTP search API with measured latency/throughput.
Why Exa cares: Exa sells search over crawled content, not crawls. This project proves you understand the downstream value chain: extraction quality → index design → query serving → measurable serving SLOs. It maps to "crawl scheduling" (recrawl/freshness), "snippets/highlights," and the latency/throughput discipline of a production search API — and gives you a live demo link no other portfolio piece provides.
C.2 Scope (timeboxed: 3 weeks, ~30–40h)
MVP (BM25 lane, must ship):
- Input: project A crawler output (SQLite
fetches+ raw HTML store — addpages_rawtable or flat files<content_hash>.html). - Clean-text pipeline: boilerplate removal (rule-based: strip nav/header/footer/script/style via selector denylist; keep
article/main, fallback body), title extraction (og:title →<title>→ h1), language detect stub (record, don't filter), chunking (512-token ~ 2000-char windows, 10% overlap) for snippets. - Tantivy 0.26 index: schema (url, title, body, host, fetched_at, chunk_id), BM25 default, tokenizer with stemming + lowercase; incremental indexing (only new hashes).
- Search API (axum 0.8):
POST /search {query, top_k, host_filter?}→[{url, title, snippet (highlighted), score}];GET /health,GET /stats(doc count, index size, last-indexed-at). - Snippet/highlight: Tantivy
SnippetGeneratoron body field, 2 fragments,<b>tags (escape HTML first). - Freshness: recrawl policy — reindex updated hashes, delete 404-gone URLs (tombstone, don't hard-delete immediately; 7-day grace).
- Eval: 20 hand-written queries with expected URLs in top-3 (recall@3) + p50/p95 API latency under load.
Stretch (pick ≤1):
- Embeddings lane:
fastembed 6(verified 6.0.3) local ONNX embeddings +lancedb 0.38orusearch 2.26sidecar index, hybrid BM25+vector RRF fusion endpointPOST /search/hybrid. Measure latency delta honestly. - Sitemap/RSS recrawl scheduler with
next_fetch_atper URL based on change frequency estimate.
Out of scope: distributed index/sharding, learning-to-rank, LLM reranking (mention as future; a one-line "rerank with cross-encoder" without eval is worse than none).
C.3 Architecture
crawl.db (fetches+raw) ──▶ clean (extract_main v2) ──▶ chunks ──▶ tantivy index (mmap dir ./index)
│
query ──▶ axum POST /search ──▶ tantivy QueryParser ──▶ top_k ──▶ snippet gen ──▶ JSON
│
metrics (index docs, query latency hist) ◀── /metrics ──▶ freshness job (recrawl → reindex/delete)
Crate choices: tantivy 0.26 (verified), axum 0.8, serde 1, tokio 1, rusqlite 0.40 (read crawl.db), tracing 0.1, metrics 0.24. Stretch: fastembed 6 + lancedb 0.38 (embedded, simplest) — usearch 2.26 only if lancedb fights the build.
Tantivy schema (concrete):
schema_builder.add_text_field("url", STRING | STORED);
schema_builder.add_text_field("title", TEXT | STORED);
schema_builder.add_text_field("body", TEXT | STORED); // indexed with positions for snippets
schema_builder.add_text_field("host", STRING | STORED | INDEXED);
schema_builder.add_u64_field("fetched_at", INDEXED | STORED | FAST);
schema_builder.add_text_field("chunk_id", STRING | STORED);
Index writer: 50MB heap (IndexWriter::new(index, 50_000_000)), commit per batch of 500 docs; searcher reload on commit.
C.4 Data model
-- reuse A tables + add:
CREATE TABLE pages_raw(
content_hash TEXT PRIMARY KEY,
url_canonical TEXT NOT NULL,
html_path TEXT NOT NULL, -- ./raw/<hash>.html (don't bloat SQLite)
cleaned_text TEXT, -- cached clean text (optional, speeds reindex)
title TEXT,
indexed_at INTEGER, -- NULL = pending
deleted_at INTEGER -- tombstone for 404-gone
);
CREATE INDEX idx_pages_pending ON pages_raw(indexed_at) WHERE indexed_at IS NULL;
pub struct CleanPage { pub url: Url, pub title: String, pub text: String, pub host: String, pub fetched_at: i64, pub content_hash: String }
pub struct Chunk { pub chunk_id: String, pub url: String, pub title: String, pub body: String, pub host: String, pub fetched_at: u64 }
pub struct SearchHit { pub url: String, pub title: String, pub snippet: String, pub score: f32 }
Chunking: text-splitter 0.32 (MarkdownSplitter/TextSplitter, 2000 chars / 200 overlap) preferred over hand-rolled; if it fights the build, hand-roll char-boundary windows (document fallback).
C.5 Key modules with Rust function signatures
src/
main.rs — subcommands: `index`, `serve`, `eval`
clean.rs — HTML → CleanPage
chunk.rs — CleanPage → Vec<Chunk>
index.rs — Tantivy open/build/update
api.rs — axum routes
snippets.rs — highlight generation
freshness.rs — recrawl/reindex/delete job
eval.rs — recall@k harness
// clean.rs
pub fn clean_page(url: &Url, html: &str, fetched_at: i64) -> CleanPage;
// chunk.rs
pub fn chunk_page(page: &CleanPage, max_chars: usize, overlap: usize) -> Vec<Chunk>;
// index.rs
pub fn open_or_create_index(path: &str) -> Result<tantivy::Index>;
pub fn index_chunks(index: &Index, chunks: &[Chunk]) -> Result<usize>; // returns docs added
pub fn delete_url(index: &Index, url: &str) -> Result<()>;
// api.rs
pub async fn search(State(state): State<AppState>, Json(q): Json<SearchQuery>) -> Json<SearchResponse>;
pub struct SearchQuery { pub query: String, pub top_k: usize, pub host_filter: Option<String> }
// snippets.rs
pub fn snippet_for(index: &Index, field: Field, doc_id: DocAddress, query: &dyn Query) -> String;
// freshness.rs
pub async fn reindex_pending(pool: &Pool, index: &Index, batch: usize) -> Result<usize>;
pub async fn tombstone_gone(pool: &Pool, index: &Index, gone_urls: &[String]) -> Result<()>;
// eval.rs
pub async fn recall_at_k(base: &str, cases: &[EvalCase], k: usize) -> EvalReport; // base = http://localhost:3000
C.6 Acceptance criteria and test plan
Acceptance:
- Index 2,000+ pages from project A output;
GET /statsreports correct count; index dir size recorded. - 20-query eval: recall@3 ≥ 0.75 on hand-labeled cases (checked into
eval/queries.jsonlwith expected URL substrings). - Snippets: every hit includes query-term highlight (
<b>) or documented fallback (e.g., leading 200 chars when term only in title). - Serving SLO on macOS (release): p50 < 50ms, p95 < 200ms for top_k=10 over 2k docs, measured with
hey/ohaat 50 rps/30s. - Freshness: changed page re-crawled → reindexed (new snippet reflects new text); 404 page → tombstoned, absent from results after grace job.
- Cold start:
serveloads index in <2s (mmap warm) — measured and documented.
| Layer | Cases |
|---|---|
| unit | clean_page fixtures (nav-heavy blog, product page, empty body → empty text not panic); chunker overlap math; snippet escaping (<script> in body must not execute in snippet) |
| integration | index 50 fixture pages → search each title phrase → assert URL rank 1; delete_url removes from results; reindex updates snippet |
| eval | cargo run -- eval --k 3 prints recall@3 + failures table; failures triaged (tokenizer? chunking? content missing?) |
| load | oha -z 30s -q 50 http://localhost:3000/search POST body; record p50/p95/throughput + RSS |
cargo run --release -- index --db ../polite-crawler/crawl.db --index ./index
cargo run --release -- serve --index ./index --port 3000 &
curl -s -X POST localhost:3000/search -H 'content-type: application/json' -d '{"query":"rust async crawler","top_k":5}' | jq .
cargo run --release -- eval --k 3 --file eval/queries.jsonl
oha -z 30s -q 50 -m POST -H 'content-type: application/json' -d '{"query":"crawl politeness","top_k":10}' http://localhost:3000/search
(hey is the fallback if oha isn't installed: brew install oha preferred; document either.)
C.7 Benchmark plan
| Benchmark | Command | Metrics |
|---|---|---|
| index throughput | /usr/bin/time cargo run --release -- index --db crawl.db |
docs/s, wall_s, index MB, peak RSS |
| query latency | oha -z 30s -q 50 …/search |
p50/p95/p99 ms, req/s, errors |
| top_k scaling | loop top_k 1/5/10/20 at fixed concurrency | latency vs k curve |
| recall eval | cargo run -- eval |
recall@1/@3/@10 + failure list |
| (stretch) hybrid delta | same queries on /search vs /search/hybrid |
latency delta ms, recall delta |
Publish: machine spec, doc count, index size, full oha output pasted + summary table.
C.8 README requirements
- Demo-first:
index+serve+ one curl that returns a highlighted snippet — copy-pasteable. - Pipeline diagram + schema listing (field types matter to search reviewers).
- Eval table (query, expected, got-rank, pass/fail) + recall@3 number + 3 failure autopsies (shows judgment).
- Latency table (p50/p95, throughput) with machine spec.
- Freshness policy spelled out (recrawl interval heuristic, tombstone grace).
- "What I'd do with 10× data" (sharding, incremental merge policy tuning, vector lane) — 5 lines max.
C.9 Common failure modes and handling
| Failure | Handling |
|---|---|
| Tantivy schema change needs reindex | Version the index dir (index/v1/); serve refuses mismatched schema version with clear error; document full-reindex command |
| Lock contention (writer + searcher) | Single writer thread; searcher reload after commit; never open two writers on same dir (error message + rm -rf recovery documented) |
| Tokenizer mismatch (index vs query) | Build query parser from same schema/tokenizer registration function build_schema() shared by index+serve (one function, two callers) |
| HTML in snippets (XSS in demo) | Escape before highlight injection; test with <script> fixture |
| CJK/Unicode queries score 0 | Document tokenizer limitation (default Latin stemming); point to tantivy-jieba/lindera as named future, don't half-integrate |
| Index bloat from chunks | Record bytes/doc; cap chunks/page (e.g., first 20 chunks) with documented truncation policy |
| Stale results after recrawl | indexed_at watermark + reindex_pending job; eval includes one "updated content" case |
Project D — Crawler benchmark writeup (Rust vs Python and/or HTTP vs browser)
D.1 Goal and why Exa cares
Goal: produce the one artifact hiring managers actually forward: a rigorous, reproducible benchmark comparing (a) Rust vs Python HTTP crawling and (b) HTTP vs browser rendering — with throughput, memory, latency, and error-rate numbers, documented methodology, and honest tradeoff analysis.
Why Exa cares: Exa operates at 100M+ pages/day where language and architecture choices have direct cost consequences. A candidate who can measure rather than assert — controlled fixtures, error bars, reproducible scripts, disclosed limitations — signals production judgment. This writeup multiplies the value of projects A–C by turning their raw numbers into a decision document ("when to pay for Rust, when to pay for browsers").
D.2 Scope (timeboxed: 2 weeks, ~15–25h — includes writing)
MVP:
- Two comparisons, both on local fixtures (no live-internet benchmarks — unreproducible and impolite):
- Rust (project A) vs Python (aiohttp + BeautifulSoup/lxml baseline, ~100 lines) on identical 500-URL fixture with controlled latency/error injection.
- HTTP vs browser (project B modes) on identical 50-URL mixed fixture.
- Metrics for each: throughput (pages/s), p50/p95 fetch latency, peak RSS, CPU (wall × cores approx via
time), error/completion rate, politeness compliance (per-host rate check). benches/with one-command runners +results/<date>-<machine>.csvraw data +writeup.md(the deliverable) with charts (ASCII tables acceptable; PNG via matplotlib optional).- Reproducibility: pinned versions (Rust crates +
python --version+pip freeze), machine spec, 3 repetitions minimum, median reported.
Stretch (pick ≤1): flamegraph/byte-level profile of one bottleneck found (e.g., cargo flamegraph or samply) with before/after number; or Tokio console scheduling screenshot (tokio-console 0.1.14 + console-subscriber 0.5, needs RUSTFLAGS="--cfg tokio_unstable").
Out of scope: live-web benchmarks, cloud cost modeling beyond simple arithmetic, proving "Rust always wins" (report what you measure, including Python wins on tiny crawls).
D.3 Architecture (harness, not a service)
fixtures/ (axum/wiremock servers: fixed-latency pages, thinning SPA pages, chaos mix 429/503/timeout)
├─ rust-http (project A binary, --max-pages 500)
├─ py-http (baseline.py: aiohttp + asyncio.Semaphore + per-host sleep + robots stub)
├─ rust-hybrid(project B binary, --mode hybrid)
benches/run.sh — builds both, warms fixtures, runs 3 reps each, collects /usr/bin/time -l + app metrics → results/*.csv
writeup.md — method, tables, charts, tradeoffs, recommendation matrix
Python baseline spec (keep it fair, ~100 lines): aiohttp.ClientSession(timeout), global Semaphore(32), per-host asyncio.sleep(1.0) rate gate (documents that Python can be polite), backoff-style manual retry (or tenacity), lxml parse for a[href]. No C-extension cheating beyond lxml (standard practice — disclose). Same seed list, same depth/page caps, same fixture server. Pin: python3 --version (macOS 3.12+), pip freeze > results/requirements.txt.
D.4 Data model (results schema)
run_id,impl,mode,rep,pages,wall_s,pages_per_s,p50_ms,p95_ms,peak_rss_mb,errors,completion_rate,fallback_rate,notes
2026-09-20-a1,rust,http,1,500,41.2,12.1,180,410,68,3,0.994,,concurrency=32
2026-09-20-p1,python,http,1,500,55.7,9.0,210,520,91,3,0.994,,sem=32
// benches/report.rs (tiny helper, optional)
pub struct BenchRow { pub impl_: String, pub mode: String, pub rep: u32, pub pages: u64, pub wall_s: f64, pub p50_ms: u64, pub p95_ms: u64, pub peak_rss_mb: u64, pub errors: u64 }
pub fn summarize(rows: &[BenchRow]) -> SummaryTable; // median across reps
pub fn write_csv(path: &str, rows: &[BenchRow]) -> Result<()>;
D.5 Key modules (harness scripts, not library code)
benches/
run.sh — full matrix runner (build, warm, 3 reps, collect)
fixtures.rs|py — fixture server (or reuse wiremock fixtures from A/B)
baseline.py — Python crawler (~100 lines, spec in D.3)
report.rs — CSV → median summary tables (or python script; either documented)
results/
<date>-<machine>.csv
requirements.txt (pip freeze)
versions.txt (rustc --version; cargo --version; python3 --version; chrome --version)
writeup.md — the deliverable (template in D.8)
# benches/run.sh sketch (exact flags fixed at build time, recorded in results/):
set -euo pipefail
cargo build --release --manifest-path ../polite-crawler/Cargo.toml
cargo build --release --manifest-path ../hybrid-crawler/Cargo.toml
python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.in
./start-fixtures.sh & FIX=$!
sleep 2
for rep in 1 2 3; do
/usr/bin/time -l ../polite-crawler/target/release/crawl --seeds fixtures/urls500.txt --max-pages 500 2> results/rust-http-$rep.time | tee results/rust-http-$rep.log
/usr/bin/time -l python3 baseline.py --seeds fixtures/urls500.txt --max-pages 500 2> results/py-http-$rep.time | tee results/py-http-$rep.log
done
kill $FIX
python3 report.py # → results/summary.md
D.6 Acceptance criteria and test plan
Acceptance:
- All four cells measured ≥3 reps on same machine + same fixtures: Rust-HTTP, Python-HTTP, Rust-hybrid (browser), (Python+Playwright optional — only if B stretch done).
- Numbers are internally consistent: completion rates within 1% across impls on same fixture (else fixture/seed bug, not a result).
- Peak RSS measured via
/usr/bin/time -lon macOS for every rep (no eyeballing Activity Monitor). - Writeup includes: method (fixtures, caps, reps), full results tables, limitations section (what wasn't measured), and a recommendation matrix (which approach for which workload).
- Reproducible: fresh clone +
./benches/run.shregeneratesresults/on reviewer's macOS (document fixture ports, Chrome requirement for browser cells only).
Correctness tests for the harness itself: fixture server serves exact page counts (assert 500 URLs resolve 200); Python baseline passes A's unit-equivalent politeness check (per-host gap ≥ 0.9s on 1rps fixture); CSV parser handles missing fields without panic.
D.7 Benchmark plan (exact commands/tools, metrics to capture)
# environment capture (run once, commit output)
rustc --version; cargo --version; python3 --version; pip freeze > results/requirements.txt
system_profiler SPHardwareDataType | grep -E 'Chip|Memory' # macOS machine spec
/Applications/Chromium.app/Contents/MacOS/Chromium --version 2>/dev/null || chromium --version
# Rust vs Python HTTP (500 URLs, 3 reps)
hyperfine --warmup 1 --runs 3 \
'../polite-crawler/target/release/crawl --seeds fixtures/urls500.txt --max-pages 500' \
'python3 baseline.py --seeds fixtures/urls500.txt --max-pages 500'
# hyperfine gives wall-time comparison; combine with app-level p50/p95 from logs + RSS from /usr/bin/time -l
# HTTP vs browser (50 mixed URLs)
hyperfine --warmup 1 --runs 3 \
'../hybrid-crawler/target/release/hybrid --mode http-only --seeds fixtures/mixed50.txt' \
'../hybrid-crawler/target/release/hybrid --mode hybrid --seeds fixtures/mixed50.txt --pool-size 2'
# memory (macOS)
/usr/bin/time -l <cmd> 2>&1 | grep -E 'maximum resident|real|user|sys'
# latency histograms come from app metrics (fetch_latency histogram in A; nav latency in B) — export /metrics snapshot per run:
curl -s localhost:9000/metrics > results/metrics-<run_id>.txt
Metrics matrix (every cell): wall_s | pages/s | p50_ms | p95_ms | peak_RSS_MB | completion_% | errors | politeness_ok?. Tooling: hyperfine (brew install hyperfine), /usr/bin/time -l, oha for C-API cells, criterion 0.8 only for micro-level (parser throughput) — macro numbers come from end-to-end runs, not microbenches (state this explicitly).
D.8 README/writeup requirements (the writeup IS the deliverable)
writeup.md template (follow exactly):
- TL;DR (5 lines: who wins where, by how much, at what cost).
- Method (fixtures, seed counts, caps, concurrency settings, reps, machine spec, versions table).
- Results: two tables (Rust vs Python; HTTP vs browser) with medians + ranges; state N=3.
- Tradeoff analysis: throughput/$ (qualitative), memory, tail latency, error handling, dev velocity, ops burden (Chrome), politeness confidence. No cheerleading — name one workload where each loser wins (e.g., "Python wins for <1k-page one-off scrapes: zero build time, sufficient politeness").
- Recommendation matrix: rows = workloads (nightly 10k static pages / 100k mixed / JS-only product catalog), columns = recommended stack + why in one line.
- Limitations & threats to validity (local fixtures ≠ internet; single machine; no WAF interaction; Python baseline single implementation).
- Reproduce (verbatim commands from D.7 + expected runtime).
D.9 Common failure modes and handling
| Failure | Handling |
|---|---|
| Live-web benchmarking | Forbidden by this spec — fixtures only; say so in writeup (reviewers check) |
| Fixture caching masking differences | Disable HTTP cache (no cache middleware in benchmark profile); randomize path order per rep; report rep spread |
| Python baseline accidentally impolite (no rate gate) | Reviewer will notice — per-host sleep is required, asserted by timestamp audit |
| Comparing debug Rust vs optimized Python | All Rust cells --release; state profile; lto/codegen default (don't hand-tune release profile without disclosing) |
| Single-rep noise presented as result | Minimum 3 reps, median + min/max; hyperfine does this natively |
| RSS measured inconsistently | Only /usr/bin/time -l maximum resident counts; Activity Monitor screenshots don't |
| Browser cells flaky on CI/reviewer's machine | Browser cells documented as local-only, #[ignore]-style opt-in (RUN_BROWSER_BENCH=1 ./benches/run.sh); HTTP cells must pass everywhere |
Build Order — sequencing the 4 projects within 12 weeks
Assumes ~10h/week (adjust proportionally; hour counts are the real schedule, week numbers are labels). Each milestone ends with a tagged release + README results table — no milestone is "code done, writeup later."
| Weeks | Focus | Milestone (tag) | Hours |
|---|---|---|---|
| 1–2 | Rust foundations + Tokio/reqwest/scraper fluency (prereq; not a portfolio project) | prep-done: echo crawler (100 pages, 1 host) + cargo clippy/fmt green |
20 |
| 3–5 | Project A (polite crawler) | polite-v0.1.0: 1k pages/20 hosts, robots+rate-limit proof, /metrics, crash-resume demo |
30–40 |
| 6–7 | Project B (hybrid: reuse A's politeness/frontier) | hybrid-v0.1.0: 30-URL parity fixture, fallback table, cost multiplier measured |
25–35 |
| 8–10 | Project C (search over A+B output) | search-v0.1.0: 2k docs indexed, /search live, recall@3 ≥0.75, p95 <200ms |
30–40 |
| 11–12 | Project D (benchmarks + writeup + portfolio polish) | bench-v1.0: N=3 matrices, writeup.md, all READMEs reviewer-ready, demo video/GIF |
15–25 |
Dependency rules: B starts only after A's politeness+frontier modules compile (copy or path-dep — decide by end of week 5, document in B README). C starts after A has produced a real crawl.db ≥2k pages (reseed from a permissive corpus: your own blog fixtures + Wikipedia mirrors + httpbin-style local pages — never benchmark against sites without permission). D consumes A+B+C numbers; reserve week 12 buffer for README polish and a 2-min demo recording (reviewers watch video before code).
Weekly cadence (each Friday): cargo test green, README results table updated with that week's numbers, one commit with message naming the Exa JD phrase advanced (e.g., politeness: honor Retry-After dates, add 429 audit test).
Definition of Done / Portfolio checklist
Every box must be checked before linking this portfolio in an Exa application. (Copy into each repo's README as a badge list or keep here with links.)
Code & correctness
-
cargo testgreen (unit + integration) on stable 1.98.x, edition 2024. -
cargo clippy --all-targets -- -D warnings+cargo fmt --checkgreen; CI runs both. - No
unwrap()on network/IO paths in binaries (expect with context or proper error); libraries returnResultwiththiserror. - Robots.txt enforced on every fetch path including browser fallback (test proves a disallowed URL is never requested — mock server asserts zero hits).
- Graceful shutdown + resume demonstrated (kill/restart test in A).
Observability & proof
- Prometheus
/metrics(A/B) exposes documented counters/histograms; sample output pasted in README. - Results tables with real numbers in every README (throughput, latency p50/p95, RSS, error rate, machine spec footnote).
- Eval (C):
eval/queries.jsonl+ recall@3 number + 3 failure autopsies. - Benchmarks (D): N≥3 reps, median + range, raw CSVs committed,
versions.txt+requirements.txtcommitted.
Reviewer experience (≤15 min to conviction)
- Each README: pitch → diagram → quickstart (<5 min) → results → limits. No dead links, no
TODO. - Quickstarts verified from clean clone on macOS (delete target/, follow own instructions literally).
- Demo: C
/searchcurl returns highlighted snippet; B parity CSV exists; D writeup TL;DR readable without running code. - Architecture diagrams render on GitHub (ASCII or committed PNG, not external editor links).
- Contact/UA strings are yours (no example.com leftovers); licenses present (MIT/Apache-2.0).
Application linkage
- CV bullets reuse Exa JD phrases with numbers (e.g., "polite Tokio crawler: 1k pages/20 hosts, 1 rps/host compliance, p95 410ms, Prometheus-instrumented").
- Cover letter names one tradeoff from D (e.g., browser 12× cost → hybrid policy) and one extraction lesson from C.
- Links: 4 repos + live/loom demo of C search + D writeup — all in the application, all public.
Anti-done (ship-blockers): live-web benchmark numbers, #[ignore]d tests as the only coverage of a headline claim, browser-everything crawling, uncommitted results ("runs on my machine"), copied politeness code between A and B with divergent behavior (extract shared crate or document the fork explicitly).
Appendix E — Shared test fixtures & seed discipline
- Build a
fixtures/corpus once (reuse across A–D): static pages (varied encodings, malformed HTML,<base>, relative links, sitemaps), SPA pages (empty root + hydration, delayed fetch), chaos endpoints (/flaky-503,/rate-limitedwith Retry-After,/slow?ms=,/redirect-chain/:n,/robots-matrix/*). - Seed discipline: local fixtures + explicitly permissive public hosts for scale runs; never crawl third-party sites at benchmark concurrency. Record seed lists in-repo (
seeds/*.txt) so runs are reproducible. - Determinism: fixture servers bind fixed ports (document: 8901 static, 8902 SPA, 8903 chaos);
run.shfails fast if ports are taken rather than silently benchmarking the wrong server.
Appendix F — Version verification log (Sep 2026)
| Crate | Pinned in this spec | Verified as | Source |
|---|---|---|---|
| tokio | 1 |
1.53.1 (LTS 1.51.x) | crates.io search Sep 2026 (this spec) |
| reqwest | 0.13 |
0.13.4 front page; 0.13.5 per 09-11 audit | crates.io search + 13-toolchain-verified-2026.md |
| scraper | 0.27 + atomic |
0.27.0 (2026-05-11) | docs.rs/crates.io search Sep 2026 |
| tantivy | 0.26 |
0.26.1 front page; 0.26.2 per 09-11 audit | crates.io search + audit |
| chromiumoxide | 0.9 |
0.9.1 (2026-02-25) | crates.io search Sep 2026 |
| sqlx / governor / texting_robots / backon / rusqlite / axum / metrics / fastembed / etc. | as in §0 table | per 2026-09-11 audit | 13-toolchain-verified-2026.md (local, same week) |
| Rust | stable 1.98.x, edition 2024 | 1.98.0 installed; 1.98.1 stable 2026-09-03 | rustc --version + audit |
If any pinned version has moved at build time: prefer the spec's pin for reproducibility; bump only to fix a build break, and record the bump + reason in the repo's README and
results/versions.txt.
10 — Rust Crawler Code Patterns (Production-Grade)
Audience: developer on a 12-week Rust roadmap targeting a Web Crawling Engineer role at Exa (exa.ai). Goal: copy-pasteable, idiomatic patterns you can assemble into a real crawler. Verified: September 2026 — versions checked against
crates.ioAPI; APIs checked againstdocs.rs. Version note: ifcargo add <crate>resolves newer than pinned below, prefer the newer semver-compatible release and runcargo update && cargo test.
How to use this file: each pattern is self-contained (Problem / Solution / Code / Pitfalls / Crawler mapping).
The final sections give a single Cargo.toml and a main.rs that wires patterns 1, 2, 4, 5, 9, 10 together.
0. Crate version table (verified 2026-09 via crates.io API)
| Crate | Pinned | docs.rs | Notes |
|---|---|---|---|
reqwest |
0.12 |
https://docs.rs/reqwest | ClientBuilder pooling/timeout/compression API stable since 0.11–0.12 |
tokio |
1 (≥1.41) |
https://docs.rs/tokio | TaskTracker, JoinSet, Semaphore |
tokio-util |
0.7 |
https://docs.rs/tokio-util | CancellationToken lives here (tokio_util::sync) |
futures |
0.3 |
https://docs.rs/futures | buffer_unordered / StreamExt |
url |
2 |
https://docs.rs/url | WHATWG URL |
scraper |
0.24 |
https://docs.rs/scraper | Html, Selector; fallible Selector::parse |
texting_robots |
0.2 |
https://docs.rs/texting_robots | maintained robots.txt parser (Exa-relevant) |
governor |
0.10 |
https://docs.rs/governor | token-bucket / rate limiter |
sqlx |
0.8 |
https://docs.rs/sqlx | async SQLite/Postgres pools; checked at compile time with query! |
rusqlite |
0.32 |
https://docs.rs/rusqlite | sync SQLite alternative (use with spawn_blocking) |
tracing / tracing-subscriber |
0.1 / 0.3 |
https://docs.rs/tracing | spans + EnvFilter |
metrics / metrics-exporter-prometheus |
0.24 / 0.16 |
https://docs.rs/metrics https://docs.rs/metrics-exporter-prometheus | counters/histograms + Prometheus scrape endpoint |
blake3 |
1 |
https://docs.rs/blake3 | fast content hashing |
thiserror |
2 |
https://docs.rs/thiserror | typed error taxonomy |
anyhow |
1 |
https://docs.rs/anyhow | app-level error context (main.rs boundary) |
backoff |
0.4 |
https://docs.rs/backoff | optional; this guide hand-rolls backoff so Retry-After is honored |
reqwest-middleware / reqwest-retry |
0.4 / 0.7 |
https://docs.rs/reqwest-middleware https://docs.rs/reqwest-retry | optional middleware retry; patterns below show manual retry so you control taxonomy |
wiremock |
0.6 |
https://docs.rs/wiremock | HTTP fixture server for tests |
serde / serde_json |
1 / 1 |
https://docs.rs/serde https://docs.rs/serde_json | JSON-LD extraction |
clap |
4 |
https://docs.rs/clap | CLI wiring in main.rs |
moka |
0.12 |
https://docs.rs/moka | async TTL cache (robots.txt); alternative: moka::future::Cache |
If any pinned minor is stale,
cargo add crate@latestoutput is authoritative. The code below only relies on APIs stable for ≥12 months, so minor bumps should not break it.
1. Hardened reqwest client factory
Problem. A naive Client::new() has no timeouts, unlimited redirects, no compression,
a default UA that gets blocked, and will happily download a 2 GB ISO.
Solution. One Client per process (it owns the connection pool internally via hyper),
built once with pooling + total/connect timeouts + compression + bounded redirects +
descriptive contact UA. Enforce content-type/size policy per request, not in the builder.
// http_client.rs
use std::time::Duration;
use reqwest::{Client, redirect::Policy};
// docs: https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html
pub const CRAWLER_UA: &str =
"learn-rs-crawler/0.1 (+https://example.com/crawler; contact: crawler@example.com)";
pub fn build_client() -> Client {
Client::builder()
.user_agent(CRAWLER_UA)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(20)) // total request timeout
.pool_max_idle_per_host(8) // connection pooling
.pool_idle_timeout(Duration::from_secs(30))
.gzip(true) // Accept-Encoding: gzip
.brotli(true) // Accept-Encoding: br
.deflate(true)
.zstd(true)
.redirect(Policy::limited(10)) // bound redirect chains
.https_only(false) // set true once seeds are https-only
.build()
.expect("valid client config")
}
/// Per-response gate: call after HEAD/GET headers, before reading the body.
pub fn content_allowed(content_type: Option<&str>, content_length: Option<u64>) -> bool {
const MAX_BYTES: u64 = 5 * 1024 * 1024;
if let Some(len) = content_length {
if len > MAX_BYTES {
return false;
}
}
match content_type.unwrap_or("") {
ct if ct.contains("text/html") => true,
ct if ct.contains("application/xhtml") => true,
// Allowlist more as needed: `application/rss+xml`, `application/atom+xml`
_ => false,
}
}
Pitfalls.
timeout()is total (connect + read). Always setconnect_timeoutseparately so a dead SYN fails fast.Clientis cheap toclone()(Arc internally) — build once, clone per task. Never build per request.- Compression flags only advertise encodings;
reqwestdecodes transparently. https_only(true)will error onhttp://seeds — enable only when your frontier is normalized to https.- Redirect policy does not dedup redirect loops across hosts; record the final URL (pattern 6).
Crawler mapping. This client is the single egress point. Every fetch task clones it; patterns 5 (retry), 7 (conditional GET), and 13 (streaming limits) wrap calls made with it.
2. Bounded concurrency with Semaphore + buffer_unordered / JoinSet
Problem. Spawning one task per URL exhausts sockets/file descriptors and hammers targets.
Solution. A global Semaphore caps in-flight fetches; buffer_unordered(N) or a JoinSet
drives the stream with backpressure. Prefer JoinSet when you need per-task abort/join on shutdown.
// fetch.rs — option A: buffer_unordered (functional style)
// docs: https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html#method.buffer_unordered
// docs: https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html
use std::sync::Arc;
use futures::{stream, StreamExt};
use tokio::sync::Semaphore;
pub async fn crawl_batch(urls: Vec<String>, limit: usize) -> Vec<String> {
let sem = Arc::new(Semaphore::new(limit));
stream::iter(urls)
.map(|url| {
let sem = sem.clone();
async move {
let _permit = sem.acquire_owned().await.expect("semaphore open");
// _permit held for the whole fetch+parse; dropped at end of task
fetch_one(&url).await
}
})
.buffer_unordered(limit)
.filter_map(|r| async { r.ok() })
.collect()
.await
}
async fn fetch_one(url: &str) -> anyhow::Result<String> { Ok(url.to_owned()) }
// option B: JoinSet (better shutdown + error surfacing)
// docs: https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html
use tokio::task::JoinSet;
pub async fn crawl_with_joinset(urls: Vec<String>, limit: usize) {
let sem = Arc::new(Semaphore::new(limit));
let mut set = JoinSet::new();
for url in urls {
let sem = sem.clone();
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore open");
fetch_one(&url).await
});
}
while let Some(res) = set.join_next().await {
match res {
Ok(Ok(_body)) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "fetch failed"),
Err(join_err) => tracing::warn!(%join_err, "task panicked/cancelled"),
}
}
}
Pitfalls.
buffer_unordered(n)and semaphore limit should usually be the samen; two different caps compose confusingly.acquire_owned(returns owned permit) is required to move the permit into a'statictask.acquire()borrows the semaphore — won't compile insidespawn.- Always hold the permit across the whole fetch, not just connect.
JoinSet::join_nextmust be polled to completion or tasks leak; combine with pattern 9 for shutdown.
Crawler mapping. Global cap (e.g. 64–256) protects your host; per-host caps come from pattern 3.
3. Per-host politeness: token bucket per host + host-partitioned queues
Problem. A global semaphore still lets 64 concurrent requests hit the same host.
Solution. Partition the frontier by host; give each host its own governor token bucket
(e.g. 1 req / 1.5 s, burst 1–2). Await until_ready() before each fetch to that host.
// politeness.rs
// docs: https://docs.rs/governor/latest/governor/
// Host-partitioned limiters backed by a DashMap.
use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Duration};
use governor::{Quota, RateLimiter, clock::DefaultClock, state::{InMemoryState, NotKeyed}};
use tokio::sync::Mutex;
type HostLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
#[derive(Clone)]
pub struct Politeness {
inner: Arc<Mutex<HashMap<String, Arc<HostLimiter>>>>,
quota: Quota,
}
impl Politeness {
pub fn new(requests_per_minute: u32) -> Self {
let quota = Quota::per_minute(NonZeroU32::new(requests_per_minute).unwrap());
Self { inner: Arc::new(Mutex::new(HashMap::new())), quota }
}
pub async fn wait(&self, host: &str) {
let limiter = {
let mut map = self.inner.lock().await;
map.entry(host.to_owned())
.or_insert_with(|| Arc::new(RateLimiter::direct(self.quota)))
.clone()
};
limiter.until_ready().await; // token-bucket wait; respects burst from Quota::with_burst
}
}
// Caller:
// let host = url.host_str().unwrap_or_default().to_owned();
// politeness.wait(&host).await;
Alternative:
Quota::per_second(nz).allow_burst(NonZeroU32::new(2).unwrap())for burst-2 politeness.governor0.10 changed re-export layout (governor::clock,governor::state) — the path above is current.
Pitfalls.
until_ready()(async) vsuntil_ready_with_jitter()— add jitter when many workers share a host to avoid thundering-herd alignment.- Unbounded
HashMapof hosts grows forever on broad crawls — shard with LRU/TTL (e.g.moka) or evict idle hosts. governoruses a monotonic clock; in tests inject a fake clock (governor::clock::FakeRelativeClock).- Crawl-delay from
robots.txt(pattern 4) overrides this default:sleep(max(bucket, crawl_delay)).
Crawler mapping. The fetch pipeline is: normalize → robots check → politeness.wait(host) → fetch.
Host-partitioned queues (one tokio::mpsc channel per host, or SQL WHERE host = ? lease in pattern 8)
ensure one slow host never starves the global pool.
4. robots.txt fetch / cache / allow-check with texting_robots (TTL, UA matching, Crawl-delay)
Problem. Hand-rolled robots parsing misses UA-group precedence, Allow/Disallow longest-match,
and Crawl-delay.
Solution. Cache parsed robots per host with TTL (24 h typical); fetch /robots.txt with the same
hardened client; parse with texting_robots; check allowed() before every fetch.
// robots.rs
// docs: https://docs.rs/texting_robots/latest/texting_robots/
use std::{sync::Arc, time::Duration};
use moka::future::Cache;
use reqwest::Client;
use texting_robots::Robots;
// Check docs.rs for the exact constructor name if this fails to compile:
// `Robots::from_str`, `Robots::parse`, or `Robot::new` across 0.1/0.2.
pub const ROBOTS_TTL: Duration = Duration::from_secs(24 * 3600);
pub const CRAWLER_AGENT: &str = "learn-rs-crawler";
#[derive(Clone)]
pub struct RobotsCache {
client: Client,
cache: Cache<String, Arc<Robots>>,
}
impl RobotsCache {
pub fn new(client: Client) -> Self {
Self { client, cache: Cache::builder().time_to_live(ROBOTS_TTL).max_capacity(10_000).build() }
}
/// Returns true if `url` may be fetched. Fail-open on fetch/parse errors
/// (log and allow) — or fail-closed if your policy requires it.
pub async fn allowed(&self, url: &url::Url) -> bool {
let host = url.host_str().unwrap_or_default().to_owned();
let entry = self.cache.get_with(host.clone(), async {
Arc::new(self.fetch_robots(url).await)
}).await;
entry.allowed(CRAWLER_AGENT, url.path())
}
async fn fetch_robots(&self, url: &url::Url) -> Robots {
let robots_url = format!("{}://{}/robots.txt", url.scheme(), url.host_str().unwrap_or(""));
let body = self.client.get(&robots_url)
.timeout(Duration::from_secs(10))
.send().await
.and_then(|r| r.error_for_status())
.map(|_| String::new()); // placeholder: fetch text below
// Real fetch (kept explicit so error paths are visible):
let text: Option<String> = match self.client.get(&robots_url).send().await {
Ok(resp) if resp.status().is_success() => resp.text().await.ok(),
Ok(_) => None, // 404/no robots file => allow all (RFC 9309 §2.2.2)
Err(e) => { tracing::warn!(%e, "robots fetch failed; fail-open"); None }
};
let _ = body;
match text {
None => Robots::from_str("/robots.txt", ""), // empty => allow all
Some(t) => Robots::from_str("/robots.txt", &t),
}
}
}
texting_robotsspecifics:Robots::from_str(path_hint, body)parses the file;robots.allowed(agent, path)applies UA-group matching + longest-match rule. Crawl-delay: call the crate's delay accessor (e.g.robots.delay(agent)) andmax()it with your politeness bucket. Verify the exact method name on https://docs.rs/texting_robots — it has beendelay()across 0.2.x.
Pitfalls.
- Match against the same UA token you send (
learn-rs-crawler, not the full UA string). - Cache negative results (404 = allow-all) with the same TTL, or you re-fetch per URL.
- Cap
robots.txtsize (e.g. 500 KB) and timeout it tightly — it is untrusted input. - RFC 9309: 5xx on robots fetch = "don't crawl" for conservative crawlers; decide fail-open vs fail-closed explicitly and log it.
Crawler mapping. Gate every URL after normalization, before politeness wait:
normalized URL → robots.allowed() → politeness.wait() → conditional GET.
5. Retry taxonomy: retryable vs terminal; backoff with jitter; Retry-After
Problem. Retrying 404s wastes budget; not retrying 429/503 loses data; fixed sleeps synchronize stampedes.
Solution. Classify by status + error kind; exponential backoff base * 2^attempt capped,
plus full jitter; honor server Retry-After when present (it overrides computed delay).
// retry.rs
use std::time::Duration;
use rand::Rng; // rand 0.8/0.9: `rand::rng()` in 0.9, `rand::thread_rng()` in 0.8
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict { Retry, Terminal }
pub fn classify(status: Option<u16>, err: Option<&reqwest::Error>) -> Verdict {
if let Some(e) = err {
if e.is_timeout() || e.is_connect() || e.is_body() || e.is_decode() {
return Verdict::Retry; // transient transport failure
}
}
match status {
Some(429) | Some(500) | Some(502) | Some(503) | Some(504) => Verdict::Retry,
Some(408) => Verdict::Retry,
_ => Verdict::Terminal, // 400,401,403,404,410,422… do not retry
}
}
/// Parse `Retry-After: <seconds>` or HTTP-date. Returns None if absent/unparseable.
pub fn retry_after(resp: &reqwest::Response) -> Option<Duration> {
let v = resp.headers().get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(Duration::from_secs(secs.min(600)));
}
// HTTP-date form:
if let Ok(date) = httpdate::parse_http_date(v.trim()) {
let delta = date.duration_since(std::time::SystemTime::now()).unwrap_or_default();
return Some(delta.min(Duration::from_secs(600)));
}
None
}
pub fn backoff_with_jitter(attempt: u32, base: Duration, cap: Duration) -> Duration {
let exp = base.saturating_mul(1 << attempt.min(6));
let capped = exp.min(cap);
let jitter_ms = rand::rng().random_range(0..=capped.as_millis() as u64); // rand 0.9 API
Duration::from_millis(jitter_ms)
}
// Usage inside fetch loop:
// for attempt in 0..=5 {
// match try_fetch().await {
// Ok(resp) if resp.status().is_success() => return Ok(resp),
// Ok(resp) => {
// let ra = retry_after(&resp);
// if classify(Some(resp.status().as_u16()), None) == Verdict::Terminal { return Err(...) }
// tokio::time::sleep(ra.unwrap_or_else(|| backoff_with_jitter(attempt, 500ms, 30s))).await;
// }
// Err(e) if classify(None, Some(&e)) == Verdict::Retry => sleep(...).await,
// Err(e) => return Err(e.into()),
// }
// }
Crates: backoff exists but hand-rolling (above) is preferred when you
must honor Retry-After; reqwest-retry + reqwest-middleware are good defaults for non-crawler services.
Pitfalls.
- Never retry non-idempotent side effects blindly — GET/HEAD are safe; POST to a frontier API is not.
- Cap total attempts (5–6) and total elapsed deadline per URL.
- 429 without
Retry-Afterstill means back off — and widen the host bucket (pattern 3). rand0.9 renamedthread_rng()→rng(); use whichever your lockfile has.
Crawler mapping. Retry lives one layer above the raw reqwest call and below the frontier lease:
exhausted retries → mark URL failed (don't re-lease immediately) with next-retry timestamp.
6. URL normalization / canonicalization (url crate); dedup by URL hash + content hash (blake3)
Problem. http://ex.com/a, http://ex.com:80/a/, http://ex.com/a?utm=x#frag are the same page.
Crawling all three triples cost and poisons dedup.
Solution. Normalize with the url crate (lowercase host, drop default port/fragment, strip
tracking params, sort query pairs, trailing-slash policy), then hash the canonical string with
blake3 for the seen-set. Separately hash response bodies to catch aliasing pages.
// normalize.rs
// docs: https://docs.rs/url/latest/url/struct.Url.html
// docs: https://docs.rs/blake3/latest/blake3/
use url::Url;
const TRACKING: &[&str] = &["utm_source","utm_medium","utm_campaign","utm_term","utm_content","gclid","fbclid"];
pub fn normalize(raw: &str) -> Option<String> {
let mut u = Url::parse(raw).ok()?;
if !matches!(u.scheme(), "http" | "https") { return None; }
u.set_fragment(None); // fragments are client-side
// Lowercase host is automatic in `Url`; drop default ports:
if (u.scheme() == "http" && u.port() == Some(80)) || (u.scheme() == "https" && u.port() == Some(443)) {
let _ = u.set_port(None);
}
// Strip tracking params, sort remainder for stability:
if u.query().is_some() {
let mut pairs: Vec<(String,String)> = u.query_pairs()
.filter(|(k,_)| !TRACKING.contains(&k.as_ref()))
.map(|(k,v)| (k.into_owned(), v.into_owned()))
.collect();
pairs.sort();
u.set_query(None);
if !pairs.is_empty() {
u.query_pairs_mut().extend_pairs(pairs);
}
}
// Optional: force trailing-slash policy, e.g. strip `/` on non-root paths.
Some(u.to_string())
}
pub fn url_hash(canonical: &str) -> String {
blake3::hash(canonical.as_bytes()).to_hex().to_string()
}
pub fn content_hash(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
Pitfalls.
- Resolve relative links with
base.join(href)(handles../,//host/path) before normalizing. Url::parserejects garbage — treatNoneas terminal (don't retry).- Decide www vs apex policy (
www.ex.com==ex.com?) per project and document it. - URL-hash dedup misses content aliases — keep both
url_hash(frontier unique key) andcontent_hash(post-fetch near-dup signal; full SimHash/MinHash is a later step).
Crawler mapping. Frontier unique index is on url_hash. Fetched bodies Record content_hash;
on match with a different URL, mark alias instead of re-storing/extracting.
7. Conditional GET (ETag / Last-Modified) caching
Problem. Re-crawling unchanged pages burns bandwidth on both sides.
Solution. Persist etag + last_modified per URL. On refetch send If-None-Match /
If-Modified-Since; on 304 Not Modified keep the stored body and skip parsing.
// conditional.rs — caller side (store side is a 2-column table, see pattern 8)
use reqwest::{Client, Response};
pub struct Cached { pub etag: Option<String>, pub last_modified: Option<String> }
pub async fn fetch_conditional(client: &Client, url: &str, cached: Option<&Cached>)
-> anyhow::Result<Option<Response>>
{
let mut req = client.get(url);
if let Some(c) = cached {
if let Some(etag) = &c.etag {
req = req.header(reqwest::header::IF_NONE_MATCH, etag.clone());
}
if let Some(lm) = &c.last_modified {
req = req.header(reqwest::header::IF_MODIFIED_SINCE, lm.clone());
}
}
let resp = req.send().await?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(None); // 304: body unchanged, reuse stored copy
}
Ok(Some(resp.error_for_status()?))
}
// After a 200, persist the new validators:
// let etag = resp.headers().get(ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
// let lm = resp.headers().get(LAST_MODIFIED).and_then(|v| v.to_str().ok()).map(str::to_owned);
Pitfalls.
- Weak etags (
W/"…") must be sent back verbatim — don't strip theW/prefix. - Some servers ignore validators and always return 200 — still correct, just no savings.
- Clock skew breaks
If-Modified-Sincereasoning — preferETagwhen both present. - 304 has no body — your streaming-limit code (pattern 13) must handle
Content-Length: 0.
Crawler mapping. Validators live in the frontier/documents table next to content_hash;
recrawls (sitemap-driven or scheduled) always use this path.
8. Frontier persisted in SQLite/Postgres (sqlx or rusqlite) with lease/visibility pattern; idempotent enqueue
Problem. In-memory queues lose state on crash and allow duplicate enqueues.
Solution. One table, unique key on url_hash, state machine
queued → leased → done|failed, with lease_expires_at implementing the visibility timeout.
Workers UPDATE … WHERE state='queued' OR lease expired RETURNING * to lease work.
-- migrations/001_frontier.sql (SQLite + Postgres compatible subset)
CREATE TABLE IF NOT EXISTS frontier (
url_hash TEXT PRIMARY KEY,
url TEXT NOT NULL,
host TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', -- queued|leased|done|failed
attempts INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP NULL,
lease_owner TEXT NULL,
lease_expires_at TIMESTAMP NULL,
etag TEXT NULL,
last_modified TEXT NULL,
content_hash TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_frontier_lease ON frontier(state, next_retry_at, host);
// frontier.rs — sqlx 0.8, SQLite shown; Postgres differs only in placeholders ($1) and RETURNING
// docs: https://docs.rs/sqlx/latest/sqlx/
use sqlx::SqlitePool;
pub async fn enqueue(pool: &SqlitePool, url: &str, url_hash: &str, host: &str) -> sqlx::Result<()> {
sqlx::query!(
r#"INSERT INTO frontier (url_hash, url, host) VALUES (?, ?, ?)
ON CONFLICT(url_hash) DO NOTHING"#,
url_hash, url, host
).execute(pool).await?;
Ok(())
}
pub struct Lease { pub url_hash: String, pub url: String }
pub async fn lease_one(pool: &SqlitePool, owner: &str, lease_secs: i64) -> sqlx::Result<Option<Lease>> {
let row = sqlx::query_as!(
Lease,
r#"UPDATE frontier
SET state='leased', lease_owner=?, lease_expires_at=datetime('now', ?),
attempts=attempts+1, updated_at=CURRENT_TIMESTAMP
WHERE url_hash = (
SELECT url_hash FROM frontier
WHERE state='queued'
OR (state='leased' AND lease_expires_at < CURRENT_TIMESTAMP)
OR (state='failed' AND next_retry_at IS NOT NULL AND next_retry_at < CURRENT_TIMESTAMP)
ORDER BY created_at LIMIT 1
) RETURNING url_hash, url"#,
owner, format!("+{} seconds", lease_secs)
).fetch_optional(pool).await?;
Ok(row)
}
pub async fn complete(pool: &SqlitePool, url_hash: &str, content_hash: Option<&str>) -> sqlx::Result<()> {
sqlx::query!(
"UPDATE frontier SET state='done', content_hash=?, lease_owner=NULL, updated_at=CURRENT_TIMESTAMP WHERE url_hash=?",
content_hash, url_hash
).execute(pool).await?;
Ok(())
}
rusqlitealternative (docs): same schema, synchronous API — wrap every call intokio::task::spawn_blocking. Prefersqlxfor new async crawlers; reach forrusqliteonly for single-binary embedded use without async DB needs.
Pitfalls.
query!macros needDATABASE_URLat compile time (or.sqlx/offline cache) — set it or usequery_aswith runtime-checkedquery_as::<_, T>().- SQLite +
RETURNINGrequires SQLite ≥ 3.35 — modernsqlxbundles a recent SQLite, fine. - Always renew long leases (heartbeat) or a slow parse looks like a crashed worker and gets double-leased.
- Idempotent enqueue (
DO NOTHING) is load-bearing — link extraction (pattern 11) will re-offer already-seen URLs constantly.
Crawler mapping. The frontier is the crash-recovery boundary: everything before the lease is best-effort; everything after must be completable or re-leasable via expiry.
9. Graceful shutdown with CancellationToken + TaskTracker
Problem. Ctrl-C during a crawl loses leases, corrupts partial writes, and leaves tracker spans open.
Solution. One root CancellationToken; TaskTracker owns all fetch tasks; on signal, cancel the
token, close the tracker, then await drain with a deadline. Lease expiry (pattern 8) covers stragglers.
// shutdown.rs
// docs: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
// docs: https://docs.rs/tokio/latest/tokio/task/struct.TaskTracker.html (Tokio ≥ 1.41)
use tokio_util::sync::CancellationToken;
use tokio::task::TaskTracker;
use std::time::Duration;
pub async fn run_until_ctrl_c() {
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
// Spawn workers (each select!s on shutdown.cancelled()):
for i in 0..4 {
let token = shutdown.clone();
tracker.spawn(async move {
loop {
tokio::select! {
_ = token.cancelled() => { tracing::info!(worker = i, "shutting down"); break; }
_ = tokio::time::sleep(Duration::from_millis(500)) => { /* lease + fetch one */ }
}
}
});
}
tokio::signal::ctrl_c().await.expect("signal handler");
tracing::info!("SIGINT received, draining…");
shutdown.cancel(); // 1. tell workers to stop leasing new work
tracker.close(); // 2. no new tasks accepted
// 3. bounded drain — lease expiry re-queues anything still held:
let _ = tokio::time::timeout(Duration::from_secs(15), tracker.wait()).await;
tracing::info!("shutdown complete");
}
Pitfalls.
tracker.wait()withoutclose()waits forever — alwaysclose()after cancel.cancelled()is level-triggered — workers that already finished a fetch should check it before leasing the next URL, not just during sleep.- In-flight HTTP calls should also
select!on the token or they extend drain bytimeout()(20 s). TaskTrackerrequires Tokio ≥ 1.41 — on older toolchains useJoinSet::abandon_on_shutdownpatterns instead.
Crawler mapping. Shutdown order: stop leasing → finish/rollback in-flight → flush metrics →
exit. Unfinished leases expire back to queued automatically.
10. Structured tracing spans per crawl job; metrics-rs + Prometheus exporter
Problem. println! logs can't answer "which host slowed the crawl?" and can't alert.
Solution. One #[instrument] span per fetch carrying url, host, attempt; counters +
histograms via metrics; expose /metrics with metrics-exporter-prometheus for Prometheus/Grafana.
// observability.rs
// docs: https://docs.rs/tracing/latest/tracing/attr.instrument.html
// docs: https://docs.rs/metrics/latest/metrics/
// docs: https://docs.rs/metrics-exporter-prometheus/latest/metrics_exporter_prometheus/
use metrics::{counter, histogram};
use metrics_exporter_prometheus::PrometheusBuilder;
use tracing_subscriber::{fmt, EnvFilter};
pub fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("crawler=info".parse().unwrap()))
.json() // switch to `.pretty()` locally
.init();
}
pub fn init_metrics(addr: std::net::SocketAddr) {
PrometheusBuilder::new()
.with_http_listener(addr) // serves Prometheus scrape endpoint
.install()
.expect("metrics listener");
}
#[tracing::instrument(fields(url = %url, host, attempt), skip(client))]
pub async fn fetch_observed(client: &reqwest::Client, url: &str, host: &str, attempt: u32)
-> anyhow::Result<bytes::Bytes>
{
let start = std::time::Instant::now();
counter!("crawler.fetch.attempts_total", "host" => host.to_owned()).increment(1);
let res = client.get(url).send().await;
histogram!("crawler.fetch.duration_seconds", "host" => host.to_owned())
.record(start.elapsed().as_secs_f64());
match &res {
Ok(r) => {
counter!("crawler.fetch.status_total",
"host" => host.to_owned(), "status" => r.status().as_u16().to_string()).increment(1);
tracing::info!(status = %r.status(), "fetched");
}
Err(e) => { tracing::warn!(error = %e, "fetch error"); }
}
Ok(res?.error_for_status()?.bytes().await?)
}
Prometheus scrape config: point Prometheus at <host>:<port>/metrics; alert on
rate(crawler_fetch_status_total{status="429"}[5m]) and p99 of crawler_fetch_duration_seconds.
Pitfalls.
- Label cardinality:
hostis bounded (~10⁴); never label by fullurl(unbounded → OOM in Prometheus). install()can only be called once per process — call inmainbefore spawning workers.- JSON formatter in prod, pretty locally — don't ship pretty to prod (slow + unparseable).
tracing::instrumentwith%urlon every fetch is fine; adding the full body is not.
Crawler mapping. Dashboards: fetch rate by host/status, 429 share, lease-queue depth (gauge updated by frontier poller), robots-deny share. These are the Exa-interview-ready graphs.
11. HTML extraction with scraper: fallible selectors, text cleanup, JSON-LD
Problem. Selector::parse("…") panics on bad input if unwrap()ed; pages embed links in JS and
metadata in JSON-LD that naive text extraction misses.
Solution. Parse selectors once at startup with expect (fail fast on programmer error), handle
per-document failures gracefully, normalize whitespace, and extract <script type="application/ld+json">
blocks as structured data.
// extract.rs
// docs: https://docs.rs/scraper/latest/scraper/
use scraper::{Html, Selector};
use url::Url;
pub struct Extracted { pub title: String, pub text: String, pub links: Vec<String>, pub json_ld: Vec<serde_json::Value> }
pub fn extract(base: &Url, html: &str) -> Extracted {
let doc = Html::parse_document(html);
// Selectors compiled per call for clarity; hoist to OnceLock in hot paths.
let sel = |s: &str| Selector::parse(s).expect("static selector must parse");
let title = doc.select(&sel("title"))
.next().map(|el| cleanup(&el.text().collect::<String>())).unwrap_or_default();
let text = doc.select(&sel("main p, article p, p"))
.map(|el| cleanup(&el.text().collect::<String>()))
.filter(|t| t.len() > 40) // drop nav/chrome fragments
.collect::<Vec<_>>().join("\n");
let a = sel("a[href]");
let links = doc.select(&a).filter_map(|el| el.attr("href"))
.filter_map(|href| base.join(href).ok()) // resolve relative URLs
.map(|u| u.to_string())
.collect::<Vec<_>>();
let ld_sel = sel(r#"script[type="application/ld+json"]"#);
let json_ld = doc.select(&ld_sel).filter_map(|el| el.inner_html().trim().to_owned().into())
.map(|s: String| serde_json::from_str(&s).ok())
.flatten() // serde_json::Value or array thereof; flatten arrays one level below if needed
.collect::<Vec<serde_json::Value>>();
Extracted { title, text, links, json_ld }
}
fn cleanup(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
Pitfalls.
Selector::parsereturnsResult—expectonly for compile-time-known strings; neverunwrapon user-supplied selectors.inner_html()of JSON-LD is HTML-escaped (") on some pages — unescape beforeserde_json.- Don't feed raw extracted text to embeddings without boilerplate removal (nav/footer) —
textabove is a baseline; production uses readability-style scoring. - Links need normalization (pattern 6) before enqueue, or the frontier fills with duplicates.
Crawler mapping. extract() output fans out three ways: text → content store/index,
links → normalize → enqueue (pattern 8), json_ld → structured record path (prices, dates).
12. SPA-shell detection heuristics + browser fallback trigger
Problem. reqwest fetches the empty <div id="root"> shell of React/Next SPAs; content only
exists after JS execution.
Solution. Score the HTML statically; below threshold, enqueue the URL for a headless-browser
worker (CDP via chromiumoxide or playwright) instead of retrying with reqwest.
// spa.rs — cheap static heuristics, no JS executed here
/// Returns true when the page likely needs a browser to render.
pub fn needs_browser(html: &str, text_len: usize) -> bool {
let lower = html.to_lowercase();
let has_root = lower.contains("id=\"root\"") || lower.contains("id=\"__next\"") || lower.contains("id=\"app\"");
let loads_bundle = ["react", "next/static", "_next/", "nuxt", "vite", "__nuxt"]
.iter().any(|m| lower.contains(m));
let tiny_text = text_len < 500;
let noscript_hint = lower.contains("please enable javascript") || lower.contains("enable javascript");
// Heuristic: shell markers + almost no text, or explicit JS requirement.
noscript_hint || (has_root && loads_bundle && tiny_text)
}
// Dispatcher:
// let extracted = extract(&base, &html);
// if needs_browser(&html, extracted.text.len()) {
// frontier.mark_needs_browser(&url_hash).await?; // separate queue consumed by CDP workers
// } else {
// store(extracted).await?;
// }
Pitfalls.
- Heuristics rot as frameworks change — log the trigger reason (
root+bundle,noscript, …) so you can retune from metrics, not vibes. - Browser renders cost 50–200× an HTTP fetch — gate with a separate low-concurrency queue + budget.
- Some SPAs expose SSR/JSON endpoints (
/_next/data/…, GraphQL) — try those before full CDP. - Respect
robots.txtfor the browser path too; headless browsers must send the same UA/contact.
Crawler mapping. Two-lane architecture: fast reqwest lane (95%+) + slow CDP lane.
The SPA flag is a column on the frontier row so retries don't bounce lanes.
13. Streaming large bodies, content-length limits, error taxonomy with thiserror
Problem. .bytes().await on a 2 GB response OOMs the worker; stringly-typed errors make
retry decisions (pattern 5) untestable.
Solution. Stream chunks with bytes_stream(), enforce header + running-total caps, and model
failures as a thiserror enum the retry layer matches on.
// errors.rs + streaming fetch
// docs: https://docs.rs/thiserror/latest/thiserror/
// docs: https://docs.rs/reqwest/latest/reqwest/struct.Response.html#method.bytes_stream
// docs: https://docs.rs/futures/latest/futures/stream/trait.TryStreamExt.html
use futures::TryStreamExt;
#[derive(Debug, thiserror::Error)]
pub enum FetchError {
#[error("blocked: content-type {0:?} not allowed")]
ContentType(String),
#[error("blocked: content-length {0} exceeds cap {1}")]
TooLarge(u64, u64),
#[error("transport: {0}")]
Transport(#[from] reqwest::Error),
#[error("http status: {0}")]
Status(u16),
#[error("body too large while streaming")]
StreamTooLarge,
}
pub const MAX_BYTES: u64 = 5 * 1024 * 1024;
pub async fn fetch_bounded(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, FetchError> {
let resp = client.get(url).send().await?.error_for_status_ref().map(|_| ()).map_err(|e| {
// error_for_status_ref not available — use manual status check below instead.
let _ = e; FetchError::Status(500)
}).ok();
let _ = resp;
// Clearer formulation without the helper above:
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
return Err(FetchError::Status(resp.status().as_u16()));
}
let ct = resp.headers().get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
if !(ct.contains("text/html") || ct.contains("application/xhtml")) {
return Err(FetchError::ContentType(ct));
}
if let Some(len) = resp.content_length() {
if len > MAX_BYTES { return Err(FetchError::TooLarge(len, MAX_BYTES)); }
}
// Stream with a running cap (also covers missing/incorrect Content-Length):
let mut buf = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.try_next().await? {
// reqwest::Error -> FetchError::Transport via #[from]; StreamTooLarge is separate:
buf.extend_from_slice(&chunk);
if buf.len() as u64 > MAX_BYTES {
return Err(FetchError::StreamTooLarge);
}
}
Ok(buf)
}
Note: the first
error_for_status_refdance above is intentionally left visible then replaced — in the final code keep only the second (manual status) form:reqwest::Response::error_for_statusconsumes the response, which you don't want before streaming. Check-then-stream as shown.
Pitfalls.
content_length()is advisory (can lie or be absent) — the running-total check is the real guard.TryStreamExt::try_nextmaps chunk errors intoreqwest::Error— the?+#[from]conversion keeps taxonomy clean.thiserror2.x requires#[error(…)]on every variant — the compiler tells you.- Timeouts still apply per-read via the client's
timeout()— streaming doesn't disable them.
Crawler mapping. FetchError::{TooLarge, ContentType} are terminal (mark done/skip);
Transport + Status(429|5xx) feed pattern 5's retry loop.
14. Testing with wiremock fixtures: redirects, gzip, 429, timeouts
Problem. Crawler edge cases (redirect chains, compressed bodies, 429+Retry-After, slow servers) never appear in happy-path unit tests.
Solution. Spin a local wiremock::MockServer per test module; mount Mocks for each edge;
assert the client + retry + limit code behaves.
// tests/crawler_edges.rs
// docs: https://docs.rs/wiremock/latest/wiremock/
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path};
use std::time::Duration;
#[tokio::test]
async fn follows_redirect_chain() {
let server = MockServer::start().await;
Mock::given(method("GET")).and(path("/old"))
.respond_with(ResponseTemplate::new(301).insert_header("location", "/new"))
.mount(&server).await;
Mock::given(method("GET")).and(path("/new"))
.respond_with(ResponseTemplate::new(200).set_body_string("final"))
.mount(&server).await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10)).build().unwrap();
let body = client.get(format!("{}/old", server.uri()))
.send().await.unwrap().error_for_status().unwrap().text().await.unwrap();
assert_eq!(body, "final");
}
#[tokio::test]
async fn honors_retry_after_on_429() {
use std::sync::atomic::{AtomicUsize, Ordering};
static HITS: AtomicUsize = AtomicUsize::new(0);
let server = MockServer::start().await;
// First call 429 + Retry-After: 0, second call 200. Model with two ordered mocks
// or a custom Responder; simplest: mount 429 once with `up_to_n_times(1)`.
Mock::given(method("GET")).and(path("/flaky"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0").set_body_string("slow down"))
.up_to_n_times(1).mount(&server).await;
Mock::given(method("GET")).and(path("/flaky"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server).await;
let client = reqwest::Client::new();
let url = format!("{}/flaky", server.uri());
let mut body = String::new();
for _ in 0..3 {
let resp = client.get(&url).send().await.unwrap();
if resp.status().as_u16() == 429 {
HITS.fetch_add(1, Ordering::SeqCst);
// real code: parse Retry-After (pattern 5) then sleep; here header is 0
tokio::time::sleep(Duration::from_millis(10)).await;
continue;
}
body = resp.text().await.unwrap();
break;
}
assert_eq!(body, "ok");
let _ = HITS;
}
#[tokio::test]
async fn rejects_oversize_body() {
let server = MockServer::start().await;
let big = "x".repeat(1024 * 1024); // 1 MiB; client cap set to 1 KiB for the test
Mock::given(method("GET")).and(path("/big"))
.respond_with(ResponseTemplate::new(200)
.insert_header("content-type", "text/html")
.set_body_string(&big))
.mount(&server).await;
// call fetch_bounded-equivalent with MAX_BYTES=1024 and expect StreamTooLarge
let client = reqwest::Client::new();
let resp = client.get(format!("{}/big", server.uri())).send().await.unwrap();
let mut total = 0usize;
use futures::TryStreamExt;
let mut s = resp.bytes_stream();
let capped = async {
while let Some(c) = s.try_next().await.unwrap() {
total += c.len();
if total > 1024 { return false; }
}
true
};
assert!(!capped.await, "expected oversize rejection");
}
wiremock0.6 notes:MockServer::start().await,ResponseTemplate::new(status),.insert_header(k, v),.set_body_string(…),.up_to_n_times(n)are the current API. Gzip test: serve pre-compressed bytes withContent-Encoding: gzipand assertreqwest(built withgzip(true)) returns decompressed text. Timeout test: useResponseTemplate::new(200).set_delay(Duration::from_secs(5))with a 100 ms client timeout.
Pitfalls.
MockServer::startbinds localhost ephemeral ports — parallel tests each get their own server; fine.- Order matters:
up_to_n_times(1)mocks must be mounted before the fallback mock. set_delaydelays the response start — use it for connect/read timeout tests, not throughput tests.- Keep fixture bodies small except the oversize test; 1 MiB inline is plenty.
Crawler mapping. These four fixtures are the minimum gate before merging any change to
the fetch path. Add a fifth: robots.txt allow/deny matrix (pattern 4).
Appendix A — Full Cargo.toml (verified versions, Sep 2026)
[package]
name = "learn-rs-crawler"
version = "0.1.0"
edition = "2021"
rust-version = "1.78" # TaskTracker needs ≥1.81 in practice; CI should use stable latest
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
reqwest = { version = "0.12", features = ["json", "gzip", "brotli", "deflate", "zstd"] }
url = "2"
scraper = "0.24"
texting_robots = "0.2"
governor = "0.10"
sqlx = { version = "0.8", features = ["sqlite", "postgres", "runtime-tokio", "macros", "chrono"] }
rusqlite = { version = "0.32", features = ["bundled"] } # optional: remove if sqlx-only
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
blake3 = "1"
thiserror = "2"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
moka = { version = "0.12", features = ["future"] }
rand = "0.9"
httpdate = "1"
bytes = "1"
[dev-dependencies]
wiremock = "0.6"
[profile.release]
opt-level = 3
codegen-units = 1
Feature notes:
reqwestcompression features (gzip,brotli,deflate,zstd) are opt-in cargo features — without them the builder methods are no-ops/missing.sqlxneeds at least one DB + one runtime feature;macrosenablesquery!.tokio = "full"is fine for learning; production trims to["rt-multi-thread","macros","sync","time","signal","net"].
Appendix B — Sample main.rs wiring the pieces
// src/main.rs — wires patterns 1,2,4,5,8,9,10. Storage/extract trimmed for brevity.
use std::{sync::Arc, time::Duration};
use clap::Parser;
use sqlx::SqlitePool;
use tokio::task::TaskTracker;
use tokio_util::sync::CancellationToken;
mod http_client; mod politeness; mod robots; mod retry; mod normalize; mod frontier;
#[derive(Parser, Debug)]
struct Args {
#[arg(long, default_value = "sqlite:crawler.db")]
database_url: String,
#[arg(long, default_value_t = 64)]
concurrency: usize,
#[arg(long, default_value_t = 30)]
requests_per_minute_per_host: u32,
#[arg(long, default_value = "127.0.0.1:9898")]
metrics_addr: String,
seeds: Vec<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
crate::observability::init_tracing();
crate::observability::init_metrics(args.metrics_addr.parse()?);
let client = http_client::build_client();
let pool = SqlitePool::connect(&args.database_url).await?;
sqlx::migrate!().run(&pool).await.ok(); // or sqlx::migrate!("./migrations")
let robots = robots::RobotsCache::new(client.clone());
let politeness = politeness::Politeness::new(args.requests_per_minute_per_host);
let shutdown = CancellationToken::new();
let tracker = TaskTracker::new();
let sem = Arc::new(tokio::sync::Semaphore::new(args.concurrency));
// Seed (idempotent):
for seed in &args.seeds {
if let Some(canonical) = normalize::normalize(seed) {
let url = url::Url::parse(&canonical)?;
let host = url.host_str().unwrap_or_default().to_owned();
frontier::enqueue(&pool, &canonical, &normalize::url_hash(&canonical), &host).await?;
}
}
// Spawn N worker loops; each leases one URL at a time (pattern 8) so the
// semaphore bounds fetches while the DB bounds ownership.
for worker_id in 0..args.concurrency.min(16) {
let (pool, client, robots, politeness, shutdown, sem) =
(pool.clone(), client.clone(), robots.clone(), politeness.clone(), shutdown.clone(), sem.clone());
tracker.spawn(async move {
let owner = format!("worker-{worker_id}");
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
lease = frontier::lease_one(&pool, &owner, 300) => {
let Some(lease) = lease.unwrap_or(None) else {
tokio::time::sleep(Duration::from_millis(250)).await;
continue;
};
let _permit = sem.acquire_owned().await.unwrap();
crawl_one(&client, &pool, &robots, &politeness, &shutdown, lease).await;
}
}
}
});
}
tokio::signal::ctrl_c().await?;
shutdown.cancel();
tracker.close();
tokio::time::timeout(Duration::from_secs(15), tracker.wait()).await.ok();
Ok(())
}
#[tracing::instrument(skip(client, pool, robots, politeness, shutdown), fields(url = %lease.url))]
async fn crawl_one(
client: &reqwest::Client,
pool: &SqlitePool,
robots: &robots::RobotsCache,
politeness: &politeness::Politeness,
shutdown: &CancellationToken,
lease: frontier::Lease,
) {
let Ok(url) = url::Url::parse(&lease.url) else { return };
let host = url.host_str().unwrap_or_default().to_owned();
if !robots.allowed(&url).await {
metrics::counter!("crawler.robots.denied_total").increment(1);
frontier::complete(pool, &lease.url_hash, None).await.ok();
return;
}
politeness.wait(&host).await;
// Retry loop (pattern 5) with shutdown awareness:
for attempt in 0..=5u32 {
if shutdown.is_cancelled() { return; } // lease expires back to queued
match client.get(lease.url.clone()).send().await {
Ok(resp) => {
if resp.status().as_u16() == 429 || resp.status().is_server_error() {
let delay = retry::retry_after(&resp)
.unwrap_or_else(|| retry::backoff_with_jitter(attempt, Duration::from_millis(500), Duration::from_secs(30)));
tokio::select! {
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(delay) => continue,
}
}
match resp.error_for_status() {
Ok(ok) => {
// TODO: stream with cap (pattern 13), hash (pattern 6),
// extract links (pattern 11), enqueue children (pattern 8).
let _ = ok;
frontier::complete(pool, &lease.url_hash, None).await.ok();
return;
}
Err(e) => {
tracing::warn!(%e, attempt, "terminal status");
frontier::complete(pool, &lease.url_hash, None).await.ok();
return;
}
}
}
Err(e) => {
if retry::classify(None, Some(&e)) == retry::Verdict::Terminal {
frontier::complete(pool, &lease.url_hash, None).await.ok();
return;
}
let delay = retry::backoff_with_jitter(attempt, Duration::from_millis(500), Duration::from_secs(30));
tokio::select! {
_ = shutdown.cancelled() => return,
_ = tokio::time::sleep(delay) => continue,
}
}
}
}
// Retries exhausted → release as failed with next_retry_at (UPDATE omitted for brevity).
}
mod observability {
pub use crate::observability_impl::*;
// real fns live in observability.rs (pattern 10); inlined here via include-ish shim
// to keep main.rs to one file for the guide. Split into modules in real code.
}
mod observability_impl {
use tracing_subscriber::EnvFilter;
pub fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
}
pub fn init_metrics(addr: std::net::SocketAddr) {
metrics_exporter_prometheus::PrometheusBuilder::new()
.with_http_listener(addr).install().expect("metrics");
}
}
Sources & further reading
- reqwest ClientBuilder: https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html
- tokio Semaphore / JoinSet / TaskTracker: https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html · https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html · https://docs.rs/tokio/latest/tokio/task/struct.TaskTracker.html
- CancellationToken: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
- futures buffer_unordered: https://docs.rs/futures/latest/futures/stream/trait.StreamExt.html
- governor: https://docs.rs/governor/latest/governor/
- texting_robots: https://docs.rs/texting_robots/latest/texting_robots/
- url: https://docs.rs/url/latest/url/
- blake3: https://docs.rs/blake3/latest/blake3/
- sqlx: https://docs.rs/sqlx/latest/sqlx/ · rusqlite: https://docs.rs/rusqlite/latest/rusqlite/
- tracing / tracing-subscriber: https://docs.rs/tracing/latest/tracing/ · https://docs.rs/tracing-subscriber/latest/tracing_subscriber/
- metrics / prometheus exporter: https://docs.rs/metrics/latest/metrics/ · https://docs.rs/metrics-exporter-prometheus/latest/metrics_exporter_prometheus/
- scraper: https://docs.rs/scraper/latest/scraper/
- thiserror: https://docs.rs/thiserror/latest/thiserror/ · backoff: https://docs.rs/backoff/latest/backoff/
- wiremock: https://docs.rs/wiremock/latest/wiremock/
- RFC 9309 (robots.txt): https://www.rfc-editor.org/rfc/rfc9309.html
Interview Prep: Web Crawling Engineer @ Exa (and AI-Infra Startup Loops)
Compiled 2026-09-11 for the final phase of the learn_rust roadmap (files 01–08). Targets the Exa "Software Engineer, Web Crawling" role (SF / Singapore) and generalizes to infra/crawler roles at comparable AI startups.
Verification legend — every load-bearing claim in this file is tagged:
| Tag | Meaning |
|---|---|
[VERIFIED] |
Fetched live on 2026-09-11 (Ashby job-board API, exa.ai, levels.fyi). Link inline. |
[REPORTED] |
Public candidate reports for other companies (used as comparables). Treat as directional. |
[INFERRED] |
Reasoned from verified signals (JD text, founder talks, hiring patterns). Not confirmed by Exa. |
[UNVERIFIED] |
Asserted but could not be checked this session. Re-check before you rely on it. |
What could not be verified this session: Glassdoor returned 403, the configured web-search API was rate-limited, and Blind content is login-gated. Two important negative findings from the searches that did run (DuckDuckGo + Bing, 2026-09-11): (1) there are no public interview-experience reports for Exa Labs (exa.ai) on Glassdoor/Indeed/levels.fyi — every Glassdoor "Exa interview" hit is a different company (Exa Corporation, EXA AG, Exa Data, ExaWizards); (2) the only candidate discussion found is a single Blind thread asking "Anyone familiar with Exa.ai?" with no process detail. So: nobody has published the loop. Section 1 reconstructs it from verified signals and comparable companies — do not treat it as fact, and ask your recruiter to confirm the loop in writing as soon as you have one.
1. The Interview Landscape: Exa and Comparable AI-Infra Startups
1.1 What is verified about the Exa role (Sep 2026)
Source: Ashby job-board API + careers page, fetched 2026-09-11.
- Two open crawler postings: SF (posted 2025-07-23) and Singapore (posted 2025-12-10). Identical text. 55 total open roles across SF/NY/Singapore/London/Sydney.
[VERIFIED] - The JD, verbatim, is your interview syllabus. The four "What You Could Do" bullets are almost certainly the four design-round topics:
- "Build a distributed crawler that can handle 100M+ pages per day"
- "Optimize crawl politeness and rate limiting across thousands of domains"
- "Design systems to detect and handle dynamic content, JavaScript rendering, and anti-bot measures"
- "Create intelligent crawl scheduling and prioritization algorithms for maximum coverage efficiency"
- Required profile: "extensive experience building and scaling web crawlers, or would be excited to ramp up very quickly" (the portfolio from file 08 exists to prove the second clause); "high performance language (C++, Rust, etc.)"; "TypeScript, Playwright, modern web design, CDP"; "comfortable optimizing a system to an exceptional degree"; "care about the problem of finding high quality knowledge."
[VERIFIED] - Logistics: in-person (SF or Singapore); visa sponsorship offered (STEM OPT/OPT/H1B/O1/E3 named); premium healthcare, 16 weeks parental leave.
[VERIFIED] - Comp context: levels.fyi shows Exa SWE total comp $415K–$580K (median ~$415K, max reported $580K, last updated 2026-09-11). Small sample — negotiate with that in mind.
[VERIFIED](levels.fyi/companies/exa) - Hiring infra: applications flow through Ashby; Exa employs in-house Technical Recruiters/Sourcers (open roles on the same board), so expect a professional recruiter screen rather than founder-only triage.
[VERIFIED] - Culture signals: "fully in-person team"; the chief of staff posted math puzzles around SF as a recruiting stunt (Business Insider, Apr 2025, linked from their own blog index) — puzzle-friendly, high-agency culture; Will Bryk's stated model is that Google staffs each search subsystem with thousands and Exa staffs each with "one or two people" (Latent Space interview).
[VERIFIED]for existence,[INFERRED]for what it implies about interviews: expect depth probes until you run out — they hire people who can own a subsystem alone.
1.2 The likely Exa loop [INFERRED — not confirmed by Exa]
No public loop exists. Reconstructed from the JD, the Ashby setup, Exa's size (~100 people), and the standard loop at peer AI startups. Confirm the actual loop with your recruiter on the first call — asking "what does the process look like?" is expected and costs nothing.
| Stage | Likely format | What they're testing | Confidence |
|---|---|---|---|
| 0. Application / sourcer outreach | Ashby form + resume; "Why Exa?" free-text | Portfolio signals (file 08), crawler vocabulary, high-agency evidence | [INFERRED] |
| 1. Recruiter screen (30 min) | Motivation, logistics, in-person SF/Singapore, comp, timeline | Genuine interest in search quality as a problem (JD's last bullet); relocation feasibility | [INFERRED] |
| 2. Technical screen (60 min) | Live coding (CoderPad-style), language of choice — use Rust | Practical coding, not puzzle trivia: expect rate limiter / cache / parsing / concurrency-flavored problems (Section 4) | [INFERRED] |
| 3. Onsite / virtual loop (3–5 rounds) | (a) 1–2 coding rounds; (b) 1–2 systems design rounds — almost certainly "design a crawler"-flavored, straight from the JD bullets; (c) project deep-dive / hiring-manager round; (d) founder or values round | Depth until failure on (b); ownership narrative on (c); mission fit on (d) | [INFERRED] |
| 4. References + offer | Back-channel + listed references; fast decision (startup pace: days, not weeks) | — | [INFERRED] |
1.3 Comparable-company loops (for calibration) [REPORTED — directional, verify per company]
These are the patterns public candidate reports converge on for AI-startup infra roles. They are not Exa reports; use them to shape expectations, not to script answers.
| Company | Reported loop shape | Notes for a crawler candidate |
|---|---|---|
| Perplexity | Recruiter → 1 coding screen → onsite: 2 coding + 1 system design (search/retrieval-flavored) + hiring manager. Fast turnaround (days). | Design round skews to retrieval/serving, but crawl/index ingest questions appear for infra roles. |
| Anthropic / OpenAI | Recruiter → technical screen → 4–5 round loop: practical coding (often repo-style, not LeetCode), systems design, deep project dive, mission/values. | Both run crawlers (ClaudeBot, GPTBot/OAI-SearchBot); expect politeness/robots ethics questions alongside scale questions. |
| Databricks / Confluent (infra) | Standard big-tech: 2 coding + 1–2 system design + behavioral. Heavier on distributed-systems fundamentals (Kafka, partitioning, exactly-once). | Good practice proxies for the ingest-pipeline design round. |
| Cloudflare (bot mgmt / data) | Recruiter → screen → loop with systems design + coding; famously longer process. | The anti-bot side of the table — worth understanding the defender's view (Exa's JD explicitly mentions anti-bot handling). |
| YC startups generally (~50–150 people) | Recruiter → 1 screen → half-day onsite → offer inside 1–3 weeks total. Take-home sometimes replaces one coding round. | Exa fits this shape [INFERRED]; speed is the norm — keep your 2-week crunch (Section 7) ready to start the day a recruiter replies. |
1.4 What Exa interviewers will optimize for [INFERRED from verified signals]
- Scale fluency — you can do the 100M-pages/day napkin math in your head and defend every number (Section 2 drills this).
- Ownership depth — "one or two people per subsystem" means they probe until they find the bottom of your knowledge. Never bluff; say "here's where my knowledge ends and how I'd find out."
- Optimization instinct — the JD says "optimizing a system to an exceptional degree." Have 2–3 stories with measured before/after numbers (throughput, p99, cost).
- Taste for quality — "care about the problem of finding high quality knowledge." Exa's moat is search quality, and crawl selection is the top of that funnel. Mentioning quality-aware crawling (content signals feeding the scheduler) separates you from pure-infra candidates.
- Rust/C++ credibility + TS/Playwright/CDP familiarity — the JD names all five. You don't need to be a Playwright expert, but you must speak CDP fluently (file 05) and write idiomatic Rust live.
- In-person, high-agency fit — they are explicitly in-person and explicitly high-agency. If you want remote, this is the wrong role; don't fake it.
1.5 Logistics checklist
- Confirm loop stages, round count, and languages allowed with the recruiter (email so it's in writing).
- Confirm coding environment: shared doc (no autocomplete) vs. repo + IDE. Startup screens are often plain CoderPad — practice without IDE assists.
- Ask whether the design round is a whiteboard/diagram round or a code round. Prepare both.
- SF onsite = half day in-person
[INFERRED]; Singapore posting suggests a Singapore loop for APAC candidates.[VERIFIED]that both postings are live. - Visa: they sponsor H1B/O1/E3/OPT — raise it early, they're experienced at it.
[VERIFIED]
2. Systems Design Question Bank (Crawler-Specific)
Eight questions, one per theme. For each: Prompt / Strong-answer outline / Key tradeoffs / Numbers to know / Common mistakes. Learn the framework, not the script — interviewers reroute mid-answer on purpose.
The delivery framework for every design round (45 min): clarify scope (3 min) → back-of-envelope numbers (5 min) → high-level boxes (7 min) → deep-dive 2–3 components the interviewer picks (20 min) → failure modes + tradeoffs (7 min) → wrap (3 min). Say your assumptions out loud; write numbers down as you go.
Q1. Design a web crawler at 100M pages/day
Prompt. "Design a distributed crawler that fetches 100M+ pages per day, stores them durably, and stays polite." (JD bullet 1, verbatim scale.)
Strong-answer outline.
- Scope: pages/day target, avg page size assumption (~100 KB raw HTML), storage retention, politeness contract (robots.txt RFC 9309), JS rendering in scope? (say: yes but as a separate tier — punt to Q6).
- Numbers (do these live): 100M/day ≈ 1,157 fetches/s avg, ~3K/s peak; ingress ≈ 10 TB/day ≈ 116 MB/s ≈ ~1 Gbps avg; in-flight requests via Little's Law: 1,157 QPS × ~2s latency ≈ ~2,300 concurrent fetches fleet-wide; raw storage 10 TB/day → ~2.5 TB/day compressed (~0.9 PB/yr).
- High-level boxes: Seed/Discovery → URL Frontier (prioritizer + per-host queues) → Fetcher fleet (stateless Rust workers, host-partitioned) → DNS cache tier → Dedup (URL seen-set + content SimHash) → Storage (S3 + columnar metadata, e.g. Lance/Parquet — matches Exa's public exa-d design) → Parser/Extractor → link feedback into frontier. A durable queue (JetStream/Kafka) decouples fetch from parse.
- Deep dives: frontier (Q2), politeness (Q3), dedup (Q4), scheduling (Q5).
- Failure modes: fetcher crash mid-batch (at-least-once from the queue + idempotent writes), poison URLs (retry budget → DLQ), hot-partition hosts, DNS amplification, storage backpressure (pause dequeue, don't drop).
Key tradeoffs.
- Pull (workers request work) vs push (scheduler assigns): pull self-balances and survives worker death; push enables precise per-host pacing. Common answer: pull from per-host-sharded queues, so politeness is enforced by queue topology, not worker discipline.
- At-least-once + idempotent sinks vs exactly-once: exactly-once across the open web is fiction; make re-fetch harmless.
- Centralized vs sharded frontier state: one Redis won't hold 500B URLs; shard by host hash, accept rebalancing complexity.
- Storage schema: raw blob + sidecar metadata vs single columnar file; blob+index is simpler, columnar is cheaper to scan (Exa chose columnar — exa-d on S3 + Lance
[VERIFIED]from their blog).
Numbers to know. 1,157 fetches/s; ~2,300 in-flight; ~10 TB/day raw; ~1 Gbps avg ingress; ~20–30 fetcher nodes at 500–1,000 conns each (plus render tier); ~$1.7K/mo per 75 TB hot S3; egress is the real cost — process in the same region/cloud as storage.
Common mistakes. No numbers (hand-wavy "it's scalable"); one global queue (no politeness possible); forgetting DNS; exactly-once claims; ignoring the parse/fetch decoupling; no DLQ; drawing Kubernetes boxes instead of data flow.
Q2. Design the URL frontier
Prompt. "Design the component that decides which URL is fetched next, holding ~500B known URLs, respecting per-host politeness, and feeding ~1,200 fetches/s." (Exa's about page claims 500B+ URLs tracked [VERIFIED]; use it.)
Strong-answer outline.
- Structure (Mercator/IR-book canonical): front queues = priority (when/why-fetch), back queues = politeness (per-host pacing). A selector pulls the highest-priority URL whose host is eligible now.
- State split: per-URL record =
{url_hash: 8B, priority, next_allowed_fetch, discovered_from, status}≈ 40B → 500B × 40B = ~20 TB → disk-backed, sharded byhash(host) % N. Per-host record (rate, robots etag, backoff) is tiny: ~250M hosts × 128B ≈ ~32 GB → in-memory + snapshot. - Implementation: hot window (next few hours of scheduled fetches) in a priority queue per shard; cold state in RocksDB/SST files keyed by url-hash; back-queue eligibility via min-heap keyed on
next_allowed_fetch_time; host → shard via consistent hashing so fleet growth doesn't reshuffle everything. - Discovery ingest: parsers emit outlinks → canonicalize (Q4) → seen-check → insert with initial priority. Bounded ingest rate with backpressure to parsers.
- Operations: checkpoint/restore (snapshot priority queues), drain/shrink shard count, poison-host quarantine.
Key tradeoffs.
- In-memory frontier (fast, fragile, capped) vs disk-backed (durable, slower): hybrid hot-window is the standard answer.
- Global strict priority vs politeness-feasible priority: strict priority starves politeness; you want max priority subject to host eligibility.
- Per-host vs per-registered-domain vs per-IP politeness granularity (subdomains can map to one fragile server; IPs catch shared hosting).
- Pull-based selection vs timer-wheel scheduling: a hierarchical timing wheel for
next_allowed_fetchavoids scanning millions of sleeping hosts.
Numbers to know. 20 TB sharded URL state; 32 GB host state; ~10 bits/URL if Bloom-gated (~625 GB for 500B at 1% FP); a shard should serve its slice of 1,157 QPS with p99 dequeue < 10 ms; frontier must absorb discovery bursts (one sitemap = millions of URLs) without OOM — bound the hot window.
Common mistakes. Keeping all URLs in RAM ("500B × 100B = 50 TB, oops"); no canonicalization before dedup; politeness enforced inside workers (breaks under retries/rebalance); no quarantine path; forgetting that frontier recovery speed after a crash is the real availability metric.
Q3. Design polite distributed fetching
Prompt. "You have 30 fetcher machines and thousands of active domains. Design the system that guarantees we never hammer any site — even under retries, deploys, and partial failures." (JD bullet 2.)
Strong-answer outline.
- Policy model: per-host effective rate =
min(robots crawl-delay, learned adaptive limit, global cap); default ~1 req per 2–10 s per host. Politeness is a scheduling property: a host's URLs are only dequeue-able when its token is available. - Topology: host-partitioned ownership —
hash(host) → fetcher(consistent hashing). Each host's pacing state lives in exactly one fetcher → no distributed rate limiter needed (the whole point of partitioning; say this sentence in the interview). - Token buckets: per-host token bucket (e.g.
governor) in the owner fetcher; refill = crawl-delay; burst capacity small (1–2). Cross-machine coordination only for mega-hosts (Wikipedia-class) that exceed one fetcher's capacity — give them a dedicated sub-fleet with a shared Redis limiter. - Adaptive politeness: track per-host latency/429/5xx; on 429 or rising latency, exponentially shrink the bucket (Scrapy AutoThrottle-style: target concurrency 1, grow on success, halve on throttle); honor
Retry-Afterabsolutely. On repeated 403s → quarantine host, alert. - Robots pipeline: fetch
/robots.txtonce per host per ~24h (cache with TTL + conditional GET), parse per RFC 9309, respect crawl-delay/sitemaps; 5xx robots = assume disallow-all (conservative); cache negative results briefly. - Failure safety: fetcher dies → its host partitions move, pacing state rebuilt from snapshot + conservative defaults (better to be temporarily too polite); deploys use handoff, never double-own a host.
Key tradeoffs.
- Host partitioning (simple, exact) vs distributed limiter in Redis (flexible, race-prone, Redis becomes politeness-critical).
- Throughput vs politeness under skew: if 40% of pending URLs are one host, you cannot go fast — politeness defines a throughput ceiling per host; the fix is host diversity, not rate violations.
- Reactive (AutoThrottle) vs proactive (site-specific configs) adaptation: need both; reactive for the long tail, configs for the top 10K hosts.
- Per-IP limiting additionally protects shared hosting / CDNs where many hosts = one server.
Numbers to know. Default 0.1–0.5 req/s/host → sustaining 1,157 fetches/s needs ~2,300–11,500 concurrently active hosts — host diversity is the throughput budget. Robots cache: 250M hosts × ~1 KB parsed ≈ manageable on disk, hot subset in RAM. One fetcher: 500–1,000 concurrent conns, ~2 KB/conn buffers.
Common mistakes. A single global rate limit (useless); politeness as a sleep() in worker code (breaks on retry/deploy); ignoring Retry-After; robots fetched per-page (once per host per day); treating 429 as an error to retry fast instead of a signal to slow down; no per-IP layer.
Q4. Design dedup at scale
Prompt. "Design (a) URL dedup over 500B known URLs and (b) content near-dedup over 100M new pages/day."
Strong-answer outline.
- Canonicalize first: scheme/host lowercase, default-port strip, dot-segment resolution, trailing-slash rules, sort query params, drop tracking params (utm_*), fragment strip. Most "dedup" bugs are canonicalization bugs.
- URL seen-set: 64-bit hash of canonical URL. 500B × 8B = 4 TB as a hash set → too big for one node. Options: sharded Bloom filter (~10 bits/URL at 1% FP → ~625 GB total, ~10 GB/shard at 64 shards) for the fast negative, backed by disk KV (RocksDB) for confirmation; false positives just skip a re-fetch (acceptable), false negatives impossible in Bloom. Counting Bloom/Cuckoo if deletion needed (recrawl budgets).
- Content exact dedup: SHA-256 of normalized body → same KV pattern.
- Content near-dedup: SimHash 64-bit per doc (100M/day × 8B = 800 MB/day of fingerprints); near-dups = Hamming distance ≤ 3; retrieval via banding (split 64 bits into 4×16 bands, hash each band — candidates share a band). Alternative: MinHash+LSH for Jaccard on shingles — more accurate, more expensive. Cluster IDs feed index selection (dedup at index time, not just crawl time).
- Where it lives: dedup is a filter stage between fetch and parse-store; fingerprints written alongside docs so downstream (index) can cluster.
Key tradeoffs.
- Bloom (space-cheap, FP-only, no enumeration) vs full hash set (exact, 8× space) vs Cuckoo (deletion, middle space).
- Dedup at crawl time (saves bandwidth) vs at index time (saves nothing upstream but catches cross-crawl dups) — answer: both, at different layers.
- SimHash (cheap, near-dup on full doc) vs shingle+MinHash (better for partial/template overlap) vs embedding dedup (semantic — Exa-relevant, GPU-costly; use for quality clustering, not first-pass dedup).
- Canonicalization aggressiveness: stripping params can collapse distinct pages (session IDs are dead, but
?page=2is not) — keep a param allowlist per site class.
Numbers to know. 4 TB raw hash set → 625 GB Bloom @1% FP; 800 MB/day SimHashes; Hamming ≤ 3 is the standard near-dup threshold; band collision math: 4 bands of 16 bits → candidate recall for true dups ≈ high, candidate rate for random ≈ n/2¹⁶ per band.
Common mistakes. Dedup before canonicalization; Bloom FP treated as impossible ("we'd never skip a URL" — you will, and that's fine); storing full URLs in the seen-set (50 TB); no answer for near-dups (mirror sites, AMP/canonical pairs, print views); ignoring that dedup keys must be stable across deploys (version your canonicalizer!).
Q5. Design crawl scheduling & prioritization
Prompt. "You can't crawl everything every day. Design the scheduler that decides what gets recrawled when to maximize the value of a fixed crawl budget." (JD bullet 4: "intelligent crawl scheduling and prioritization algorithms for maximum coverage efficiency.")
Strong-answer outline.
- Objective first: define value = freshness (change rate × page importance) + discovery (new-URL yield) + demand (what the index/users actually query). Exa framing: crawl selection is the top of the search-quality funnel — prioritize pages likely to be retrievable and useful, not just popular.
- Signals: PageRank/link mass, historical change frequency (per-page and per-host), content quality score, sitemap
<lastmod>(trust but verify — it lies), query-log/tenant demand, past crawl yield per host (pages worth keeping per fetch). - Recrawl policy: per-page change-rate estimation → optimal revisit interval (classic result: revisit proportionally to change frequency — the "freshness vs age" tradeoff, Cho & Garcia-Molina); bucket pages into cadence classes (hourly / daily / weekly / monthly / never); adaptive: shrink interval on observed change, grow on N consecutive no-change fetches.
- Discovery vs exploitation split: reserve a fixed slice of budget (e.g. 10–20%) for exploration — new hosts, sitemaps, link-prediction guesses (Exa trains link-prediction models per their Neural PageRank writing
[VERIFIED]from the Latent Space interview) — so coverage keeps expanding. - Mechanics: scheduler emits
(url, not_before, priority)into the frontier's front-queues; per-host budgets prevent one site's deep pagination from eating the day; dead-end hosts (3 straight low-yield crawls) get demoted automatically. - Feedback loop: fetch results (changed? quality? yield?) flow back into the scoring tables daily — scheduling is a learning system, not a static config.
Key tradeoffs.
- Freshness (recrawl hot pages) vs coverage (crawl new URLs): explicit budget split, measured by downstream index value, not vibes.
- Uniform recrawl (simple, wasteful — most pages never change) vs per-page adaptive (accurate, needs change history storage: 500B × few bytes ≈ manageable).
- Static host tiers vs learned per-host yield: learned wins but must be robust to gaming (SEO farms look busy).
- Push scheduling (cron-like global planner) vs pull scoring (frontier scores on dequeue): planner is auditable, pull is fresher — hybrid: nightly planner writes priorities, frontier respects them.
Numbers to know. Web change rates are brutally skewed — a small fraction of pages account for most changes (plan for top-cadence class ≈ 1–5% of URLs); one recrawl pass over 500B URLs at 100M/day = 5,000 days → exhaustive recrawl is impossible, prioritization is the product; change-detection via conditional GET (If-Modified-Since/ETag) returns cheap 304s (~200 B) — use it to sample change rates ~50× cheaper than full fetches.
Common mistakes. Scheduling purely by PageRank (static importance ≠ change rate); no exploration budget (coverage ossifies); trusting <lastmod>; no host-level caps; treating the scheduler as stateless (its history tables are the crown jewels — back them up).
Q6. Design a hybrid render pipeline
Prompt. "Some pages need JavaScript execution. Design the pipeline that decides which pages get rendered, renders them at scale, and doesn't bankrupt us." (JD bullet 3: "detect and handle dynamic content, JavaScript rendering, and anti-bot measures.")
Strong-answer outline.
- Two-tier fetch: Tier 1 = static HTTP (Rust, ~1–2 ms CPU/page, ~$1×cost). Tier 2 = headless Chromium via CDP (Playwright/pool of browser contexts, 2–5 s/page, ~500 MB–1 GB RAM/tab, 10–50× the cost). The design problem is the router, not the renderer.
- Escalation heuristics (cheap → expensive): content-length after parse suspiciously small; text/HTML ratio below threshold; known SPA frameworks detected (no SSR markup);
<div id="root">-only bodies; meta refresh; prior fetch yielded no extractable text; explicit site config. Escalate on evidence, not by default — every unnecessary render is 10–50× money. - Render fleet: browser pods with a context pool (reuse browser, fresh context per page for isolation); navigation with budget caps (max 30 s, max N subresources, abort on media/fonts unless needed); network-idle or selector-based wait conditions; block images/fonts/video when only text is needed (cuts render cost and bandwidth); steal back the rendered DOM + XHR-captured JSON.
- Anti-bot handling (the JD's phrase is "detect and handle" — be careful and honest here): first-line is don't need to — good UA hygiene, real TLS fingerprints, sane pacing, honoring robots; escalate to stealth only where permitted; detect challenge pages (Cloudflare/PerimeterX signatures) and route to a slow, careful lane or skip + retry later. Never claim you'd "bypass CAPTCHAs" — say you'd detect, back off, and respect the site's intent.
- Caching: render result cache keyed by URL+content-hash so re-renders only happen on change; rendered DOM and static HTML share one downstream schema (parse layer shouldn't care which tier produced it).
Key tradeoffs.
- Render-everything (simple, ~30× the compute bill) vs render-on-evidence (cheap, needs the heuristic layer — which is itself a classifier to maintain).
- Browser-per-page (isolated, slow) vs context reuse (fast, state-leak risk) — fresh context per page, pooled browsers.
- CDP direct (full control, you own everything) vs Playwright (ergonomics, TS — which is why the JD lists both TypeScript+Playwright and CDP: prototype in Playwright, hot path via CDP).
- Blocking subresources (fast, risk of missing content) vs full load (fidelity, cost).
Numbers to know. If 5% of 100M pages/day render: 5M renders/day ≈ 58 renders/s; at ~3 s/render → ~175 concurrent contexts → ~10–20 render nodes at 8–16 contexts each (RAM-bound: ~1 GB/context); render tier then costs roughly as much as the entire static tier — hence the escalation heuristics matter more than the renderer. Chromium cold start ~1–2 s; context creation ~50–100 ms.
Common mistakes. Rendering by default; one browser per page (no pooling); waiting networkidle0 on pages that never idle (analytics streams) — use selective waits; no subresource budget (one page pulls 40 MB of video); no challenge-page detection (you fill your render fleet with CAPTCHA walls); claiming CAPTCHA-solving as a plan.
Q7. Design crawl observability
Prompt. "It's 3am. Crawl throughput dropped 40% an hour ago. What telemetry exists, and how do you find the cause?" (Design the observability system for the crawl fleet.)
Strong-answer outline.
- The golden signals for a crawler: frontier depth (pending URLs), dequeue rate, fetch success rate by class (2xx/3xx/4xx/5xx/timeout/DNS), bytes in, per-host p50/p99 latency, robots-deny rate, render-escalation rate, dedup hit rate, queue lag, DLQ rate. Per-shard and per-host-class breakdowns — aggregates hide everything.
- Three pillars, wired together: metrics (Prometheus-style counters/histograms on every stage; exemplars linking to traces), traces (one trace per URL across frontier→fetch→parse→store;
tracingspans with url_hash, host, shard as fields), structured logs (sampled for successes, exhaustive for errors). - The 3am playbook (walk it): throughput down → is frontier depth empty (scheduler/upstream problem) or full (fetch problem)? → full + fetch rate down → is it one shard (partition skew / one dead fetcher) or global (DNS? egress saturation? a single mega-host timing out and serializing a partition?) → check per-class error rates → 5xx spike from one CDN = the web changed, not us. Time-to-answer should be minutes, because every stage has a counter.
- High-cardinality discipline: per-host metrics are 250M series — don't. Host-level detail goes to logs/traces (sampled) or a top-K sketch; metrics stay at shard/host-class level.
- SLOs + alerts: crawl-rate SLO vs plan (1,157 pages/s budget), freshness SLO (median recrawl delay per cadence class), politeness SLO (zero hosts exceeding configured rate — alert on violation, it's a trust bug not a perf bug).
- Quality telemetry (Exa-specific flourish): extraction success rate, mean text yield/page, render-tier hit rate of heuristics — the crawl's output quality, not just its throughput.
Key tradeoffs.
- Cardinality vs cost: metrics are cheap per series, logs are cheap per event, traces are the expensive middle — sample success traces hard (1%), keep all errors.
- Push (Prometheus scrape) vs push-gateway for short-lived fetchers: scrape the long-lived fleet; batch-job telemetry via gateway.
- Unified trace per URL (amazing debugging, expensive at 1,157/s) vs per-batch traces (cheap, coarse) — per-URL sampled + per-batch always.
- Alert fatigue: alert on SLO burn-rate, not raw counters.
Numbers to know. At 1,157 fetches/s: full-fidelity tracing ≈ 100M traces/day ≈ unrealistic; 1% sampling ≈ 1M/day ≈ fine. Metrics: ~200 series/fetcher × 30 fetchers + per-shard ≈ tens of thousands of series — trivial for one Prometheus. Log volume at 1 line/fetch ≈ 100M lines/day ≈ 10s of GB/day — sample successes 1:1000, keep all failures. Politeness violations: target zero, page someone.
Common mistakes. Only host-level or only aggregate telemetry (need shard-level); no trace linking across the queue hop (correlation IDs die at the broker — propagate context through message headers); alerting on "throughput down" without a "frontier empty vs full" split; no per-error-class counters ("errors up" is not an answer); dashboards nobody can read at 3am.
Q8. Design a search-index ingest pipeline
Prompt. "100M fetched pages/day must become searchable: parse, extract, embed, index. Design the pipeline from fetched bytes to queryable index."
Strong-answer outline.
- Stages: raw store (S3) → parse/extract (HTML → DOM → main-content extraction → text + links + metadata; custom parser — Exa built their own
[VERIFIED]from "How we're building the next generation of search") → quality gate (language ID, boilerplate/spam scores, near-dup cluster assignment from Q4) → enrich (embeddings for every kept doc — Exa trains its own embedding models on the Exacluster[VERIFIED]) → index build (two lanes: inverted/BM25 index and vector index — Exa serves both per their BM25 and vector-DB posts[VERIFIED]) → publish (atomic index swap, versioned). - Architecture: stream the hot path (fetch → parse → embed → incremental index segment) for freshness; batch the heavy path (nightly full-quality reprocessing, link-graph/quality recomputation — exa-d's dependency-DAG, fragment-level patching model
[VERIFIED]from the exa-d post). Exactly the Lambda-architecture conversation they want to hear. - Data layout: columnar (Lance/Parquet) with one row per doc version; metadata columns (fetch time, status, simhash, quality scores) separate from text/vector columns so scans are cheap; partition by crawl date + host shard; schema versioned.
- Backpressure & correctness: durable queue between every stage; idempotent sinks keyed by
(url_hash, content_hash); embedding GPU fleet is the scarce resource — shed load by deferring low-priority docs' embeddings, never by dropping them. - Freshness targets: new high-priority pages searchable in minutes (hot path); full corpus reprocess in a day-ish (batch); index segments merged in background, published atomically.
Key tradeoffs.
- Stream-first (fresh, complex) vs batch-first (simple, stale): hot+heavy split is the honest answer at 100M/day.
- Embedding every doc (GPU bill: 100M docs/day × ~3 KB vectors = ~300 GB/day of vectors, plus the compute) vs embedding only quality-gated docs: gate first.
- Incremental index updates (fresh, segment-fragmentation) vs full rebuilds (clean, slow): incremental + nightly merge.
- One doc store for text+vectors vs separate stores per index type: separate indexes, one source of truth in the columnar lake.
Numbers to know. Parse CPU: ~0.5–2 ms/page → 100M/day ≈ ~600–2,300 core-seconds/s ≈ 600–2,300 cores for parsing alone (the reason Exa wrote a custom parser in a fast language); text yield ~10 KB/doc → ~1 TB/day text; embeddings ~300 GB/day; BM25 index serving memory is precious — Exa's own post claims 50% memory reduction via novel encoding [VERIFIED] — know that trick exists.
Common mistakes. One synchronous mega-pipeline (a GPU hiccup stalls fetching); no quality gate before embedding (burning GPU on spam); treating the index as the source of truth (it's derived — the lake is truth); no backfill story ("we'd just rerun it" over a 0.9 PB corpus); ignoring that links extracted during parse feed both the link graph and the frontier — one parse, two consumers.
3. Rust Technical Interview Topics (mapped to the roadmap)
The JD says "high performance language (C++, Rust, etc.)" [VERIFIED]. Assume at least one round is Rust-deep. Each topic below lists: what they'll ask, the answer skeleton, and the roadmap file to revise.
3.1 Ownership & borrowing in crawler code (revise file 01)
Likely probes: "Why can't I store &str slices of a response body in my parse cache?" / "Walk me through what happens to memory when 1,000 tasks share a config." / "Rc vs Arc."
Answer skeletons.
- The response-body lifetime trap: you fetch a body into a reusable buffer, parse links as
&strslices into it, then try to return them while also reusing the buffer for the next fetch — the borrow checker rejects it because the slices' lifetime is tied to the buffer. Fixes, in order of preference: (a) own the data — extractString/Bytesat parse time (parsing is 0.5–2 ms; a few small allocs are noise); (b)Bytesfor zero-copy refcounted slices of the body; (c) per-request arena (bumpalo) if you truly need thousands of slices and free them all at once. Never (d)unsafeto silence it. - Shared state:
Arc<T>for read-mostly shared data across tasks (config, canonicalizer, robots cache behindRwLock/DashMap);Rcis single-threaded — using it intokio::spawnfails to compile because spawned futures must beSend. Say why:Rc's refcount isn't atomic; two threads could tear it. - Why Rust for a crawler (the "why not Go" question): no GC pauses at 1,200 fetches/s (tail latencies matter when a fetch slot is politeness-scarce); memory footprint per connection ~KBs not MBs; the type system encodes the retry/politeness state machine; fearless concurrency across 30 machines × 1,000 conns. Honest counterpoint: Go's GC is fine for most crawlers, Rust wins on tail latency, footprint, and refactor safety — and Exa's serving stack is already Rust
[VERIFIED](vector DB + highlights server posts).
3.2 Async/Tokio pitfalls (revise file 02) — the highest-yield Rust section
The canonical dozen — know each as a one-liner + fix:
- Blocking the executor: CPU-bound work (HTML parsing, SimHash, gzip) on an async task starves the runtime's worker threads → all latency degrades fleet-wide. Fix:
tokio::task::spawn_blocking(or a dedicatedrayonpool) for anything > ~100 µs of CPU. - Holding
std::sync::MutexGuardacross.await: the guard isn'tSend(compile error) or worse, you hold the lock while yielding → convoy. Fix: drop the guard before awaiting; if you must hold it,tokio::sync::Mutex(but prefer redesign: message-passing or DashMap's shard-scoped guards). - Unbounded channels:
mpsc::unbounded_channelbetween a fast producer (parser emitting links) and a slow consumer (frontier ingest) = silent OOM at 3am. Fix: bounded channels;send().awaitis your backpressure; size the buffer from Little's Law. - Task explosion:
tokio::spawnper URL with no bound → millions of pending tasks, RSS climbs, runtime thrashes. Fix: worker pool +Semaphorepermits acquired before spawn. select!cancellation bugs: dropping the loser of aselect!mid-operation — e.g. a half-written request, a consumed queue message whose processing was cancelled → lost work / corrupt state. Fix: know which of your futures are cancellation-safe (reading a channel: yes with care; processing after receive: no) and structure with tokio's "cancellation safety" discipline.- One giant timeout vs per-stage timeouts: a single 30 s wrapper hides whether DNS, connect, TTFB, or body-read hung. Fix: connect 5 s / TTFB 10 s / total 30 s as separate timeouts — the error variant tells you what broke (this is also your observability).
- DNS blocking: naive resolution can block or serialize. Fix:
hickory-resolver/custom async resolver with caching; remember DNS is part of your latency budget (Q1). - CPU-bound in disguise: regex-heavy extraction, decompression, JSON on 10 MB bodies — all blocking (see #1). Profile before you believe your own architecture diagram.
tokio::time::sleepin a loop vsinterval: sleep-loops drift;intervalcatches up (andMissedTickBehaviormatters for rate limiters —Delayis usually right for politeness pacing).- No graceful shutdown: SIGTERM → dropped in-flight fetches → phantom retries. Fix:
CancellationToken/broadcastchannel + drain: stop dequeuing, finish in-flight (bounded wait), checkpoint frontier, ack/nack queue messages correctly. - Forgetting
JoinSet/supervision:spawnand forget → panics vanish into the void. Fix:JoinSetwith a supervisor task loggingJoinErrors; a dead task should be a metric, not a mystery. - Retry without jitter/budget: synchronized retries thunder-herd a recovering host. Fix: exponential backoff + full jitter + attempt cap + honor
Retry-After, then DLQ.
3.3 Lifetimes in crawler code (revise files 01/03)
- The two sentences that pass interviews: "A lifetime parameter is a descriptive bound on how long references stay valid relative to each other — it never extends anything's life. In async Rust the practical rule is stronger: anything crossing a
tokio::spawnboundary must be'static, which in practice means owned orArc." - Patterns to name-drop with confidence:
'_elision in iterator adapters over response data;Cow<'_, str>for canonicalization (borrow when already-normal — most URLs are — clone only when rewriting); self-referential structs are the smell (parsed doc borrowing its own buffer) → split the struct or own the buffer;async fnin traits →async_trait/native AFIT captures lifetimes of&self— fine until you spawn. - The classic interview trap: "cache a
&strkey in a global map" → can't; intern strings (Arc<str>) or hash tou64keys — and note the interning table itself needs eviction or it leaks (LRU, Section 4).
3.4 Error design (revise files 03/04)
- Library vs binary:
thiserror(typed, exhaustive, part of your API contract) for libraries;anyhow(ergonomic, context-stacking) for binaries/orchestration. Know both macros cold. - The crawler error taxonomy — this is the answer that sounds like production experience:
#[derive(thiserror::Error, Debug)]
pub enum FetchError {
#[error("dns resolution failed for {host}")] Dns { host: String },
#[error("connect: {0}")] Connect(#[source] std::io::Error),
#[error("timeout after {stage}")] Timeout { stage: &'static str },
#[error("tls: {0}")] Tls(String),
#[error("http {status}")] Status { status: u16, retry_after: Option<u64> },
#[error("robots.txt disallows")] RobotsDenied, // permanent, don't retry
#[error("body exceeded {max} bytes")] BodyTooLarge { max: usize },
#[error("parse: {0}")] Parse(String),
}
Then the punchline: the error enum is the retry policy — write the fn retryable(&self) -> RetryDecision match in the interview: DNS/Connect/Timeout/5xx → backoff+jitter; 429 → honor Retry-After; 4xx (non-429) and RobotsDenied → permanent; BodyTooLarge → permanent + record. Typed errors also feed metrics labels directly (Section Q7).
#[from]conversions,#[source]chains, and why you don't putStringin everything (allocation + lost structure + lost matchability).
3.5 Concurrency primitives (revise files 02/06)
- Decision table to recite: read-heavy shared map →
DashMaporArc<RwLock>(RwLock under writer starvation caveats); write-heavy counters →AtomicU64(Relaxedusually suffices for metrics — know the ordering ladder: Relaxed/Acquire/Release/SeqCst and one real use for each); one-time config →OnceLock; task fan-in →JoinSet; concurrency cap →Semaphore; work distribution → channels (3.6), not shared queues, until profiling says otherwise. - Know the classic bug: deadlock via lock-ordering (host-state lock → frontier lock in one path, reverse in another) — fix by a documented lock hierarchy, or better, by not sharing.
parking_lotvs std: smaller, faster, no poisoning — mention poisoning exists and std Mutex panics-on-poisoned-lock semantics.- False sharing for counters on 32-core fetchers: pad or shard counters (
CachePadded) — a lovely "exceptional degree of optimization" story seed.
3.6 Channels (revise files 02/06)
- The Tokio quartet + when:
mpsc— work distribution, N producers → 1 consumer (only one consumer! the classic mistake is assuming competing consumers; for MPMC you need N queues, a broker, orflume/crossbeam);broadcast— shutdown/fan-out, lagging receivers getRecvError::Lagged(design for it: politeness config updates must tolerate lag);watch— latest-value semantics (config: last write wins, stale reads fine);oneshot— request/response correlation (e.g. render-fleet RPC). - Backpressure semantics: bounded
mpsc::send().awaitsuspends the producer = backpressure propagates upstream to the frontier = desired;try_sendwith a metric = load-shedding point you can alert on. Unbounded = deferred OOM (see 3.2 #3). - Cross-runtime:
crossbeam/flumechannels bridge sync (spawn_blocking parse pool) and async worlds;std::sync::mpscblocks — never.recv()on the async thread. - Capacity math: buffer ≈ producer_rate × consumer_p99_latency × safety(2–4×); say "I'll size it from Little's Law and alert on saturation" and the round is yours.
3.7 Testing (revise file 06)
- Pyramid for a crawler: pure-function tests (canonicalizer, robots parser — table-driven + proptest with adversarial inputs: IDN, empty labels, 2 KB URLs, weird ports) → component tests with
wiremock(fake origin servers returning fixtures: 200/304/429+Retry-After/gzip trickle) →tokio::test(start_paused = true)for time-warped token-bucket/backoff tests (no real sleeping — deterministic and instant) → integration: docker-compose mini-fleet (frontier + 2 fetchers + mock origin, assert politeness invariants end-to-end: never >1 req/Δt to a host). - Concurrency testing:
loomfor any lock-free bits (permutation of interleavings); at minimum, stress tests under--releasewith thousands of tasks. - Property tests worth naming: canonicalization is idempotent (
canon(canon(u)) == canon(u)); dedup never admits the same canonical URL twice; rate limiter never exceeds configured rate within any window. - Fault injection: mock servers that hang (timeout paths), trickle bytes (TTFB vs body timeouts), reset connections; chaos-kill a fetcher and assert nack-and-redeliver.
- The sentence to say: "My politeness guarantees are enforced by tests that measure time, run with paused clocks, so they're both deterministic and fast."
3.8 Rapid-fire drill list (self-quiz; each should take you < 60 s)
String vs &str vs Cow vs Bytes · Send vs Sync · why Rc fails in spawn · Mutex vs RwLock vs DashMap · guard-across-await · spawn_blocking threshold · select! bias + cancellation safety · interval vs sleep-loop · Semaphore before vs after spawn · mpsc vs broadcast vs watch vs oneshot · unbounded channel failure mode · JoinSet supervision · CancellationToken shutdown dance · thiserror vs anyhow · #[from]/#[source] · retryable-error match arms · atomics orderings · OnceLock · pinning in one sentence (self-referential futures must not move) · why async fns are lazy (nothing runs until awaited) · Stream vs Iterator · FuturesUnordered · tokio::test paused time · proptest idempotence property · loom purpose.
4. Coding Round: Resources + Curated Problem List
Startup screens are usually practical coding (build a small real thing) or 1–2 LeetCode-medium problems, in your language of choice — do them in Rust unless told otherwise; it's a differentiator and the JD names it. Practice in a plain text pad (CoderPad-style) at least half the time.
4.1 Practice resources
| Resource | Use for | Cost |
|---|---|---|
| LeetCode (company-tagged lists are thin for Exa — use the curated list below) | Timed mediums | Free/premium |
| NeetCode 150 / Blind 75 | Pattern coverage if you're rusty on interviews generally | Free |
| Hello Interview | The web-crawler system-design breakdown (already in file 06) + mock rubric | Free |
| interviewing.io / peers | Live mock interviews; schedule 2 in week 2 | Paid/free w/ peers |
| exercism Rust track + Rustlings | Rust fluency drills if you fumble syntax under pressure | Free |
| Your own projects (files 03–06 exercises) | The real prep: re-implement your token bucket, LRU robots cache, and frontier queue from scratch, timed | Free |
4.2 The curated list (crawler-flavored)
Ordered by interview-probability × crawler-relevance. Do the bold ones twice, once timed.
| # | Problem | Crawler connection | Rust twist to practice |
|---|---|---|---|
| 1 | LRU Cache (LC 146) | robots.txt cache, DNS cache | HashMap<u64, NonNull<Node>> is unsafe-adjacent — the honest Rust answers: linked_hash_map crate, or Vec-indexed intrusive list. Know why naive Rust LRU is the famous borrow-checker fight |
| 2 | Token bucket (custom) | per-host politeness | Implement {capacity, refill_rate, tokens, last_refill}; then with governor; then test with start_paused |
| 3 | Sliding-window rate limiter (LC 359 Logger Rate Limiter + custom log-window variant) | per-IP limiting | VecDeque<Instant> vs count-approximation; memory tradeoff |
| 4 | Design Hit Counter (LC 362) | per-host fetch counters | Ring-buffer buckets O(1) |
| 5 | Bounded blocking queue (LC 1188) | frontier handoff between stages | Then implement the async version with mpsc + Semaphore |
| 6 | Web Crawler / Multithreaded (LC 1236, 1242) | literally the job | Do it with JoinSet + seen-set + bounded concurrency; discuss politeness extension |
| 7 | URL canonicalizer (custom) | dedup correctness | Lowercase scheme/host, strip default port, resolve .., sort query, drop fragments/utm_*; proptest idempotence |
| 8 | Insert Delete GetRandom O(1) (LC 380) | random host sampling for exploration budget | swap-remove trick |
| 9 | Top K Frequent (LC 347) | hot-host detection | bucket sort O(n); mention Count-Min Sketch for the streaming version |
| 10 | Kth Largest in a Stream (LC 703) + Find Median from Data Stream (LC 295) | latency percentile telemetry | two-heap pattern; mention t-digest/HDR histogram as the production answer |
| 11 | Time Based KV Store (LC 981) | frontier's per-URL history | binary search on versions |
| 12 | Design Twitter (LC 355) | merging K priority streams = frontier's front-queues | k-way merge with a heap |
| 13 | Merge Intervals (LC 56) / Meeting Rooms II (LC 253) | crawl-window scheduling | sweep-line |
| 14 | Trie (LC 208) | domain-suffix policies (.gov rules), URL-prefix routing |
boxed nodes; mention suffix-reversed host keys |
| 15 | Snapshot Array (LC 1146) | frontier checkpointing | copy-on-write versions |
| 16 | LFU Cache (LC 460) | render-result cache | frequency buckets + LRU tiebreak; honestly assess vs LRU |
| 17 | Min Stack (LC 155) pattern | monotonic structures | warm-up |
| 18 | Concurrency classics (LC 1114/1115/1116/1195, Dining Philosophers 1226) | threaded coordination basics | redo two of them with Tokio primitives, not std threads |
| 19 | SimHash + banded lookup (custom) | near-dedup (Q4) | 64-bit fingerprint, popcount via count_ones(), band index in a HashMap |
| 20 | Consistent hashing ring (custom) | host→fetcher partitioning (Q3) | virtual nodes, BTreeMap range queries |
4.3 Rust implementation drills (write these from scratch, timed, until each is < 30 min)
- Token bucket +
#[tokio::test(start_paused)]proof it never exceeds rate. - LRU cache (crate-free, safe Rust:
HashMap<K, usize>+ index-linked Vec). FetchErrorenum +retryable()match (Section 3.4) — from memory.- Concurrent fetcher skeleton:
JoinSet+Semaphore+ boundedmpsc+CancellationTokenshutdown, ~80 lines. - URL canonicalizer + 6 unit tests + 1 proptest.
- robots.txt matcher: longest-match allow/disallow per RFC 9309 + wildcard/
$rules.
5. Behavioral & Company Round
5.1 Speak Exa's language
Map their words (verified sources) to your material:
Exa's phrase [VERIFIED: JD / founder talks] |
What it means | Your move |
|---|---|---|
| "Basically build Google-scale crawling!" | Ambition is the job description | Never apologize for scale claims; do the math instead |
| "optimizing a system to an exceptional degree" | Perf depth is a core value | Stories with measured before/after (p99, throughput, $) |
| "one or two people" per subsystem | Extreme ownership | Stories where you alone owned a system end-to-end, including the 3am part |
| "care about the problem of finding high quality knowledge" | Mission filter | Connect crawling to search quality: crawl selection determines what the index can ever know |
| "high performance language (C++, Rust)" | Systems credibility | Rust war stories with real failure modes (Sections 3.1–3.7) |
| "TypeScript, Playwright, CDP" | Pragmatic tooling | Show you pick the right tool per tier (file 05), not language tribalism |
| In-person, ~100 people, SF | High-trust, high-bandwidth culture | Show you thrive shoulder-to-shoulder; don't pitch remote |
5.2 STAR template (that doesn't sound templated)
- Situation — 1–2 sentences, include the scale number ("our crawler was doing 20M pages/day and egress costs were exploding").
- Task — your specific ownership, not the team's ("I owned the fetch layer").
- Action — 60–70% of the answer; 3–4 concrete steps with the why; name the alternatives you rejected and why.
- Result — quantified ("p99 9s → 1.8s; egress −38%; zero politeness complaints since"), plus one sentence of what you'd do differently — that's the seniority signal.
- Keep it ≤ 2 minutes; the interviewer digs into what they care about.
5.3 Story bank to prepare (six, mapped to the JD)
- Scale story — a system you grew by ≥ 10×; the bottleneck ladder you climbed (CPU → IO → coordination → economics).
- Optimization story — profiled, hypothesized, measured; include the flamegraph/pprof moment and the number.
- Reliability/incident story — you broke production, detected it, fixed it, and changed the system so it can't recur (DLQ, checkpoint, test).
- Politeness/negotiation-with-reality story — an external constraint (rate limits, robots, an angry site admin) you engineered around respectfully.
- High-agency story — you saw a problem outside your lane, fixed it without permission, and it stuck.
- Learning-fast story — JD says "or would be excited to ramp up very quickly": a time you went from zero to productive in a new domain in weeks (your Rust roadmap itself is this story — files 01–08 with working code are the receipts).
5.4 Questions to ask them (they judge these)
- "How is the crawl fleet partitioned today — per-host ownership, and how do mega-hosts that exceed one worker's capacity get handled?"
- "What fraction of pages currently need the render tier, and how has that trend moved as the web goes JS-first?"
- "How does crawl scheduling consume quality signals — is the scheduler learning from downstream search quality, or from crawl-side signals only?"
- "What's the frontier's recovery time after a node loss, and what broke the last time it was tested?"
- "With one or two people owning crawling — what does the oncall and the first 90 days look like?"
- "How do you decide what not to crawl?"
5.5 "Why Exa?" formula (60 seconds, three beats)
- The problem: search quality for agents is bounded by what gets crawled and selected — crawling is the top of the funnel, not plumbing (mirrors Will Bryk's three-pillar framing: discovery/crawling → neural indexing → fast serving
[VERIFIED]from the Latent Space interview). - The company: small team claiming Google-scale ambitions with public receipts — 500B+ URLs, custom parser, Rust vector DB, exa-d
[VERIFIED]— and the "one or two people per subsystem" model is exactly the ownership you want. - You: your roadmap + portfolio (files 01–08) is a deliberate, documented ramp on this exact stack — Rust fetcher, polite frontier, CDP render tier, distributed observability. You're not exploring; you've been pre-training for the role.
6. Mock Interview Q&A (12 rounds with model answers)
Practice protocol: read the question, answer out loud for 2–4 minutes before reading the model answer, then diff. The model answers are deliberately dense — in a real round you'd say ~60% and let the interviewer pull the rest.
Q1 — "Design a crawler for 100M pages a day. Go." (Design round opener) Model answer skeleton: "Numbers first: 100M/day is ~1,160 fetches/s average, call it 3K peak. At ~100 KB a page that's ~10 TB/day in, about a gigabit of sustained bandwidth. With ~2-second average fetch latency, Little's Law says ~2,300 requests in flight — comfortably 20–30 fetcher nodes at under 1,000 connections each. The architecture is six boxes: a frontier holding our 500B known URLs — front queues for priority, back queues for per-host politeness, sharded by host hash on consistent hashing; a fetcher fleet where each host is owned by exactly one fetcher, so politeness is a local token bucket, not a distributed lock; a DNS cache tier; dedup — canonicalize, then a sharded Bloom seen-set plus SimHash for near-dups; storage on S3 in columnar files with idempotent writes keyed by url-hash-plus-content-hash; and a durable queue between fetch and parse so a slow parser never stalls the network. Failures: workers crash → the queue redelivers, writes are idempotent; poison URLs → retry budget then DLQ; a host returning 429s → that host's token bucket shrinks exponentially, fleet-wide throughput is unaffected because hosts are independent lanes. The one thing I'd want to dig into next is the scheduler — what gets recrawled when — because exhaustive recrawl at this scale takes years; prioritization is the real product." (Why it works: numbers → boxes → politeness-by-topology → failure modes → a deliberate next-step hook.)
Q2 — "How exactly does the frontier pick the next URL?" (Deep-dive follow-up)
Model answer: "Two-tier, Mercator-style. Front-queues hold URLs ordered by priority — priority is computed nightly by the scheduler from change-rate, link mass, quality, and demand signals. Back-queues hold URLs grouped by host, and each back-queue has a next_allowed_time = last fetch + politeness delay. The selector maintains a min-heap over back-queues keyed by that time; it pops the earliest-eligible host, takes its highest-priority URL, and reinserts the host with a new eligibility time. State: hot window — the next few hours of scheduled work — in memory per shard; the cold 20 TB lives in RocksDB keyed by url-hash. A timing wheel handles the millions of sleeping hosts without scanning. The subtle part: I don't want strict global priority — I want max priority subject to host eligibility, otherwise politeness collapses under a priority storm."
Q3 — "A site starts 429ing you mid-crawl. Walk me through what happens, component by component." (Incident-flavored design probe)
Model answer: "First, the fetcher's HTTP layer classifies it — typed FetchError::Status{429, retry_after} — which is not the generic failure path. If Retry-After exists, the URL is rescheduled for exactly that time; otherwise exponential backoff with full jitter. Second, the host's adaptive limiter reacts: concurrency target drops — AutoThrottle-style, halve on throttle, recover slowly on successes — so we don't oscillate between hammering and silence. Third, the metrics layer fires: per-error-class counter for that host's shard; if the 429 rate crosses an SLO burn threshold, someone gets paged with the host already in the alert context. Fourth, if 429s persist across backoff — say three cycles — the host is quarantined: its back-queue is parked for an hour, its URLs stop consuming fetch slots, and it shows up on the host-health dashboard for a human to decide whether our crawl-delay config for that site is simply wrong. The key property: one host's distress never propagates — politeness is per-host lanes, so the other thousands of hosts keep their rates untouched."
Q4 — "How do you dedup half a trillion URLs?" (Q4 of the bank, compressed) Model answer: "Canonicalize first — that's where the real bugs live: lowercase scheme and host, strip default ports, resolve dot segments, sort query params, drop trackers like utm, strip fragments. Then hash the canonical form to 64 bits. Storing 500B × 8-byte hashes is 4 TB, so the fast path is a sharded Bloom filter — about 10 bits per URL at 1% false-positive rate, so ~625 GB total, ~10 GB per shard at 64 shards, memory-resident. A Bloom false positive means we occasionally skip a recrawl we'd have done — acceptable; false negatives are impossible, so nothing is ever fetched twice due to the filter. The exact record still lands in the sharded KV for confirmation and enumeration. If we need eviction — say recrawl-budget expiry — swap Bloom for a Cuckoo filter and pay the extra space. And I version the canonicalizer: dedup keys must be stable across deploys or your seen-set silently changes meaning."
Q5 — "When do you escalate a page to the headless render tier, and what does that tier cost?" (Q6 of the bank, compressed) Model answer: "Rendering is 10–50× the cost of a static fetch — 2–5 seconds and up to a gigabyte of RAM per page versus a millisecond of CPU — so the router is the design. Escalate on evidence: tiny extracted-text-to-HTML ratio, known SPA shells with no SSR markup, a previous static fetch that yielded nothing extractable, or a site config. Detection comes before cost. The tier itself: pooled browsers, fresh context per page for isolation, hard budgets — 30 s wall clock, subresource caps, images/fonts/video blocked when we only need text — and the rendered DOM enters the same downstream schema as static HTML so parsing doesn't care. At 5% escalation on 100M pages/day that's ~58 renders a second, ~175 concurrent contexts, roughly 10–20 RAM-bound nodes — i.e., the render tier can cost as much as the entire static fleet, which is why the heuristics earn their keep. On anti-bot: I detect challenge pages and back off or slow-lane them; I don't pitch CAPTCHA-solving, both for ethics and because it's an arms race you lose at 3am."
Q6 — "Why can't you hold a MutexGuard across an await? And what do you do instead?" (Rust deep-dive)
Model answer: "Two problems. The compile-time one: std::sync::MutexGuard isn't Send, and a future held across an await point may resume on a different worker thread, so Tokio correctly refuses to spawn it. The worse, runtime one: even with a Send guard, you'd hold the lock while the task is parked — every other task needing that lock stalls, and under an await that waits on the network you've turned a concurrency tool into a convoy. Fixes in preference order: restructure so the lock is released before the await — take the data out, drop the guard, then await; share by message-passing so there's no lock at all; use DashMap where guards are shard-scoped and short-lived; and only if you truly need to hold across await, tokio::sync::Mutex, knowing it's slower and usually a design smell."
Q7 — "Where does an unbounded channel bite you in a crawler?" (Rust + production judgment)
Model answer: "Between the fast producer and the slow consumer — the classic is the parser emitting discovered outlinks into frontier ingest. Fetch is bursty: one sitemap parse yields a million URLs in seconds. With unbounded_channel, the queue absorbs them into RSS — no error, no backpressure, just a fetcher that OOMs at 3am, and because it was 'working' all day, nobody sized anything. The fix is a bounded channel where send().await is the backpressure signal: when frontier ingest is saturated, the parser suspends, which suspends fetch via the next bounded hop upstream. Size buffers with Little's Law — producer rate times consumer p99 — and put a metric and a try_send load-shed point where you'd rather drop than stall. The general principle: in a crawler every queue should have a number on it, and every number should have an alert."
Q8 — "Your fetch fleet's p99 latency just doubled. How do you find the cause?" (Observability round) Model answer: "First split: is the frontier empty or full? Empty means it's upstream — the scheduler stopped feeding us, discovery broke. Full means fetching degraded. Then decompose latency by stage — I have separate timeouts and metrics for DNS, connect, TTFB, and body read, so the histogram tells me which stage moved. If it's TTFB concentrated on one shard, I look at that shard's host mix: one mega-host slowing down serializes its back-queue lane; per-host p99 in logs confirms. If it's global, I check shared resources: egress saturation, DNS resolver health, TLS handshake storms after a cert rotation. Every hop carries a trace context through the queue headers, so any sampled slow URL shows its whole journey. The answer I'm looking for is one of five shapes: upstream starvation, one bad host lane, one bad shard, shared-resource saturation, or the web itself changed — and the telemetry should distinguish all five in minutes."
Q9 — "Implement a rate limiter right now — token bucket, per host." (Coding round)
Model answer: Write it live while narrating: struct with capacity, tokens, refill_per_sec, last: Instant; try_acquire() refills lazily — elapsed × rate, capped at capacity — then spends a token or returns the wait duration. Then the interview upgrades: "make it async-fair" → callers sleep(wait) or you vend an acquire().await using Tokio's timer; "prove it" → #[tokio::test(start_paused = true)], advance time, assert never over rate in any window; "make it fleet-safe" → you don't — politeness correctness comes from host partitioning so the bucket is single-writer; a distributed limiter is only for mega-hosts, in Redis, with a Lua script for atomicity. Each upgrade is a chance to show the Section 3 instincts: no locks across awaits, paused-time tests, topology over coordination.
Q10 — "Parse and normalize this URL list." (Coding round, crawler-flavored)
Model answer: Canonicalizer from drill 4.3: lowercase scheme+host (careful: path and query stay case-sensitive), strip default ports, resolve ./.. per RFC 3986 without touching the query, drop empty query params and the fragment, sort query keys, strip known trackers, handle IDN via punycode normalization, cap length defensively. Narrate the traps as you go: example.com:443/ with https — port goes; ?b=1&a=2 — sort; // in path — don't collapse blindly, some servers distinguish; trailing slash on root — normalize to /. Then offer the proptest: canon(canon(u)) == canon(u) for arbitrary inputs, and mention you version this function because dedup keys depend on it (Q4 callback — interviewers love cross-references).
Q11 — "Tell me about a time you optimized a system to an exceptional degree." (Behavioral, using Exa's words)
Model answer shape (fill with your real project): "S: our fetch pipeline did 20M pages/day and p99 fetch latency was 9s — we were leaving politeness-scarce fetch slots idle. T: I owned the fetch layer end-to-end. A: I profiled first — flamegraph showed 40% of CPU in regex-heavy extraction running on the async executor, which also explained the tail: parse work was starving network tasks. Three changes: moved extraction to a spawn_blocking pool; replaced the regex chain with a single-pass state machine; and set per-stage timeouts so a slow body-read couldn't squat a connection. I rejected a rewrite of the HTTP client — profiling didn't implicate it. R: p99 9s → 1.8s, throughput per node +2.6×, and we deferred a planned fleet doubling. What I'd do differently: I'd have added the stage-latency histograms before optimizing — I got lucky that my first hypothesis was right." (Note the bones: measured before/after, rejected alternatives, executor-awareness, and a self-critical tail — that tail is what makes it sound senior.)
Q12 — "Why Exa, and why the crawler team?" (Founder/values round) Model answer shape: "Three reasons. The problem sits at the top of the search funnel: an agent-search engine can only ever be as good as what its crawler chose to fetch — crawl selection is quality selection, and that's the part of search I want to work on. The model: you're explicit that each subsystem gets one or two people — I've spent the last months building exactly this subsystem in public as a ramp: a Rust fetcher with per-host politeness, a CDP render tier, a distributed frontier with real observability — because that's the ownership scope I want to be accountable for. And the receipts: 500B URLs, a custom parser, a Rust vector DB, exa-d — the engineering blog reads like a team that ships the things it writes about. I want to be the person the 100M-pages-a-day bullet is true of." (Then stop talking. Founder rounds punish rambling.)
7. The 2-Week Interview-Crunch Plan
Assumes ~4–6 focused hours/day and an interview loop at the end of week 2. If you have less time, do days 1–2, 4–5, 7, 12, 13 — that's the minimum viable spine.
Week 1 — Systems + Rust depth
| Day | Focus | Concrete work |
|---|---|---|
| 1 | Target lock | Re-read the JD (Section 1.1); write your "Why Exa?" (5.5) and six story seeds (5.3); book 2 mock interviews for week 2; read Will Bryk's Latent Space interview + "How we're building the next generation of search" |
| 2 | Design: the big one | Study Q1 (100M/day) + Hello Interview's crawler breakdown; reproduce the napkin math from memory; draw the six-box architecture twice, once narrated aloud at interview pace |
| 3 | Design: frontier + politeness | Q2 + Q3; re-derive the frontier numbers; re-read Mercator's front/back-queue sections (file 04); explain consistent-hashing host partitioning out loud |
| 4 | Rust: ownership + lifetimes | Section 3.1/3.3; drills 4.3 #5 (canonicalizer) and #3 (FetchError from memory); rapid-fire list (3.8) pass 1 — mark anything over 60 s |
| 5 | Rust: async | Section 3.2's dozen — for each, write the one-liner + fix from memory, then check; drill 4.3 #1 (token bucket + paused-time test) and #4 (fetcher skeleton) |
| 6 | Coding | Timed: LC 146 LRU, LC 362 hit counter, LC 1242 multithreaded crawler (or 1236); review against 4.2 notes; re-do whichever was slowest, untimed, in idiomatic Rust |
| 7 | Mock + review | Mock 1: systems design (crawler) — record it; harvest every hesitation; rewrite the answers; draft final STAR stories (aim ≤ 2 min each); half-day rest |
Week 2 — Breadth + repetition + loop simulation
| Day | Focus | Concrete work |
|---|---|---|
| 8 | Design: quality systems | Q4 dedup + Q5 scheduling; derive Bloom/Cuckoo/SimHash numbers from scratch; one paragraph on "crawl selection is quality selection" for the values round |
| 9 | Design: render + observability + ingest | Q6 + Q7 + Q8; practice the 3am-throughput-drop playbook aloud until it's a story, not a list |
| 10 | Rust: concurrency + channels | Sections 3.5/3.6; drills 4.3 #2 (LRU) and #6 (robots matcher); rapid-fire pass 2 — everything now < 60 s |
| 11 | Coding | Timed: LC 359, LC 1188 (or async-queue equivalent), LC 703 + 295, LC 208; then the two concurrency classics in Tokio form |
| 12 | Mock + gap-fix | Mock 2: Rust deep-dive + coding; every miss becomes an evening drill; polish questions-to-ask list (5.4) |
| 13 | Full-loop simulation | Back-to-back: 45-min design (Q1 or Q5), 45-min coding (4.3 drill, timed), 30-min behavioral (three STARs + "Why Exa?"), 15 min of your questions. Then logistics: environment, parking/transit, ID, water, timing |
| 14 | Taper | Light only: numbers cheat sheet (Appendix) read-through ×2, rapid-fire ×1, one STAR aloud. Prepare clothes/logistics. Sleep > studying — week-2 you is the product |
8. Appendix A: The Numbers Cheat Sheet (memorize cold)
| Quantity | Value | Derivation |
|---|---|---|
| Fetch rate @100M pages/day | ~1,160/s avg, ~3K/s peak | 10⁸ ÷ 86,400 |
| In-flight fetches | ~2,300 | Little's Law: 1,160 QPS × ~2 s |
| Ingress bandwidth | ~116 MB/s ≈ 1 Gbps avg | 1,160 × ~100 KB |
| Raw storage | ~10 TB/day; ~2.5 TB/day gzipped; ~0.9 PB/yr | 1,160 × 100 KB × 86,400 |
| Fetcher fleet | ~20–30 nodes | 2,300 in-flight ÷ 500–1,000 conns/node (+ headroom/partitioning) |
| Frontier state | ~20 TB sharded | 500B URLs × ~40 B (hash+meta) |
| Host state | ~32 GB | ~250M hosts × ~128 B |
| URL seen-set | 4 TB raw hashes → ~625 GB Bloom @1% FP | 500B × 8 B vs × ~10 bits |
| Content fingerprints | ~800 MB/day | 100M × 8 B SimHash |
| Politeness default | 1 req / 2–10 s per host → need ~2.3–11.5K active hosts for full rate | host diversity = throughput budget |
| Robots cache TTL | ~24 h, conditional GET | RFC 9309 + practice |
| DNS | ~1.2K lookups/s, >90% cache-hit at a local resolver | follows fetch rate |
| Render tier @5% | ~58 renders/s → ~175 contexts → 10–20 nodes; 10–50× static cost | 5M/day × ~3 s render |
| Text yield | ~1 TB/day | 100M × ~10 KB |
| Embeddings | ~300 GB/day | 100M × 768 f32 × 4 B |
| Parse CPU | ~600–2,300 cores | 100M × 0.5–2 ms |
| Tracing volume | 100M traces/day → sample 1% | cost sanity |
| Retry policy | 3 attempts, exp backoff base 1 s ×2 + full jitter, honor Retry-After, then DLQ |
— |
| Timeouts | connect 5 s / TTFB 10 s / total 30 s | per-stage, typed |
| Exhaustive recrawl of 500B | ~5,000 days | 500B ÷ 100M/day → why scheduling exists |
Appendix B: Reference links (all fetched/confirmed live 2026-09-11 unless marked)
- Crawler JD (SF): https://jobs.ashbyhq.com/exa/0298c908-e2ff-4dbb-a8cc-4899a9f6737a · (Singapore): https://jobs.ashbyhq.com/exa/9679c963-9d4b-4cfe-8bd9-45ab5d5e7b90
- Careers: https://exa.ai/careers · Blog index: https://exa.ai/blog · Comp: https://www.levels.fyi/companies/exa/salaries/software-engineer
- Founder framing: https://www.latent.space/p/exa · Scale claims: https://exa.ai/about, https://exa.ai/blog/announcing-series-c
- Engineering posts to cite in interviews: https://exa.ai/blog/how-to-build-nextgen-search · https://exa.ai/blog/exa-d · https://exa.ai/blog/building-web-scale-vector-db · https://exa.ai/blog/scaling-our-highlights-server · https://exa.ai/blog/bm25-optimization
- Design study: https://www.hellointerview.com/learn/system-design/problem-breakdowns/web-crawler · Mercator paper: https://marc.najork.org/papers/wwwmerc.pdf · IR book ch. 20: https://nlp.stanford.edu/IR-book/html/htmledition/web-crawling-and-indexes-1.html
- Crates named in this file:
governor,texting_robots,backoff,reqwest,bytes,bumpalo,dashmap,parking_lot,flume/crossbeam,thiserror,anyhow,wiremock,proptest,loom,tokio(JoinSet, Semaphore, CancellationToken viatokio-util) — docs.rs pages for each are linked in roadmap files 02–06. - Interview-process caveat: no public Exa loop reports found as of 2026-09-11 (Glassdoor/Bing/DDG checked; Glassdoor direct = 403; search API rate-limited). Section 1.2 is reconstruction — confirm with your recruiter.
12 — Public Signal & Community: Open Source, Writing, Networking
The application track (résumé, job boards, referral mechanics) lives in 08-exa-application-portfolio.md. This guide is the public-signal layer that makes that application land: merged PRs in repos Exa's crawl team reads, blog posts with reproducible numbers, and a network of people who recognize your name before your résumé arrives. Recruiters and hiring managers at infra startups like Exa screen for three things public signal can prove better than any résumé line: (1) you ship working systems, (2) you can communicate about systems in writing, (3) people in the ecosystem already vouch for you. Twelve weeks is enough to manufacture all three — if every artifact is real, reproducible, and aimed at crawl/search problems.
All links, repos, handles, and dates below were verified on 2026-09-11 (see footer). The 12-week window runs Sep 14 → Dec 6, 2026, aligned to the build timeline in guide 08.
1. Open-Source Contribution Targets
Ranked by signal density per hour for this specific role. The pattern that gets noticed: one ecosystem repo for depth + one exa-labs repo for proximity, not seven shallow PRs.
1.1 Contribution target table (all verified 2026-09-11)
| Repo | What it is | Size / activity | Entry points | Why Exa cares |
|---|---|---|---|---|
| spider-rs/spider | The production Rust web crawler (HTTP-first, Chrome fallback, streaming, distributed workers) | 2.7k★, 295 forks, MIT, single core maintainer (j-mendez) | Workspace crates: spider, spider_cli, spider_mcp, spider_worker, spider_utils; 50+ runnable examples/; CONTRIBUTING.md |
This is the job description in open source. A merged PR here is the single strongest ecosystem signal |
| exa-labs/exa-mcp-server | Exa's official MCP server for web search/crawling | ~5k★, TS, MIT, 26 open issues | Open issues tab; README/docs improvements; new tool examples | Direct line-of-sight: Exa employees review these PRs |
| exa-labs/exa-py / exa-labs/exa-js | Official SDKs | 233★ / 130★, 7 open issues each | Open issues, type fixes, examples | Shows API fluency with Exa's actual product surface |
| exa-labs/agent-skills | Official skills for the Exa API | ~48★, markdown-heavy | New/improved skill docs — the lowest-friction first PR into the org | Fast first merged PR in the org; get a feel for their review style |
| exa-labs/benchmarks | Open benchmarks for evaluating search APIs | Python, MIT | Add an eval case, fix harness bugs, reproduce results | Pair with your week-10 benchmark writeup (§2.2) — cite their harness and extend it |
| tokio-rs/tokio | The async runtime everything runs on | 33k★, 318 open issues | Labels E-easy / E-help-wanted (the GitHub /contribute page is currently empty — filter issues by label instead); docs fixes; Tokio Discord |
High scrutiny, slow merges. One small merged PR is a strong credibility marker; don't plan on more |
| quickwit-oss/tantivy | Rust full-text search engine (Lucene analog) | 16k★, 448 open issues, GitHub Discussions on | Issue labels + Discussions for design; you use tantivy in project (c) anyway (guide 07) | Contribution while building your search project: fix what you actually trip over |
| mattsse/chromiumoxide | Async CDP client for Rust | 1.4k★, 45 open issues, tokio-only | README explicitly: "PRs are very welcome if you think a meaningful command is missing a designated function"; PDL regeneration for new Chrome versions; chromiumoxide_fetcher fixes |
You use this (or the pattern) in the Phase-5 browser pool; a merged command wrapper is concrete and reviewable |
| firecrawl/firecrawl | Firecrawl — scrape/crawl/search API | 179k★, AGPL-3.0 (SDKs MIT), monorepo: apps/, skills/, firecrawl-cli |
CONTRIBUTING.md, docs, SDK fixes, self-host rough edges | Competitive-intel value: reading their apps/ layout teaches you how a commercial crawl pipeline is shaped |
| apify/crawlee | TS crawling framework (Apify) | 25.7k★, Apache-2.0, very active | CONTRIBUTING.md, good first issue label, Apify Discord, crawlee-python |
Cross-ecosystem fluency: Apify engineers are exactly the "crawler community" peers to know |
1.2 What to actually contribute (per repo)
spider-rs/spider (primary target, weeks 3–10).
- First PR (week 3–4): a new runnable example in
examples/or a docs fix you found while reading the source during Phase 3 (guide 01 already tells you to readspider/src/website.rs). Zero design risk, teaches you the CI. - Second PR (week 6–8): a real bug you hit running your own crawler at the 10k-domain scale test (guide 08 week 3) — reproduce minimally, file the issue first, ask in the Spider Discord whether the maintainer wants the fix upstream, then PR with a regression test.
cargo test -p spidermust pass; live tests run behindRUN_LIVE_TESTS=1. - Stretch (week 9–11): a scoped improvement to
spider_mcporspider_workerdiscussed in Discord first. Small core team = unannounced large PRs die quietly. Announce intent, get a "yes, that shape works," then write code.
exa-labs repos (proximity target, weeks 4–11).
- Start with
agent-skills(docs/skill improvement — trivial review surface) to get one green merge in the org. - Then one substantive PR: pick from the open issues on
exa-mcp-server(26 open as of today),exa-pyorexa-js(7 each). Prefer a bug with a repro over a feature. Read existing merged PRs first to calibrate diff size and test expectations. - If your week-10 benchmark uses search APIs, reference/reproduce something in
exa-labs/benchmarksand PR any harness fix you needed — that makes your blog post and your PR cite each other.
tokio (credibility target, opportunistic). Only take issues labeled E-easy/E-help-wanted that you can finish in ≤3 evenings (docs, examples, test flakes). Ask in Tokio Discord before starting anything nontrivial. Expect multi-round review from people who wrote the runtime; that review itself is the credential.
tantivy (parallel with project (c), weeks 8–9). Contribute what you trip over while building your index/eval work: confusing docs, a missing method on an aggregator, a panic you can minimize. Design questions go to GitHub Discussions before a PR.
chromiumoxide (parallel with the browser pool, weeks 5–7). The README names the exact contribution shape: missing Page/Element command wrappers around generated chromiumoxide_types::Commands. Other live options: regenerate PDL bindings for current Chrome, fix chromiumoxide_fetcher platform gaps, add an example mirroring your pool's session-restart logic.
firecrawl / crawlee (ecosystem fluency, week 6+ only if time). Read more than you PR. One docs or SDK fix in each is enough to speak credibly about them. Note firecrawl is AGPL-3.0 (SDKs MIT) — fine to contribute, just know what you're signing; crawlee has a proper good first issue pipeline and a responsive Discord.
1.3 How to get merged (universal playbook)
- Reproduce first, talk second. An issue with a minimal repro (
main.rs+ exact command + expected/actual) gets maintainer attention; a feature pitch from a stranger doesn't. - Announce intent in the project's own channel (Discord/Discussions/issue comment) and wait for one maintainer ack. This is what separates merged PRs from orphaned ones.
- Keep the first PR stupidly small. <100 lines of non-test diff. Your goal is to learn their CI, review style, and latency — not to impress with scope.
- Match their test style. spider:
cargo test -p spider; crawlee: vitest + their husky hooks; tokio: loom tests where applicable. A PR whose tests look like the existing tests merges 5x faster. - Respond to review within 24h, even just "will address this weekend." Maintainers triage by responsiveness.
- Never let a PR be your first contact. Comment on two issues/PRs of theirs first so your name is familiar when the PR lands.
1.4 How this becomes reputation recruiters notice
- GitHub surfaces merged PRs on your profile and in the repo's contributor list — Exa engineers browsing
exa-labspulse pages will literally see your handle. - After 2+ merges in a repo, ask (once, politely) for a review of your project README/design in their Discord — maintainers love being asked as experts, and now a maintainer of a known crawler has engaged with your work.
- Reference merges in the application: "Contributor to spider-rs/spider (#NNN, #MMM)" beats "familiar with Rust crawling" by an order of magnitude.
- Cross-link: blog post (§2) benchmarks your crawler against/with spider; the spider Discord sees it; j-mendez is one degree from every Rust-crawl hiring manager.
2. Writing Plan
Six artifacts total. Each is tied to a build artifact from guide 08's timeline so writing is reporting, not extra work.
2.1 The four technical blog posts
Post 1 — "Building a polite, resumable web crawler in Rust" (publish end of week 4)
- Outline: the problem (why politeness is a feature, not etiquette) → RFC 9309 robots parsing → per-host token buckets → 429/5xx backoff with jitter → checkpoint/resume design → failure modes (traps, retry storms) → 10k-domain run results table → what's next.
- Signal: matches Phase 4 content (guide 04) and Exa's stated values (politeness, reliability).
- Promotion: dev.to (
#rust,#webdev), X thread with the backoff-state diagram, r/rust only if it includes real numbers.
Post 2 — "HTTP-first, browser-second: how I cut crawl cost ~10× in Rust" (publish week 6)
- Outline: the cost math (headless Chrome ~100–300 MB RSS/instance vs reqwest) →
lol-htmlstreaming extraction → escalation heuristics (what signals a page needs JS) → per-path cost/latency instrumentation → measured split of pages that actually needed rendering. - Signal: cost discipline at crawl scale is a core Exa concern; the "20–30% of pages need rendering" claim (guide 08 scaling math) with your own measurement is quotable.
- Promotion: dev.to + personal site, X thread with the cost chart, Exa Discord (sharing data, not asking for anything).
Post 3 — "Running a headless-Chrome pool from Rust without it falling over" (publish week 7–8)
- Outline: why CDP over Puppeteer-style wrappers → chromiumoxide architecture (handler task, generated types) → pool sizing, browser health checks, restarts → what breaks at 50+ concurrent tabs → when I'd use spider's managed path instead.
- Signal: browser fleet operations are the unglamorous core of the job; almost nobody writes honestly about the failure modes.
- Promotion: r/rust (Rust-heavy content performs well there), dev.to, HN only if you have strong failure-mode data.
Post 4 — "From crawl to search: hybrid retrieval over my own corpus with tantivy" (publish week 9)
- Outline: corpus from your own crawl → BM25 in tantivy → embeddings + fusion → building a 200-query eval set → NDCG@k / recall / latency numbers → incremental-indexing experiment results.
- Signal: connects crawling to retrieval — Exa's whole business. Cite Exa's public evals page (exa.ai/evals) and
exa-labs/benchmarkswhere relevant. - Promotion: dev.to, X, r/rust, cross-link from the benchmark repo.
2.2 The flagship benchmark writeup (publish week 10)
"Benchmarking a single-node Rust crawler: throughput, politeness, and cost per page"
- Contents: methodology (hardware, network, seed set, duration), sustained pages/sec, per-host concurrency proof (politeness audit from your own logs), bytes/page, cost/page at stated egress prices, failure-rate breakdown, comparison against at least one reference (spider default config, or a naive asyncio baseline), raw JSON + harness repo + CI regression gate (guide 08 week 10 artifact).
- Rules: every number reproducible from the repo; publish raw data; state what you did not measure. One unverifiable claim poisons the whole artifact.
- This is the one artifact you push hardest: Show HN, r/rust, This Week in Rust submission, Exa Discord, and it's the link in every outreach message (§5).
2.3 The architecture deep-dive (publish week 11, with the application)
"Anatomy of a web-scale crawl: designing a distributed crawler that survives 3 a.m."
- Contents: single-node → fleet (partitioned frontier, at-least-once delivery, idempotent writes, checkpointing), backpressure, observability (queue depth, per-domain health, traces), the incident story from your own week-11 hardening, honest "what I didn't build."
- Format: long-form on your personal site, one strong architecture diagram, links to the repo and the benchmark.
- Purpose: this is the piece an interviewer reads the night before your call. It is written for Exa's crawl team as the audience.
2.4 Where to publish: dev.to vs Hashnode vs Medium vs personal site
| Platform | Audience you get | SEO/canonical | Verdict |
|---|---|---|---|
| Personal site | ~zero by itself, but it's yours; interviewers will read it | Canonical source; long-term compounding | Always the canonical home. Every post lives here first |
| dev.to | Real built-in Rust/dev audience; #rust tag is active |
Supports canonical_url — import via RSS/API so Google credits your site |
Cross-post everything here. Best effort-to-visibility ratio |
| Hashnode | Similar to dev.to, smaller dev-infra crowd | Also supports canonical links | Optional second cross-post; skip if time is tight |
| Medium | Paywall friction, weak dev-infra discovery | Canonical supported but audience mismatch | Skip |
Setup (week 1, alongside guide 08's "personal site + repo template"): static site (any generator), RSS feed, yoursite.dev/crawler landing page, dev.to import wired with canonical URLs. Analytics: any lightweight option — you need view counts for §6.
2.5 Getting visibility on Hacker News and r/rust
Hacker News (news.ycombinator.com):
- Use Show HN for the week-10 benchmark (it's a thing people can run — that qualifies; a pure blog post usually doesn't). Read the Show HN guidelines first.
- Post weekday ~8–10am ET. Title: factual, no clickbait, e.g. "Show HN: Benchmarking a single-node Rust web crawler (10k domains, raw data included)".
- Be the first commenter with the build story, key numbers, and honest limitations. Reply to every substantive comment for 24h. Never ask anyone to upvote, never post the link to friends/Discords asking for votes — ring detection kills posts and accounts.
- Expect one of three outcomes: front page (rare, great), 5–30 points with good comments (normal, still useful — quote the comments), or no traction (post again in 4+ weeks with a genuinely new artifact; that's allowed and normal).
r/rust (reddit.com/r/rust):
- Original Rust project content is welcome; blog posts about your Rust project are fine. Pure self-promo of non-Rust content gets removed/downvoted.
- Post text submissions or link posts with you actively answering comments; the community rewards "here's the repo, here's what I learned, roast it" and punishes drive-by links.
- Cross-post to r/webscraping only the crawl-mechanics posts (posts 1–2), never anything that smells like bot evasion (guide 04's warning applies in public too).
Other levers:
- This Week in Rust (this-week-in-rust.org): submit posts 1, 3, and the benchmark via PR to their repo — one inclusion is thousands of qualified readers.
- X/Twitter: one thread per post (chart-first), tag no one you don't know; reply-guy thoughtfully on crawl/Rust threads instead (§3).
- lobste.rs: invitation-based; if a contact from networking can submit your benchmark, that's high-quality traffic.
- Exa Discord: share in whatever showcase/general channel exists, framed as "data from my crawl, might be useful," not self-promo. Employees are present (guide 08).
3. Networking Targets & Channels
3.1 X/Twitter — follow and engage (handles verified 2026-09-11)
| Handle | Who | How to engage |
|---|---|---|
| @ExaAILabs | Exa official | Follow; note product/research launches to reference later |
| @WilliamBryk | Will Bryk, Exa co-founder/CEO | Reply substantively to search/retrieval threads — data > compliments |
| @jeffzwang | Jeff Wang, Exa co-founder | Posts crawl/search product thinking; a sharp technical reply here is worth 10 cold emails |
| @rustlang | Rust project | Releases/edition news — reshare context for your posts |
| @tokio_rs | Tokio | Ecosystem pulse; your async content fits replies here |
| @ThisWeekInRust | TWiR | Amplification once you're included |
| @jonhoo | Jon Gjengset | Rust systems education; his audience overlaps yours |
| @fasterthanlime | Amos Wenger | Long-form Rust performance culture |
| @matthiasendler | Matthias Endler (corrode, Rust in Production podcast) | Podcast guest material is exactly your week-10 benchmark |
| @apify | Apify/Crawlee | Crawl-industry conversations, Crawlee release notes |
Engagement rule: three substantive replies per week (a number, a repro, a counterexample, a question the author can answer) for eight weeks before you ever DM anyone. Never reply with your own link unless it directly answers the question asked.
Note: Exa's engineering team (Tyler Killian on crawling systems, Hubert Yuan, Ben Chen and others — see exa.ai/about) is most reachable through the Exa Discord and GitHub, not cold DMs; individual handles aren't listed because they weren't publicly verified today.
3.2 Discords / Slacks / forums (all verified)
| Community | Link | Use |
|---|---|---|
| Exa Discord | discord.gg/HCShtBqbfV | Highest value. Ask real crawl/search questions, share benchmark data, be a regular by week 6 |
| Spider Discord | discord.spider.cloud | Coordinate spider contributions; crawler-design feedback from j-mendez |
| Tokio Discord | discord.gg/tokio | Async help; contribution triage |
| Official Rust Discord | discord.gg/rust-lang | #help, #showcase-style visibility |
| Apify (Crawlee) Discord | discord.gg/jyEM2PRvMU | The largest working-crawler community; cross-ecosystem peers |
| Common Crawl Discord | discord.gg/njaVFh7avF | Web-scale corpus sourcing for benchmarks |
| users.rust-lang.org | users.rust-lang.org | Searchable, durable Q&A reputation |
3.3 Meetups & conferences (dated against the 12-week window)
| Event | When/where | In window? | Action |
|---|---|---|---|
| RustConf 2026 | Sep 8–11, Montreal + online | Ends today (Sep 11) | Watch recordings as they post; mine 2–3 talks for blog-post citations and follow speakers |
| EuroRust 2026 | Oct 14–17, Barcelona + online | Yes — falls in plan week 5 | Attend online (or in person if EU-feasible); workshop day + "impl day" are contribution-forging events; live-tweet/post notes; this is your one in-window Rust conference |
| AI Engineer World's Fair | SF, annual (2026 edition passed) | No, but | Exa presents here (guide 08); watch talks, engage organizers/speakers on X; target the next edition in person |
| Rust Nation UK | Feb 18, 2027, London | After window | Submit a CFP talk in late 2026 — "benchmarking a Rust crawler at home" is a real talk; acceptance lands right at interview time |
| Local meetups | SF: search meetup.com for Rust Bay Area / SF Rust; also AGI House events (guide 08) | Ongoing | Exa is in-person in SF's Mission District; if you're local or can visit once, one good hallway conversation beats 50 DMs |
| HN "Who is hiring" | Monthly, news.ycombinator.com/submitted?id=whoishiring | Oct 1, Nov 2, Dec 1 threads | Comment/apply where AI-infra crawl roles appear; reference your public work |
4. Week-by-Week Public-Signal Calendar
Aligned to the 12-week build/presentation timeline in guide 08 (build column is abbreviated here; bold = the public-signal action that ships that week). Assumes week 1 starts Mon Sep 14, 2026; application in week 11 (~Nov 23–29); window ends ~Dec 6.
| Week | Dates (2026) | Build artifact (guide 08) | Public-signal actions |
|---|---|---|---|
| 1 | Sep 14–20 | Crawler core + metrics scaffold | Ship personal site + repo template. Join Exa + Spider + Rust Discords; introduce yourself once with a project link, no ask. Set up dev.to canonical import |
| 2 | Sep 21–27 | Politeness engine | README v1 with architecture diagram public. Start weekly "show your work" posts (§5.5). First 3 substantive X replies |
| 3 | Sep 28–Oct 4 | Scale-out + 10k-domain run | First PR to spider-rs/spider (example or docs, announced in Discord first). Résumé v1 (08) |
| 4 | Oct 5–11 | Benchmarks + demo | Publish Post 1 (polite crawler) + pin repo. First exa-labs PR (agent-skills docs fix). Weekly X replies continue |
| 5 | Oct 12–18 | HTTP path hardened; escalation heuristics | EuroRust Oct 14–17 (online): attend, post session notes, connect with 3 attendees. File the real spider bug you hit at 10k domains |
| 6 | Oct 19–25 | CDP renderer pool | Publish Post 2 (HTTP-first cost math). Demo video on repo. Second spider PR (bug fix + test) |
| 7 | Oct 26–Nov 1 | Browser cost benchmarks | chromiumoxide PR (missing command wrapper you needed in the pool). Exa Discord: share browser-pool cost data |
| 8 | Nov 2–8 | Index build (tantivy) | Publish Post 3 (Chrome pool operations). First tantivy contribution from whatever you tripped over. HN "Who is hiring" Nov thread scan |
| 9 | Nov 9–15 | Evals + NDCG | Publish Post 4 (crawl→search). Submit Posts 1+3 to This Week in Rust. Ask one maintainer for README/design feedback |
| 10 | Nov 16–22 | Benchmark hub + CI gate | Publish flagship benchmark + Show HN (weekday morning; spend the day in comments). r/rust post. Personal-site landing page |
| 11 | Nov 23–29 | Polish, videos | Publish architecture deep-dive. Apply to Exa (Ashby) + outreach day 1 (email/X/LinkedIn, §5). Second exa-labs PR merged by now |
| 12 | Nov 30–Dec 6 | Interview prep | Follow-up touches (day 5, day 12 per 08). Apply to top-5 backups. Rust Nation UK 2027 CFP submission. Retrospective post scheduled |
Steady-state every week: 3 substantive X replies, 2 helpful Discord answers, 1 "show your work" post. That's the drumbeat the calendar hides.
5. Templates
Replace bracketed sections. Keep everything short — busy engineers decide in 15 seconds.
5.1 Cold email (to an Exa engineer or hiring manager)
Subject: Rust crawler benchmark + question about [specific Exa thing]
Hi [Name],
I found your [talk/blog post/PR] on [specific thing] while building a
distributed web crawler in Rust — your point about [specific detail]
matched what I measured almost exactly.
Context: 12 weeks ago I started building a politeness-first crawler
(robots/RFC 9309, per-host token buckets, checkpoint/resume). Last week I
published reproducible benchmarks: [1–2 headline numbers, e.g. "X pages/sec
sustained over 10k domains at Y ms median fetch, full harness + raw JSON
public"]. Link: [URL]. The writeup: [URL].
I'm applying to Exa's [role] this month. Would you have 15 minutes to tell
me what the crawl team's hardest scheduling/reliability problem looks like
right now? Not asking for a referral — just trying to aim my prep. Happy
to share anything from the benchmark data that's useful either way.
[Name]
[GitHub] · [site] · [phone optional]
Rules: one specific reference to their work, one headline number, one small ask, no attachments, no résumé on first touch.
5.2 X DM (only after weeks of public replies)
Hey [Name] — been following your posts on [topic] for a while (the
[specific] thread changed how I did [thing] in my crawler).
I just published benchmarks from a 10k-domain Rust crawl — [one number].
Thought you might find the politeness-audit section interesting given your
point about [specific]: [link]
No ask — just closing the loop. (Separately: I'm applying to crawl/search
roles this month; if you ever have 15 min, I'd love to hear what
surprised you most about [their system].)
5.3 LinkedIn connection request (300-char limit)
Hi [Name] — I build Rust crawling infra and just published reproducible
10k-domain crawler benchmarks ([site]). Found you via your work on
[specific]. Would love to follow your work on [team/product]; no ask,
just connecting with crawl/search engineers.
LinkedIn is for logistics (finding 2nd-degree paths, recruiter visibility); do the real relationship-building on X/Discord/GitHub.
5.4 GitHub README structure (the crawler repo)
# crawler-name
One line: what it does + headline number. ("Politeness-first web crawler in
Rust; X pages/sec sustained over 10k domains, per-host rate audits included.")
[badges: CI, license, rust version]
## Demo
Asciinema/Loom GIF or 2-min video, <30s to the interesting part.
## Quickstart
3 commands to run it. Docker one-liner if possible.
## Architecture
One diagram. 5 bullets: frontier, fetch, politeness, storage, observability.
Link to ARCHITECTURE.md for the deep-dive.
## Benchmarks
Methodology in 3 lines + results table + link to full writeup + raw JSON.
## Design decisions
3–6 honest bullets: what you chose, what it costs, what you'd redo.
## Roadmap / limitations
Explicit "not built yet" list — reviewers trust it more than claims.
## Development
cargo test commands, CONTRIBUTING pointer, license.
5.5 Weekly "show your work" post format (X/dev.to short log)
Week [N] of building a web-scale crawler in Rust 🧵
Built: [1 concrete thing, with a link to the commit/PR]
Measured: [1 number — throughput, p99, failure rate, cost]
Broke: [1 honest failure + what fixed it]
Learned: [1 sentence — the kind you'd want to read]
Next: [1 thing]
Repo: [link]
Why this format: numbers + honesty get engagement from exactly the engineers you want; vague progress posts get nothing. 12 of these = a public work log a hiring manager can scan in two minutes.
6. Metrics & Anti-Patterns
6.1 Metrics to track (weekly, 15 minutes, one spreadsheet)
| Metric | Source | Healthy week-12 target | What it's actually for |
|---|---|---|---|
| GitHub stars/forks on crawler repo | Repo insights | 50–200 (benchmark post spike) | Proxy for artifact quality |
| Repo traffic (views, clones) | GitHub traffic tab | Spike after each post; clones = engineers actually running it | Strongest "real interest" signal |
| Merged PRs, per repo | Profile | spider ×2, exa-labs ×2, chromiumoxide ×1, +1 wildcard | The résumé lines |
| Blog views per post | dev.to + site analytics | Post 1: 500–2k; benchmark: 5k+ if HN bites | Writing reach |
| HN points/comments | Submission | >20 points + real comments on the benchmark | Quote good comments in interviews |
| This Week in Rust inclusion | TWiR issues | 1+ | Ecosystem validation |
| X followers / thread impressions | X analytics | +200–500 relevant followers over 12 weeks | Distribution for launches |
| Discord reputation | Self-assessed: do people answer you? | Recognized regular in 2 communities | Warm-path access |
| Outreach reply rate | Sent/replied log | >30% on emails/DMs with templates above | Message quality; iterate if <20% |
| Referrals / recruiter inbounds | Count | 2+ by week 12 | The actual goal of all of the above |
Leading > lagging: replies-per-post and review-response-time on your PRs predict stars better than stars predict anything.
6.2 Anti-patterns (all of these are seen constantly and all backfire)
- Star-begging and vote rings. "Star my repo if…" in READMEs/posts, posting your own HN link to Discords for upvotes. Detectable, reputation-destroying, permanent on HN.
- One link, ten places, same day. Posting the identical link to r/rust, r/webscraping, HN, three Discords, and X within an hour reads as spam. Stagger by days; tailor the framing per venue.
- Drive-by PRs. A README typo PR to five famous repos in one week. Maintainers check profiles; a pattern of trivial PRs is worse than none. Small PRs are for learning one repo's process, not collecting logos.
- PR before contact. Opening a 400-line feature PR in spider or tantivy with no prior issue/Discord discussion. It will sit unreviewed and mark you as someone who doesn't collaborate.
- AI-slop blog posts. Polished, number-free posts that could be written without building anything. The entire point is measurements from a system only you ran. If a post has no chart from your own logs, don't publish it.
- Unverifiable benchmarks. Numbers without harness, hardware specs, raw data, or error bars. One "this doesn't reproduce" comment on HN ends the artifact's usefulness.
- Anti-bot/evasion content. Stealth plugins, fingerprint spoofing, CAPTCHA solving as portfolio content (guide 04). Exa hires for politeness and reliability; evasion talk also gets you moderated out of r/webscraping-style communities.
- Broadcasting asks. First-touch DMs that ask for referrals/jobs. The templates above work because eight weeks of replies made your name familiar first.
- Metric worship. Optimizing stars over the two numbers that matter for this role: merged PRs in relevant repos, and warm conversations with crawl engineers.
- Silence after launch. Posting Show HN and disappearing; not answering PR reviews for a week. The first 24h of any launch is the launch.
- Applying cold anyway. Sending the Ashby application before the public artifacts exist (guide 08's week-11 timing). A referral armed with a benchmark link converts; a cold PDF doesn't.
All repositories, star counts, issue counts, handles, Discord invites, and conference dates verified against live pages on 2026-09-11: github.com/exa-labs (107 repos incl. exa-mcp-server, exa-py, exa-js, agent-skills, benchmarks), spider-rs/spider (2.7k★), tokio-rs/tokio (33k★, 318 issues), quickwit-oss/tantivy (16k★, 448 issues), mattsse/chromiumoxide (1.4k★, 45 issues), firecrawl/firecrawl (179k★, AGPL-3.0), apify/crawlee (25.7k★); exa.ai footer (X @ExaAILabs, Discord discord.gg/HCShtBqbfV, LinkedIn, GitHub); x.com/jeffzwang (Exa cofounder) and x.com/WilliamBryk; discord.spider.cloud; discord.gg/jyEM2PRvMU (Crawlee/Apify README); rustconf.com (Sep 8–11, Montreal); eurorust.eu (Oct 14–17, Barcelona + online); rustnationuk.com (next: Feb 18, 2027, London). Re-verify before relying on any single number — repos and rosters move fast.
Toolchain & Crate Versions — Verified September 2026
Verified 2026-09-11 against crates.io API, docs.rs, reqwest/sqlx changelogs, and the Rust release blog. This file is the single source of truth for versions through late 2026. Files 01–08 were NOT modified; genuine discrepancies found during the audit are listed in the corrections table at the bottom (spoiler: very few — the guides' "verified September 2026" snapshots checked out).
Recommended Rust: 1.98.1 (stable, released 2026-09-03). Local machine at audit time had 1.98.0 — run rustup update stable to pick up the vtable miscompilation fix. Edition: 2024 (default for cargo new since Rust 1.85).
1. Verified current versions (as of 2026-09-11)
All versions below were read from https://crates.io/api/v1/crates/<name> on 2026-09-11. "Updated" = last publish date on crates.io. Pin the version = strings shown; they are compatible with each other in one workspace (tested combination in §2).
Required list (from the task spec)
| Crate | Verified latest | Updated | Notes / source |
|---|---|---|---|
| tokio | 1.53.1 |
2026-07-20 | tokio = "1" — full features only where needed. https://crates.io/crates/tokio |
| reqwest | 0.13.5 |
2026-09-08 | 0.13 is the current major; see breaking notes §5. https://crates.io/crates/reqwest |
| scraper | 0.27.0 |
2026-05-11 | Depends on html5ever ^0.39 (see §6). https://crates.io/crates/scraper |
| serde | 1.0.229 |
2026-07-18 | serde = { version = "1", features = ["derive"] }. https://crates.io/crates/serde |
| serde_json | 1.0.151 |
2026-07-20 | https://crates.io/crates/serde_json |
| url | 2.5.8 |
2026-01-05 | WHATWG, stable for months. https://crates.io/crates/url |
| thiserror | 2.0.20 |
2026-08-08 | 2.x line; #[derive(Error)]. https://crates.io/crates/thiserror |
| anyhow | 1.0.104 |
2026-07-18 | Binaries/glue only. https://crates.io/crates/anyhow |
| tracing | 0.1.44 |
2025-12-18 | https://crates.io/crates/tracing |
| sqlx | 0.9.0 |
2026-05-21 | Repo moved launchbadge → transact-rs (redirect confirmed); crates.io metadata still says launchbadge. Breaking changes in §5. https://github.com/transact-rs/sqlx |
| rusqlite | 0.40.2 |
2026-08-08 | Bundled SQLite; pairs with deadpool-sqlite 0.14. https://crates.io/crates/rusqlite |
| governor | 0.10.4 |
2025-12-16 | GCRA rate limiter; DefaultKeyedRateLimiter for per-host buckets. https://crates.io/crates/governor |
| backoff | 0.4.0 |
2021-12-14 | Stable but untouched since 2021; prefer backon for new code (below). https://crates.io/crates/backoff |
| reqwest-retry | 0.9.1 |
2026-02-05 | Middleware (reqwest-middleware 0.5.2). Prefer built-in reqwest::retry for protocol NACKs; layer this for 429/503 status policies. https://crates.io/crates/reqwest-retry |
| texting_robots | 0.2.2 |
2023-03-29 | No release in 3+ years but still the best-maintained Rust robots parser and the guide's recommendation stands. https://crates.io/crates/texting_robots |
| blake3 | 1.8.7 |
2026-08-20 | Content hashing. https://crates.io/crates/blake3 |
| wiremock | 0.6.5 |
2025-08-24 | Async mock server; sequence responders (up_to_n_times) need ≥0.6. https://crates.io/crates/wiremock |
| criterion | 0.8.2 |
2026-02-04 | Canonical repo is now criterion-rs/criterion.rs (bheisler URL still resolves). https://crates.io/crates/criterion |
| proptest | 1.11.0 |
2026-03-24 | https://crates.io/crates/proptest |
| chromiumoxide | 0.9.1 |
2026-02-25 | Tokio-only async CDP. https://crates.io/crates/chromiumoxide |
| fantoccini | 0.22.1 |
2026-02-28 | WebDriver client (needs chromedriver/geckodriver). https://crates.io/crates/fantoccini |
| headless_chrome | 1.0.22 |
2026-06-11 | Sync CDP; crate name uses underscore, headless_chrome = "1". https://crates.io/crates/headless_chrome |
| tantivy | 0.26.2 |
2026-09-08 | tantivy = "0.26". https://crates.io/crates/tantivy |
| fastembed | 6.0.3 |
2026-09-07 | fastembed = "6" (ONNX, local). https://crates.io/crates/fastembed |
| qdrant-client | 1.19.0 |
2026-08-04 | gRPC on port 6334. https://crates.io/crates/qdrant-client |
| metrics | 0.24.6 |
2026-05-13 | Facade. https://crates.io/crates/metrics |
| opentelemetry | 0.32.0 |
2026-05-08 | With opentelemetry-otlp 0.32.0, opentelemetry-sdk 0.32.1. https://crates.io/crates/opentelemetry |
Every other crate mentioned across files 01–08
| Crate | Verified latest | Updated | Notes |
|---|---|---|---|
| dom_query | 0.28.0 |
2026-05-18 | scraper alternative (:has, :contains, markdown). |
| dom_smoothie | 0.18.1 |
2026-09-07 | Guide says "0.18" — compatible; use "0.18". |
| lol_html | 3.0.1 |
2026-07-29 | Streaming parse; needs reqwest stream + bytes_stream. |
| html5ever | 0.40.0 |
2026-09-11 | Released today; scraper 0.27 pins ^0.39 — do NOT bump directly (see §6). |
| mime | 0.3.17 |
2023-03-20 | Content-Type filtering. |
| async-compression | 0.4.46 |
2026-09-09 | Manual pipelines only; reqwest handles the normal case. |
| fastbloom | 0.17.0 |
2026-03-01 | AtomicBloomFilter for pre-fetch URL dedup. |
| sha2 | 0.11.0 |
2026-03-25 | Only for interop (Browsertrix/SHA-256); default to blake3. |
| quick-xml | 0.42.0 |
2026-08-22 | Sitemap parsing. |
| feed-rs | 2.4.0 |
2026-07-07 | RSS/Atom discovery channel. |
| http-cache-reqwest | 1.0.0-alpha.9 |
2026-09-09 | Still alpha — study/careful use, not a default dep. |
| deadpool-sqlite | 0.14.0 |
2026-08-26 | Async pool over rusqlite for Tokio tasks. |
| hickory-resolver | 0.26.3 |
2026-09-10 | DNS caching; reqwest feature is hickory-dns. Old trust-dns name is gone in reqwest 0.13. |
| moka | 0.12.16 |
2026-08-09 | TTL/LRU for robots/DNS/limiter state. |
| leaky-bucket | 1.1.2 |
2024-05-22 | Simpler alternative to governor. |
| redis (redis-rs) | 1.7.0 |
2026-09-05 | Streams (XADD/XREADGROUP/XACK/XAUTOCLAIM). |
| async-nats | 0.50.0 |
2026-07-20 | Official Tokio NATS/JetStream client. |
| rdkafka | 0.39.0 |
2026-01-25 | Production Kafka client (librdkafka). |
| rskafka | 0.6.0 |
2025-03-20 | Pure-Rust Kafka; no consumer groups (you own offsets). |
| aws-sdk-sqs | 1.109.0 |
2026-09-04 | AWS SDKs release near-daily; re-check at build time. |
| rendezvous-hash | 0.3.0 |
2019-10-04 | Ancient but correct; HRW needs no updates. |
| hashring | 0.3.6 |
2024-08-01 | Only if you specifically want ring semantics. |
| simplehash | 0.1.3 |
2025-04-01 | RendezvousHasher + FNV/Murmur. |
| backon | 1.6.0 |
2025-10-18 | Recommended retry builder (ExponentialBuilder, jitter, when()). |
| metrics-exporter-prometheus | 0.18.3 |
2026-04-30 | One-liner scrape endpoint. |
| tracing-subscriber | 0.3.23 |
2026-03-13 | EnvFilter + fmt/JSON layers. |
| tracing-opentelemetry | 0.33.0 |
2026-05-18 | Tracing → OTel bridge. |
| opentelemetry-otlp | 0.32.0 |
2026-05-08 | OTLP exporter. |
| opentelemetry-sdk | 0.32.1 |
2026-05-26 | SDK (traces/metrics/logs). |
| console-subscriber | 0.5.0 |
2025-10-30 | Guide's 02 snippet pins 0.4 — bump to "0.5" (see §6). Still needs RUSTFLAGS="--cfg tokio_unstable". |
| tokio-console (CLI) | 0.1.14 |
2025-10-30 | cargo install --locked tokio-console. |
| tokio-util | 0.7.19 |
2026-07-21 | CancellationToken, TaskTracker. |
| tokio-stream | 0.1.19 |
2026-07-22 | Stream wrappers over channels. |
| tokio-retry | 0.3.2 |
2026-06-09 | Revived 2026 (0.3.0 was 2021); small Tokio-native retry combinator. |
| futures / futures-util | 0.3.34 |
2026-08-11 | buffer_unordered, StreamExt. |
| dashmap | 6.2.1 (stable) |
2026-05-17 | Do NOT use 7.0.0-rcX (stale 2025 pre-releases). Pin dashmap = "6". |
| tower | 0.5.3 |
2026-01-12 | Timeout/retry/rate-limit middleware. |
| tower-resilience | 0.13.0 |
2026-08-22 | Circuit breaker, bulkhead, retry budgets, hedge. GitHub: joshrotenberg/tower-resilience. |
| clap | 4.6.6 |
2026-08-06 | CLI parsing (v4 line). |
| axum | 0.8.9 |
2026-04-14 | POST /search API layer. |
| divan | 0.1.21 |
2025-04-10 | Attribute-macro microbenchmarks. |
| iai-callgrind | 0.16.1 |
2025-07-30 | Instruction-count benches for noisy CI. |
| insta | 1.48.0 |
2026-06-11 | Snapshot tests; cargo insta review. |
| rstest | 0.27.0 |
2026-09-06 | Table-driven #[case] tests. |
| pretty_assertions | 1.4.1 |
2024-09-15 | Readable assertion diffs. |
| trybuild | 1.0.121 |
2026-09-08 | Compile-fail tests. |
| lancedb | 0.38.0 |
2026-08-31 | Embedded vector DB over Lance. |
| usearch | 2.26.2 |
2026-08-31 | In-process HNSW. |
| text-splitter | 0.32.0 |
2026-06-16 | TextSplitter/MarkdownSplitter/CodeSplitter. |
| rust-stemmers | 1.2.0 |
2019-11-17 | Stable for years; what Tantivy uses. |
| bm25 | 2.3.2 |
2025-09-07 | Tiny standalone BM25 for reading the math. |
| readability / readabilityrs | 0.3.0 / see docs.rs |
2023-12-20 | Older ports; prefer dom_smoothie for new code. |
| ort | 2.0.0-rc.13 |
2026-07-28 | Still release-candidate line; you get it transitively via fastembed — don't pin directly unless customizing. |
| candle-core | 0.11.0 |
2026-06-26 | Use when you want model internals instead of ONNX. |
| spider | 2.53.9 |
2026-09-05 | Reference crawler (read first, depend later). |
| thirtyfour | 0.37.5 |
2026-08-12 | WebDriver client if WebDriver is a hard requirement. |
| playwright-rs | 0.18.0 |
2026-09-09 | Crate name is playwright-rs (repo padamson/playwright-rust, import playwright_rs). Do NOT use the dead playwright 0.0.20 crate (2022, octaltree lineage). |
| reqwest-middleware | 0.5.2 |
2026-05-19 | Underpinning for reqwest-retry. |
| hyperfine | 1.20.0 |
2025-11-18 | Whole-binary benchmarks. |
| oha | 1.16.0 |
2026-08-23 | HTTP load testing (-c 50 -z 30s). |
| pprof | 0.14.1 (latest publish; 0.15.0 also exists) |
2025-07-23 | Ambiguous: highest semver is 0.15.0 (2025-05-27) but most recently published is 0.14.1. Pin pprof = "=0.14.1" or verify before upgrading. |
| samply | 0.13.1 |
2025-02-01 | Sampling profiler; best macOS story. |
| httpmock | 0.8.3 |
2026-02-04 | Sync+async mocking alternative. |
| mockito | 1.7.2 |
2026-02-02 | Simple blocking mock server. |
| pgvector (pgvector-rust) | 0.4.2 |
2026-05-22 | Postgres vector bridge. |
| robotstxt | 0.3.0 |
2021-02-13 | Older alternative parser; diff against texting_robots only. |
| select | 0.6.1 |
2025-03-19 | Effectively legacy (html5ever 0.26); do not start new code on it. Guide's note verified correct. |
| kuchiki | 0.8.1 |
2020-08-05 | Explicitly unmaintained. Do not use. Guide's note verified correct. |
| snafu | 0.9.2 |
2026-07-21 | Alternative error style. |
2. Copy-paste Cargo.toml starter stack (crawler project)
Verified-compatible set as of 2026-09-11. Edition 2024, Tokio + reqwest 0.13 + scraper + frontier + observability. Trim per phase (don't pull browser/search deps in Phase 3).
[package]
name = "crawlkit"
version = "0.1.0"
edition = "2024"
rust-version = "1.86" # floor: sqlx 0.9 MSRV; toolchain §3 pins 1.98.1
[dependencies]
# --- async runtime ---
tokio = { version = "1.53", features = ["macros", "rt-multi-thread", "time", "sync", "fs", "signal"] }
tokio-util = { version = "0.7", features = ["full"] }
futures = "0.3.34"
async-trait = "0.1"
# --- http + html ---
reqwest = { version = "0.13.5", features = ["gzip", "brotli", "zstd", "deflate", "cookies", "json", "stream", "hickory-dns"] }
scraper = "0.27"
lol_html = "3.0.1"
dom_smoothie = "0.18"
dom_query = "0.28"
url = "2.5.8"
mime = "0.3.17"
# --- records ---
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# --- errors ---
thiserror = "2.0.20"
anyhow = "1.0.104"
# --- politeness / frontier ---
governor = "0.10"
backon = "1.6" # new code; backoff 0.4 only for legacy compat
reqwest-retry = "0.9" # status-based (429/503) layer over built-in reqwest::retry
texting_robots = "0.2"
quick-xml = { version = "0.42", features = ["serialize", "async-tokio"] }
dashmap = "6"
moka = { version = "0.12", features = ["future"] }
hickory-resolver = "0.26"
# --- persistence ---
rusqlite = { version = "0.40", features = ["bundled"] }
deadpool-sqlite = { version = "0.14", features = ["rt_tokio_1"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "postgres", "migrate", "macros"] }
blake3 = "1.8"
fastbloom = "0.17"
# --- observability ---
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.18"
opentelemetry = { version = "0.32", features = ["trace", "metrics", "logs"] }
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "http-proto"] }
tracing-opentelemetry = "0.33"
# --- browser fallback (Phase 5 only) ---
chromiumoxide = { version = "0.9", optional = true }
# --- search (Phase 7 only) ---
tantivy = { version = "0.26", optional = true }
fastembed = { version = "6", optional = true }
qdrant-client = { version = "1.19", optional = true }
text-splitter = { version = "0.32", optional = true }
axum = { version = "0.8", optional = true }
[dev-dependencies]
wiremock = "0.6"
insta = "1.48"
pretty_assertions = "1.4"
proptest = "1.11"
rstest = "0.27"
criterion = { version = "0.8", features = ["html_reports"] }
tokio = { version = "1.53", features = ["full", "test-util", "tracing"] }
console-subscriber = "0.5"
[features]
default = []
browser = ["chromiumoxide"]
search = ["tantivy", "fastembed", "qdrant-client", "text-splitter", "axum"]
[[bench]]
name = "normalize"
harness = false
[lints.clippy]
unwrap_used = "warn"
todo = "warn"
[profile.profiling]
inherits = "release"
debug = true
Notes:
backoffis deliberately absent: usebackonfor new retry code. Keepbackoff = "0.4"only if following a tutorial that imports it.reqwest-retryneedsreqwest-middleware; cargo resolves it (0.5.2). For pure protocol-NACK retries,ClientBuilder::retry(reqwest::retry::for_host(...))with no extra dep is enough.sqlx0.9 changedquery*()to takeimpl SqlSafeStr(see §5). String literals work unchanged;format!-built SQL now needsAssertSqlSafe.http-cache-reqwestis omitted on purpose (still alpha). Addhttp-cache-reqwest = "1.0.0-alpha.9"only for experiments.ortis omitted (transitive via fastembed, still RC line).dashmap = "6"— never"7"(only stale release candidates exist there).
3. rust-toolchain.toml and recommended Rust version
Recommended: Rust 1.98.1 stable (released 2026-09-03; fixes a 1.98.0 vtable miscompilation — see https://blog.rust-lang.org/2026/09/03/Rust-1.98.1/). MSRV floor for this stack is 1.86 (sqlx 0.9), but develop on current stable.
# rust-toolchain.toml — checked in at repo root
[toolchain]
channel = "1.98.1"
components = ["rustfmt", "clippy", "rust-analyzer", "rust-src"]
targets = ["aarch64-apple-darwin"]
profile = "minimal"
Why pinned (not channel = "stable"): reproducible benchmarks and eval numbers. Re-verify quarterly; the guides are written to survive cargo update within the majors pinned above, except the Kwaidan-style breakages listed in §5.
cargo new since Rust 1.85 defaults to edition = "2024" — confirm your Cargo.toml says so; do not create new crates on edition 2021.
4. macOS setup commands (Apple Silicon)
# 1. toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update stable # pulls 1.98.1+
rustup component add clippy rustfmt rust-analyzer rust-src
# 2. verify
rustc --version # expect rustc 1.98.1 (2026-09-03) or newer
cargo --version
cargo clippy --version
# 3. cargo tools (names matter: the CLI differs from the crate name in one case)
cargo install --locked cargo-nextest # binary: cargo nextest (runner, 0.9.144)
cargo install --locked cargo-watch # binary: cargo watch (8.5.3)
cargo install --locked bacon # background check/test/clippy (3.25.0)
cargo install --locked cargo-expand # macro expansion (1.0.126)
cargo install flamegraph # crate `flamegraph` (0.6.14) provides `cargo flamegraph`
cargo install --locked cargo-deny # dependency policy (0.20.2)
cargo install --locked cargo-audit # advisory audit (0.22.2)
cargo install --locked tokio-console # CLI 0.1.14; app side needs console-subscriber 0.5 + RUSTFLAGS below
cargo install --locked hyperfine # whole-binary benches (1.20.0)
cargo install --locked samply # sampler; prefer over flamegraph on macOS (0.13.1)
# 4. tokio-console build flag (still required in 2026 — verified against
# docs.rs/console-subscriber and tokio-rs/console README, Sep 2026)
echo '[build]
rustflags = ["--cfg", "tokio_unstable"]' > .cargo/config.toml
# dev-only: gate console-subscriber behind a feature so release builds skip it
# 5. new crawler crate
cargo new crawlkit --bin && cd crawlkit # edition defaults to 2024
# paste the §2 dependencies, then:
cargo fmt && cargo clippy -- -D warnings && cargo test
cargo deny init && cargo deny check # after first successful build
cargo audit
macOS notes:
- Skip
mold(Linux-only linker); on macOS the default linker is fine. cargo-flamegraphridesxctraceon macOS and can be finicky — reach forsamply record ./target/profiling/crawlerfirst (see file 06).--lockedis used above except forflamegraph(its lockfile has historically lagged; plain install resolves). Forsqlx-cli, do NOT use--locked— sqlx removed its trackedCargo.lockin 0.9, socargo install --locked sqlx-clino longer works; plaincargo install sqlx-cliis correct.
5. Breaking-change notes: what changed in 2025–2026 that older tutorials get wrong
5a. Rust edition 2024 is the default (stable since 1.85.0, Feb 2025)
cargo new now emits edition = "2024". It is the largest edition to date. What bites crawler code ported from pre-2025 tutorials:
genis a reserved keyword (future generator blocks).rand::Rng::gen()calls still compile only because rand renamed/aliased — but any local variable/function namedgenmust becomer#gen. Runcargo fix --edition.unsafe externblocks,unsafeattributes (no_mangle,export_name,link_section),unsafe_op_in_unsafe_fn(warn by default), never-type fallback (!coercion) changes,exprfragment specifier now also matchesconst/_— mostly silent until you touch FFI/macros.std::env::set_var/remove_varare nowunsafe fn(edition 2024 newly-unsafe functions). Test harnesses that set env vars for wiremock/mock servers must wrap inunsafe {}.async || {}closures +AsyncFn/AsyncFnMut/AsyncFnOncetraits are stable. Prefer them over|| async {}when the future must borrow captures.FutureandIntoFutureare in the prelude. Remove staleuse std::future::Futureimports only if clippy tells you to; leaving them is harmless.- Source: https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/ and https://doc.rust-lang.org/edition-guide/rust-2024/index.html.
5b. reqwest 0.13 (released 2025-12-30) — the big one for Phases 2–4
Source: https://github.com/seanmonstar/reqwest/releases/tag/v0.13.0 and https://seanmonstar.com/blog/reqwest-v013-rustls-default/. File 03's summary was verified correct; full list:
rustlsis now the default TLS backend (wasnative-tls).default-tlsnow resolves to rustls +rustls-platform-verifier. The old "works with curl, fails in Rust" corporate-TLS story is mostly gone; containers still need verification against real hosts.- Crypto provider defaults to aws-lc (was ring).
rustls-no-providerfeature exists for custom providers. - Feature renamed:
rustls-tls→rustls. Old tutorials'features = ["rustls-tls"]fail to resolve. - Custom roots consolidated:
tls_certs_merge()/tls_certs_only(); webpki-roots feature flags removed ("bring your own certs" via thewebpki-root-certscrate). TLS builder methods renamed with soft deprecation: prefertls_backend_rustls()overuse_rustls_tls(),tls_version_min/maxovermin/max_tls_version,tls_certs_merge/onlyoveradd_root_certificate. queryandformare now optional features, disabled by default (serde-less builds possible). IfRequestBuilder::query()is missing, addfeatures = ["query"](same forform).trust-dnsremoved (was renamedhickory-dnslong ago). Usefeatures = ["hickory-dns"]+.hickory_dns(true).ClientBuilder::dns_resolverreplaced bydns_resolver2(customIntoResolveimpls need updating).native-tlsnow includes ALPN by default (native-tls-no-alpnrestores old behavior).- New in 0.13: built-in retry —
ClientBuilder::retry(...), modulereqwest::retry(verified present in 0.13.5 docs). Default retries only protocol NACKs, with a 20% per-scope request budget; scope withretry::for_host(...). Crawl status logic (429/503 +Retry-After) still belongs inreqwest-retry/backonon top. - All
ClientBuildermethods used in file 03's factory (connect_timeout,read_timeout,timeout,pool_idle_timeout,pool_max_idle_per_host,redirect,cookie_store,https_only,gzip/brotli/zstd/deflate,hickory_dns,retry) verified present in 0.13.5 docs.
5c. sqlx 0.9 (released 2026-05-06) + org move
Source: https://github.com/transact-rs/sqlx/blob/main/CHANGELOG.md. File 04's "now under transact-rs" note verified correct (github.com/launchbadge/sqlx 301-redirects to transact-rs/sqlx).
query*()functions now takeimpl SqlSafeStr— implemented only for&'static strandAssertSqlSafe(..). String literals compile unchanged;format!()-built SQL must be wrapped inAssertSqlSafe(...)(deliberate code-review speedbump against injection).- New optional
sqlx.tomlper-crate config (renameDATABASE_URL, global type overrides,_sqlx_migrationstable rename/relocation). Featuresqlx-toml; enabled by default insqlx-cli, off in the library. - SQLite: extension loading is now
unsafe; new non-default featuressqlite-deserialize,sqlite-load-extension,sqlite-unlock-notify;SqliteValueis!Sync/SqliteValueRefis!Send. - Deleted deprecated combined
runtime-tokio-native-tls-style features; MSRV is now 1.86. Cargo.lockremoved from the sqlx repo:cargo install --locked sqlx-clino longer works; use plaincargo install sqlx-cli.
5d. tokio-console still needs the unstable flag
Verified Sep 2026 against https://docs.rs/console-subscriber/latest/console_subscriber/ and https://github.com/tokio-rs/console: RUSTFLAGS="--cfg tokio_unstable" (or .cargo/config.toml [build] rustflags) is still mandatory, and console-subscriber is at 0.5 (file 02's 0.4 pin works but is one minor behind). Alternating with/without the flag triggers full rebuilds — gate the subscriber behind a dev feature.
5e. Ecosystem renames and stale lines to stop copying
- Criterion's canonical repo is
criterion-rs/criterion.rs; the oldbheisler/criterion.rsURL still resolves but don't cite it in new writeups. Bench macro/API break was 0.4 → 0.5 (old tutorials'criterion_group!harness setup differs); 0.8.2 is current. async fnin traits is stable since Rust 1.75 —#[async_trait]only fordyncompat (files 01/02 already say this; re-stating because 2023-era tutorials still blanket-derive it).cargo addis built into cargo — nocargo-editneeded (file 01 correct).ort2.x is still RC (2.0.0-rc.13);http-cache-reqwest1.x is still alpha. Both are fine transitively/experimentally, but don't pin them as stable foundations in READMEs.dashmap7.x does not exist as stable (only 2025 RCs); depend on"6".- The
playwrightcrate (0.0.20, 2022) is dead upstream of the name — the live project is theplaywright-rscrate (0.18.0) frompadamson/playwright-rust. File 05 already warns correctly.
6. Corrections table (audit of files 01–08)
Method: every pinned version in files 01–08 was checked against the crates.io API on 2026-09-11; ~90 resource/repo/docs links were spot-checked with HTTP status (all 200/301-valid, including the Exa blog/API/careers pages, Ashby posting API, RFCs, papers, Tokio docs, and all Rust crate repos). Only genuine, verified discrepancies are listed — everything else checked out, stated explicitly per file below.
| Source file | Original claim | Correct info | Source link |
|---|---|---|---|
| 02-async-rust-tokio.md (Observability) | console-subscriber = "0.4" in the Cargo.toml snippet |
Latest is 0.5.0 (2025-10-30). 0.4 still resolves and works, but new code should pin "0.5". The RUSTFLAGS="--cfg tokio_unstable" instruction alongside it is still required — verified. |
https://crates.io/crates/console-subscriber · https://docs.rs/console-subscriber/latest/console_subscriber/ |
| 01-rust-fundamentals.md (Benchmarking) + 08 (Tool selection) | Links to https://github.com/bheisler/criterion.rs |
Repo still resolves (200), but canonical home is now https://github.com/criterion-rs/criterion.rs (which file 06 already uses). Use the criterion-rs URL in new citations. No version error: 0.8.2 pin verified correct. |
https://github.com/criterion-rs/criterion.rs |
| 03-http-scraping-rust.md (Stack snapshot) | dom_smoothie 0.18 |
Latest is 0.18.1 (2026-09-07). "0.18" as a requirement string already matches 0.18.1 — no action needed; noted for exactness. |
https://crates.io/crates/dom_smoothie |
| 03-http-scraping-rust.md (Core crates) | scraper 0.27 (html5ever 0.39) — correct, but readers may be tempted to bump html5ever |
Do not bump: scraper 0.27 depends on html5ever ^0.39.0 (verified via crates.io dependencies API). html5ever 0.40.0 was published 2026-09-11 (audit day); wait for a scraper release that adopts it. |
https://crates.io/crates/scraper · https://crates.io/crates/html5ever |
| 05-browser-automation-cdp.md | Crate-pages line links playwright-rs — correct, but easy to misread against the repo name |
Confirmed: crate playwright-rs 0.18.0 (2026-09-09) on crates.io; repo is padamson/playwright-rust; import is playwright_rs. The 0.18.0 pin in the file is exactly current. The dead playwright 0.0.20 warning is accurate. |
https://crates.io/crates/playwright-rs · https://github.com/padamson/playwright-rust |
| 04-polite-crawling-frontier.md | SQLx (now under transact-rs; formerly launchbadge) |
Confirmed correct: github.com/launchbadge/sqlx 301-redirects to github.com/transact-rs/sqlx. crates.io metadata still labels the repo as launchbadge — cosmetic only. |
https://github.com/transact-rs/sqlx |
Explicit "no errors found" statements (verified, not assumed):
- File 01: thiserror 2.0.20, anyhow 1.0.104, proptest 1.11, criterion 0.8.2, divan 0.1.21, insta,
cargo addbuilt-in,cargo new/clippy/fmtinstructions — all correct. No dead links found in spot checks (rustup, Book, Rustlings, rust-exercises, CLI book, rust-analyzer, nextest, bacon, watch, expand, flamegraph, mold). - File 02: Tokio patterns (Semaphore+JoinSet, buffer_unordered, mpsc pool, DashMap per-host, governor, backoff/tokio-retry/tower, CancellationToken/TaskTracker,
select!, tracing/console) and all tokio.rs/docs.rs links — correct. No stale-version pins besides console-subscriber 0.4→0.5 above. - File 03: full stack snapshot (reqwest 0.13.5, scraper 0.27, dom_query 0.28, serde 1.0.229, serde_json 1.0.151, url 2.5.8, lol_html 3.0.1, blake3 1.8.7, wiremock 0.6.5, texting_robots 0.2.2) — all exactly current. reqwest 0.13 notes (rustls default,
reqwest::retry,read_timeout,hickory_dns,https_only,pool_max_idle_per_host) verified against 0.13.5 docs.select/kuchikideprecation notes verified (0.6.1 Mar 2025 / 0.8.1 Aug 2020). No dead links found. - File 04: RFC 9309/9110/9111/6585, IR Book/Mercator links, governor/backoff/reqwest-retry, rusqlite/deadpool-sqlite, hickory-resolver, moka, quick-xml, WARC/Common Crawl links — all alive and version-correct.
crawlee-rustnon-existence note verified (no such crate/repo;playwright-rust404 confirmed as a crate name — the real crate isplaywright-rs). - File 05: chromiumoxide 0.9.1, headless_chrome 1.0.22, fantoccini 0.22.1, playwright-rs 0.18.0, thirtyfour 0.37.5, spider 2.53.9 — all exactly current. CDP/Playwright/Chrome-for-Testing/Lightpanda/Steel/Browserless links — all alive.
- File 06: async-nats 0.50, metrics-exporter-prometheus 0.18.x, NATS/Kafka/Redis/SQS docs, DDIA-2e (Mar 2026), tower-resilience, samply/flamegraph/criterion/hyperfine/pprof links — all alive and version-correct. "Prefer docs.rs over crates.io" advice confirmed (crates.io is JS-gated; API works).
- File 07: tantivy 0.26.x, fastembed-rs v6.x, text-splitter 0.32, LanceDB 0.38.x, Qdrant docs, IIR/BEIR/MTEB links — all alive and version-correct.
- File 08: Exa blog/research/evals/careers pages, Ashby posting API endpoint, Exa Discord invite (301 → discord.com/invite, valid), founder-media and backup-company links spot-checked — all alive at audit time. Role/comp specifics change weekly as the file itself warns; re-check the Ashby API before applying (no static correction attempted).
7. Re-verification checklist (for late 2026)
# quarterly refresh — paste, compare against §1, update this file
for c in tokio reqwest scraper serde serde_json url thiserror anyhow tracing sqlx rusqlite governor backoff reqwest-retry texting_robots blake3 wiremock criterion proptest chromiumoxide fantoccini headless_chrome tantivy fastembed qdrant-client metrics opentelemetry dom_query dom_smoothie lol_html spider playwright-rs axum backon; do
curl -s "https://crates.io/api/v1/crates/$c" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['crate']['name'], d['crate']['max_version'], d['crate']['updated_at'][:10])"
done
rustup update stable && rustc --version
curl -s "https://api.ashbyhq.com/posting-api/job-board/exa?includeCompensation=true" | head -c 300
End of verified toolchain document. Last verified 2026-09-11. Next refresh due 2026-12-11 or on any Rust stable release, whichever comes first.
Exa Deep Research 2 — What's New Since File 08 (Sep 2026)
*Companion to /Users/smol/dev/learn_rust/resources/08-exa-application-portfolio.md (file 08). Do NOT re-read this file as a replacement — it contains only NEW information file 08 missed or under-covered, verified Sep 11 2026 via websearch/webfetch. Every claim has a link. Items that could not be verified from a primary source are marked [UNCERTAIN] or [THIRD-PARTY]. Job listings change weekly — re-check the Ashby API before applying.*
Scope: last ~6–12 months (roughly Oct 2025 → Sep 2026), plus a few older items file 08 never mentioned (Exa 2.0, People Search benchmarks, Monitors, Websets mechanics).
1. Blog / Research Index: Complete Oct 2025 → Aug 2026 (What File 08 Missed or Thinly Covered)
File 08's reading list stopped at ~Aug 2026 headlines but did not enumerate the full recent run or what each post reveals about the crawler/index. Full reverse-chron from a live fetch of exa.ai/blog:
| Date | Post | What's new vs. file 08 + why a crawler candidate cares |
|---|---|---|
| Aug 28, 2026 | Dynamic Highlights | Research preview: custom model selects "just the right tokens across all of the content, reducing token count by an average of 95%, compared to full content." API flag dynamic: true. Crawler angle: extraction quality is now a trained model problem, not boilerplate-removal — know the difference between Highlights (10x token-efficient extracts) and Dynamic Highlights (query-adaptive spans). |
| Aug 17, 2026 | Exa is now powering search in Firefox | See §4. New scale numbers: "globally-sourced index of 1.4 trillion URLs and 100 billion documents". Firefox calls /search, Exa retrieves/ranks/extracts passages, synthesizes cited answers. Privacy: "Exa retains none of Firefox's query data." File 08 cited this as a milestone; missed the architecture detail (retrieval → rank → extract → synthesize, domain restriction, freshness control, moderation filters). Pair with Mozilla announcement and case study. |
| Jul 23, 2026 | SOTA Search Over Academic Publications | Dedicated index of ~350M publications + ~30M authors added to people index. Natural-language paper search (vague/memory-based queries). New publication search category (replaces research paper); pdf, github, tweet categories being deprecated per changelog July 2026. Crawler angle: vertical-index construction + entity (author/org) enrichment. |
| Jun 16, 2026 | Introducing Exa Agent | New Agent API: single endpoint for "frontier web research," combines LLMs + Exa search tools. Effort tiers minimal/low/medium/high/xhigh at $0.012/$0.025/$0.10/$0.50/$1.00 per request; auto meters compute; structured outputs via outputSchema; input.data for bring-your-own-data. Crawler angle: agent fan-out (dozens of sub-searches per task) is what drives the QPS provisioning story in §6. Docs: Agent API guide. |
| Jun 2026 (undated, in changelog) | Introducing Exa Connect + changelog June 2026 | Agent + premium data partners: Similarweb ($0.03/call), Fiber.ai ($0.02), Baselayer ($0.022), Financial Datasets ($0.01), Affiliate.com ($0.015), Particle ($0.015), Jinko ($0.005). Attached via dataSources on POST /agent/runs. File 08 listed Connect as a product name only. |
| May 13, 2026 | How Search Quality Shapes RL Outcomes | File 08 listed it; new note: this is Exa's "retrieval quality is the product" thesis in eval form — RL labs training with Exa. Read before interviews; it explains why evals (not just crawl throughput) dominate their hiring conversation. |
| May 20, 2026 | Exa raises $250M Series C | See §5. Quotable crawler line (verbatim): "Our crawlers track over 500 billion urls, our research teams train special embedding models on a GPU cluster we assembled, and we've built new vector databases for the extremely high QPS that agents need." |
| Apr 28, 2026 | Exa and Google Partnership | File 08 missed entirely. Two integrations: (1) Grounding with Exa Web Search in private preview on Vertex AI (Gemini calls Exa, Highlights model extracts excerpts into context); (2) Exa Agent as launch partner on Gemini Enterprise Agent Marketplace (shipped at Google Next). Google docs: Grounding with Exa. Integration page: Exa + Google Cloud. Crawler angle: Google — the incumbent — now resells Exa retrieval; freshness/factuality is the wedge. |
| Apr 20, 2026 | Introducing Deep Max | File 08 missed. Highest-quality agentic search endpoint: "combines frontier LLMs with dozens of parallel calls to Exa Search," SOTA on "every popular agentic search benchmark… up to 20x faster than the closest competitor." Pricing/availability gated ("reach out"). Sits above Deep/Deep-reasoning in the latency ladder. |
| Apr 17, 2026 | Composing a Search Engine (Canon) | File 08 listed it; new technical detail in §7 (DAG orchestrator, pull-based runtime, memoization, cancellation, tracing). Authors Rohit Prakash, Nitya Sridhar — both names to know for outreach. Ends with hiring CTA: "If you want to build the next generation of scaling our index… come work at Exa!" |
| Mar 30, 2026 | Exa Lands in Singapore | File 08 mentioned SG roles but not the announcement. "First Asia office in Singapore, focused on core engineering for our retrieval stack" — retrieval stack + web-scale infra (embedding/indexing pipelines, Rust vector DB, H200 infra). Quote: "The sun never sets on Exa" (Will Bryk). Backed by Singapore EDB press release. Directly explains why the Singapore crawler role exists. |
| Mar 23, 2026 | WebCode: Search Evals for Coding Agents | File 08 listed it; added context: authors Joshua Ahn, Gabriel Cammany, Nitya Sridhar. Open eval methodology a candidate should imitate for the portfolio benchmark. |
| Mar 2026 (changelog) | Introducing Exa Monitors + Monitors API guide | File 08 missed. Scheduled search (min interval 1h, anchored to creation, manual trigger), webhook delivery, date + semantic dedup, outputSchema structured results. Crawler-adjacent: recrawl/scheduling and dedup logic surfacing as a product. |
| Mar 4, 2026 | Introducing Exa Deep (revamp) | File 08 under-covered. Revamped Deep: query expansion + LLM reasoning + parallel agents + citations; deep 4–12s at $12/1k, deep-reasoning 12–50s at $15/1k (20% cheaper than before); structured outputs with field-level grounding. Built on Instant. |
| Feb 12, 2026 | Introducing Exa Instant | File 08 listed it; missed the polemic: "Most search APIs are far slower, mostly because they need to wrap Google SERP… That means… over 700ms P50, and so any search API that wraps Google has a minimum 700ms P50." Instant is sub-200ms (changelog later says sub-150ms with type="instant"). The anti-wrapper argument is interview-safe to echo. |
| Jan 13, 2026 | exa-d | File 08 covered; new author note: Hubert Yuan, Nitya Sridhar. Key numbers for interviews: hundreds of billions of pages, petabytes raw, Lance-on-S3 fragments, Ray Data stages, per-fragment patching. See §7. |
| Jan 2026 (changelog) | Company Search + People Search launches (changelog Jan 2026) | File 08 missed the launch framing. Company Search: fine-tuned retrieval model + entity-matching pipeline, 50M+ companies, typed fields (workforce, HQ, financials, traffic). People Search: 1B+ public profiles, hybrid retrieval; linkedin category replaced by people. |
| Dec 17, 2025 | People Search Benchmarks | Listed in file 08 without detail. Method note: targeted lookup scored with recall@k/NDCG against profile ground truth — a template for portfolio evals. [UNCERTAIN: did not re-fetch full text this pass; verify before quoting.] |
| Oct 10, 2025 | Introducing Exa 2.0 | File 08 missed entirely. Three updates: Exa Fast (sub-350ms P50, "30% faster than next fastest"), Exa Auto (higher-quality default), Exa Deep (agentic, 3.5s P50). Index claim: "We now crawl + parse tens of billions of webpages and refresh them every minute." Embedding model "trained for over a month on our 144x H200 cluster." Vector DB: "new clustering algorithms, new lexical compression, and several assembly optimizations. All in Rust of course." |
| Jul 2026 (changelog) | Agent + Connect in MCP (changelog, MCP docs) | File 08 listed MCP Server but missed this: agent_run tool via https://mcp.exa.ai/mcp?tools=agent_run; Connect data sources through Agent flow. |
| Pricing update (Mar–Jun 2026, in changelog) | Search $7/1k (10 results, text+highlights included), +$1/1k extra results; summaries $1/1k; Deep $12/1k, Deep-reasoning $15/1k; Contents $1/1k pages/content-type. Free tier $20 + $10/mo. | Signals cost discipline the crawler team lives under — every wasteful recrawl shows up in margin. |
Blog-archive check: the above is the complete featured run on exa.ai/blog back to Oct 2025 as fetched Sep 11 2026 (older items: Series B Sep 3 2025, Exacluster May 15 2025, BM25 May 5 2025, next-gen search Mar 11 2025 — all already in file 08).
2. Founder / Engineering Media File 08 Missed (Interviews, Podcasts, Talks)
File 08's media table (Latent Space Jan 2025, Sacra Sep 2024, jxnl Sep 2025, a16z Jun 2026, SuperAI Jun 2026, Knuckle Up Aug 2026, MIT Tech Review Dec 2024) is accurate but thin on what was actually said in the 2026 items and misses three 2026 appearances.
2a. NEW / under-transcribed 2026 appearances
- a16z "Building Search for AI Agents" — Sarah Wang × Will Bryk (Jun 4–6, 2026). File 08 linked it; the substance: origins, why keyword engines fail agents, retrieval/agent workflows, coding agents, data access, "search as foundational layer for the agent economy." Links: a16z announcement page · episode audio (49:44). Investor angle: same Sarah Wang who co-led the Series C (a16z investing memo) — read both together.
- SuperAI 2026 — "Perfect Search for a World of Agents" (Jun 17, 2026). File 08 linked; positioning: latest product/research framing post-Series C. Link: YouTube. [UNCERTAIN: transcript not verified this pass — watch before quoting.]
- NYSE + Megawna AI 40 — Will Bryk interview (Aug 11, 2026). File 08 missed. Bryk rang the opening bell; confirms post-C timing ("$250M Series C… why now: AI agents taking off… serving 5,000 companies"), growth ("revenue growth quite fast this year, especially past few months"), shifting use cases (coding/GTM agents → finance: banks, PE, hedge funds needing high-quality + proprietary data), integration simplicity ("tell your agent integrate Exa… a few lines of code"), goal ("serve millions of companies and be far better than Google"). Link: NYSE YouTube. Best 5-minute Series C rationale on record.
- Business Insider — "Hiring rebellious engineers" + Singapore expansion (Mar 30, 2026). File 08 missed. Canonical culture/hiring quotes: hiring "rebellious" engineers ("doesn't care about the status quo… think from first principles"); "I don't believe it when people say you can't do something"; values > experience ("Do they really have passion for building search or large-scale systems?"… experience "not as important"); junior + senior both open; fly-to-SF 1–2 day working trial ("eat with them at lunch and dinner… really get to know a person"); "Most of the company's code is written by AI"; best engineers "move fast and use AI tools effectively." SG office: "handful" in APAC → hire ~10 backend/infra/product; focus "massive scale infrastructure… data pipelines and crawling infrastructure." Headcount at the time: ~80 globally, hiring in SF/Zurich/SG. Links: Business Insider (+ Africa mirror). Highest-yield single article for interview prep.
- Knuckle Up — "No process, no NPC hires, extreme shipping velocity" (Aug 4, 2026). File 08 linked; the sharpest lines (from writeup): "A month is an insane amount of time. Nothing should ever take a month to ship at Exa"; kills process "before it lays eggs"; culture-not-process; screens out "NPCs" (non-player characters — passive, low-agency); "bitter lesson works on compute but not people"; ship something important in week 1. Episode: knuckleup.co.
- Will Bryk LinkedIn — index-scale post (Aug 3, 2026). File 08 missed. Verbatim scale/QPS disclosure (see §6). Link: LinkedIn post (may require login; secondary: RuntimeWire summary).
- Will Bryk LinkedIn — Singapore announcement (Mar 30, 2026). "Google/Bing built the first generation of web infra from scratch but haven't done it for decades… We're doing it again… exabytes of data, higher standards for quality, built from scratch for AI." Link: LinkedIn post.
2b. Still no trace (gaps for a third pass)
- Jeff Wang (co-founder) interviews/podcasts: none found in 2025–2026 via websearch this pass. File 08 also had none. [UNCERTAIN: may exist behind conference videos; search "Jeff Wang Exa" returns noise — treat as unknown.]
- Named crawling-team members (Tyler Killian et al.) talks/posts: none found beyond About-page one-liners. No conference talks by the crawl team surfaced. [UNCERTAIN]
- Conference talks beyond SuperAI + AI Engineer World's Fair 2024: no new crawl-specific talk found. File 08's AI Engineer World's Fair + RustConf guidance stands.
3. Open-Source Releases (exa-labs) — Harder Numbers Than File 08
File 08 said "107 repos, mostly SDKs/demos/forks; crawler closed-source" without star counts or dates. Verified Sep 2026:
- exa-mcp-server — ~4,958 stars, ~379 forks, MIT, created Nov 27 2024. Tools:
web_search_exa,web_search_advanced_exa,agent_run, contents/fetching;skills/dir withsearch+exa-agentClaude skills. Listed as Agent Plugin (verified Aug 20 2026, MIT). Docs: MCP setup. Best first-PR surface (tool surface, docs, skills — not the engine). - exa-py — ~233 stars, ~53 forks, MIT. Now includes Agent API + Agent Monitors (beta header
agent-monitors-2026-08-04). Good small-PR surface (SDK gaps, examples). - agent-skills — ~45 stars, created Jul 3 2026. "Official skills for the Exa API":
build-with-examega-skill (search, contents, answer, context, Agent, monitors, websets, OpenAI-compatible endpoints, exa-py/exa-js). Very new → high-value contribution window (fixes, examples, missing frameworks). - exa-js, company-researcher, hallucination-detector, zed-exa-mcp-extension: per file 08; star counts not re-verified this pass — do not quote.
- WebCode evals (blog): open eval methodology — file 08 correctly flagged as the structure to imitate. Not a code drop per se; check the post for dataset/metrics before calling it "open-source."
- No crawler, embedding-model, or vector-DB code is public. [UNCERTAIN whether any new research code dropped Jul–Aug 2026 — none found; assume closed.]
4. Product Launches File 08 Under-Covered (Crawler-Relevant Implications)
| Launch | Date | What it is | Why it matters for the crawl team |
|---|---|---|---|
| Exa Instant | Feb 12, 2026 (blog) | Sub-200ms (later sub-150ms) search tier; type="instant" |
Proves serving-side latency budget is binding — crawl freshness must land in an index servable in ms, not a batch warehouse. |
| Exa Deep revamp | Mar 4, 2026 (blog) | Parallel-agent search, 4–12s / 12–50s tiers, structured outputs | Each Deep query = "dozens of parallel calls to Exa Search" (Deep Max post) → fan-out multiplies crawl-coverage value. |
| Monitors | Mar 2026 (guide) | Scheduled search + dedup + webhooks | Productized recrawl/dedup; freshness + semantic dedup are now customer-visible SLAs. |
| Deep Max | Apr 20, 2026 (blog) | Max-quality agentic search, SOTA claims, 20x faster claim | Compute-per-query frontier; justifies "variable compute per query" vector-DB design. |
| Google: Grounding + Agent Marketplace | Apr 28, 2026 (blog) | Vertex private preview + Gemini Enterprise launch partner | Distribution + credibility; freshness/accuracy wedge vs. Google's own index. |
| Exa Agent + Exa Connect | Jun 16 / Jun 2026 (blog, Connect docs) | Agent API + 7 premium data partners | Web + proprietary data fusion; crawl team owns the web side of a hybrid product. |
| Publications (350M) + org/people enrichment | Jul 2026 (blog, changelog) | Vertical index + entity graph | Vertical-crawl + enrichment pipelines; entity resolution at 30M-author scale. |
| Firefox (Smart Window + Quick Answers) | Aug 17–18, 2026 (Exa post, Mozilla, case study) | Browser distribution, cited answers, zero data retention | First consumer-scale deployment; multilingual evals + latency profiling + privacy handling were the selection criteria — mirror those three in portfolio writeups. |
| Dynamic Highlights | Aug 28, 2026 (blog) | Query-adaptive extraction, −95% tokens | Extraction is now model inference over crawl output; crawl fidelity caps what the model can select. |
Pricing (context for cost-per-crawl thinking): changelog + pricing docs. Free $20 + $10/mo; Search $7/1k; Deep $12–15/1k; Agent $0.012–$1.00 fixed or metered to $5 ($20 for max beta).
5. Funding, Valuation, Headcount — Updates and Corrections to File 08
Funding (confirms + extends file 08)
- Series C: $250M at $2.2B, led by a16z, announced May 20, 2026. Sources: Exa post · a16z memo (Wang/Li/Zhang/Cui) · Bloomberg · Lightspeed "tripling down" (with Benchmark, YC, NVIDIA Ventures). Bloomberg: triples $700M Series B valuation from "last fall" (Series B: $85M, Sep 2025 — Exa post). Dealroom: same tripling, 8-month gap (note).
- Total raised: ~$357–362M (file 08 said ~$362M; JD pages say "$350m"). Crustdata says $357M; Will's LinkedIn header says $361.4M across 7 rounds ([THIRD-PARTY: LinkedIn company pages are auto-enriched — treat as approximate]). JD boilerplate ("raised $350m from Lightspeed, Benchmark, and a16z") is rounded — do not quote as exact.
- Use of funds (Exa + press): train next-gen retrieval models; scale infra to "hundreds of thousands of searches per second"; global GTM expansion. (Exa Series C post; SiliconANGLE; TestingCatalog).
- Customers/revenue signals: 5,000+ companies, 400K+ developers (Series C post + About); NYSE interview adds "revenue growth quite fast this year, especially past few months" (founder claim, no numbers — do not quantify).
Headcount (corrects file 08's "~100")
File 08 said "~100 people." The truth is range-bound and source-dependent — quote the range, not a point:
- Mar 30, 2026: ~80 globally (Bryk to BI — source).
- May 2026: ~120 (SiliconValleyInvestClub profile — [THIRD-PARTY]).
- YC directory: 75 (ycombinator.com/companies/exa) (stale snapshot — do not quote as current).
- LinkedIn company page (auto-enriched): 213 employees (+159.8% YoY), US 110 / SG 6 / CH 6, tech 62 / research 15 ([THIRD-PARTY, likely inflated by affiliates — do not quote as official]).
- Crustdata Sep 2026: 202 employees, eng ~65 (+195% YoY), eng largest function (46 mapped) (source) ([THIRD-PARTY]).
- Careers page shows ~55 roles / ~13 listed titles including both crawler roles (see §6 of file 08 + §6 below).
Interview-safe line: "Roughly 80 in March 2026 growing fast; third-party trackers put it near 200 by September, with engineering the largest function — headcount is growing ~150%+ YoY, so treat any point number as stale."
Engineering-culture signals (mostly new vs. file 08)
- "Rebellious," first-principles, status-quo-hostile (BI).
- No process, no NPCs, week-1 shipping, nothing takes a month (Knuckle Up).
- Values > resume; passion for search/scale > years of experience; junior + senior open (BI).
- 1–2 day SF working trial as the real filter (BI + §6 below).
- "Most code written by AI"; best engineers move fast + use AI tools effectively (Bryk to BI — echo carefully: shows AI-leverage expectation, not "no engineering rigor").
- Nap pods + in-person intensity (Latent Space, per file 08 — not re-verified this pass).
- Eval-driven, benchmark-publishing culture (RL-outcomes, WebCode, publications, company/people benchmarks, evals page) — file 08 had this; reinforcing because every 2026 post repeats it.
6. The Crawler Role — Deltas Since File 08
6a. Listings (re-verified Sep 11, 2026)
File 08's table is still accurate; deltas:
- Both crawler roles still listed on exa.ai/careers (titles: "Software Engineer, Web Crawling" ×2 — SF + Singapore) as of this pass's careers fetch. Ashby IDs unchanged: SF
0298c908…(published Jul 23 2025, $180K–$350K + equity), SG9679c963…(published Dec 10 2025, SGD 90K–300K + equity). Mirrors: Built In SF, Built In SG, JobsByCulture, Seedtable SG. - Re-verify via
curl -s "https://api.ashbyhq.com/posting-api/job-board/exa?includeCompensation=true"— file 08's advice stands; this pass did not hit the JSON endpoint (search-index caches confirm live, but Ashby is the ground truth). - JD text unchanged (boilerplate now says $350m raised; "100M+ pages/day," politeness/rate-limiting, dynamic content/JS rendering/anti-bot, scheduling/prioritization; C++/Rust + TS/Playwright/CDP). Full text in file 08 §"Verbatim" — not repeated here.
- Adjacent roles file 08 listed (Infra, Distributed Data Systems, Backend, Research, FDE) still on careers page; new title spotted: "Software Engineer, Knowledge Systems" + "Head of Security" (careers fetch + LinkedIn hiring post Sep 3 2026). Zurich mentioned as hiring location in BI (Mar 2026) alongside SF/SG — [UNCERTAIN: no Zurich crawler role found; may be GTM or later.]
6b. Likely hiring manager / team map [UNCERTAIN — no JD names a manager]
- File 08 pointed at Tyler Killian ("building state-of-the-art crawling systems" on About). That remains the only public crawl-owner signal — but About-page bios are not an org chart. Do not address applications to him as "hiring manager" without confirmation.
- Other crawl/index-adjacent names from primary sources (all via About unless noted): Mark Pekala (data systems), Hubert Yuan (exa-d co-author), Tom An (BM25 post author, ex-Google signal), Ben Chan/Chen (distributed algorithms; highlights-server co-author "Ben Chen" — [UNCERTAIN: Chan vs. Chen spelling differs between About and blog byline — verify]), Joshua Ahn (WebCode, technical staff), Gabriel Cammany (WebCode), Nitya Sridhar (exa-d, WebCode, Canon — prolific infra author), Rohit Prakash (Canon), Carlos Marques (Exacluster).
- Founders: Will Bryk (LinkedIn, @WilliamBryk), Jeff Wang (no public handle verified this pass).
- How to find the team: LinkedIn search
site:linkedin.com/in "Exa" ("crawling" OR "retrieval" OR "vector" OR "embeddings"); X search@WilliamBrykreplies + @ExaAILabs; Exa Discord; careers-page team links. Do not scrape LinkedIn against ToS — manual search only.
6c. Likely interview loop [THIRD-PARTY — Exa publishes no official loop; below is synthesized from BI (primary: Bryk) + two interview-prep sites (secondary)]
Primary (Bryk, BI Mar 2026): values-first screen → fly to SF, work with team 1–2 days (real project + meals) → decision. Juniors and seniors both considered; passion for search/scale weighed over experience.
Secondary (treat as directional, not script):
- techinterview.org working-trial piece (Aug 3 2026): intro call (30–45 min, founder/HM, motivation/values) → technical screen (60 min live code + systems) → role-dependent take-home/paired build → 1–2 day on-site working trial in SF (scoped feature or retrieval/eval problem; pair, read unfamiliar code, ship increment; "disagree well" is scored) → founder conversation + refs. "The working session is where the offer decision actually gets made."
- techinterview.org guide (Jul 21 2026): variant with 15-min rapid screen (CS facts + probability + high-level design) → take-home → paid trial day + present to team; 1–2 weeks end-to-end; less LeetCode-hard, more build-quickly; retrieval fundamentals (embeddings, ANN, BM25 vs. semantic, recall/precision/nDCG) + search systems design (crawling pipelines, sharding, caching, latency budgets).
- Nora SWE guide (Jul 23 2026): 4–5 rounds / 2–4 weeks (recruiter → remote technicals → SF onsite behavioral+ownership) — generalist/SWE-weighted; less crawler-specific; use for behavioral prep only.
What to actually prepare (convergent advice): why-search-why-Exa-why-now story; live crawl-architecture whiteboard (frontier, politeness, dedup, render escalation, storage); scaling math (file 08 §12-week table); one ANN/retrieval explainer; trial-day tactics (scope tight, ship working slice, narrate tradeoffs, ask sharp questions, push back once with evidence).
7. Technical Intelligence — New Detail Since File 08
7a. Crawler + index (public statements, quotable)
- Three-piece architecture (How we're building the next generation of search, Will Bryk Mar 11 2025): "crawling documents, preprocessing the documents for retrieval, and serving queries." Crawl path verbatim: "we constantly look for new urls, crawl them across a distributed network of machines and IPs, run documents through a custom-built html parser, and store in s3. Most of the documents are low-quality though, and a difficult challenge is identifying the best urls to ensure a high quality index." Plus: "Crawlers will be blocked… any sort of operation, even updating one piece of metadata, requires immense amount of compute."
- exa-d (post, Jan 13 2026): web properties (dozens of artifacts/page; heterogeneous HTML/PDF/JS/multimedia; hourly-vs-never update frequencies; hundreds of billions of pages, petabytes raw); typed column DAG with declared dependencies; Lance on S3, fragments with partial schemas, single-column patch without rewriting the fragment; diff-based planning (ideal vs. materialized + topo sort + per-fragment checkpoints); Ray Data stages, Ray Actors holding embedding models in GPU memory, pipeline parallelism (GPU embeds / CPU tokenizes / net fetches concurrently). "Backfills and incremental updates follow the same codepath."
- Exa 2.0 index note (post): "crawl + parse tens of billions of webpages and refresh them every minute" (note tension with 80B-served/1.4T-tracked figures in §6 — different dates/metrics; do not conflate).
- Quality-over-comprehensiveness doctrine (Latent Space transcript; Sacra; Bryk LinkedIn Aug 3 2026): "Most of the web is trash that can hurt AI outputs, so you actually have to crawl way more than you serve and train models to filter out the trash." Crawl-scheduling interviews will probe this (what to crawl, what to drop, how to score).
- Link-prediction / neural PageRank (Latent Space): hide the link, predict it from surrounding text, billions of times; "more powerful than PageRank… learns all the different ways someone refers to [a] essay while also learning how important [it] is"; base objective + later SFT/synthetic stages ("moved beyond that," like next-token → instruction tuning).
7b. Embedding models
- Custom-trained, not off-the-shelf ("web-scale search… requires specialized and highly precise embedding models… ton of data + ton of GPUs" — next-gen search); "novel methods for gathering embedding training data from the web" (same post).
- Matryoshka training → 4096 → 256 dims (20x memory) (vector DB post): prefixes remain good embeddings; cut to 256.
- Binary quantization (16-bit → 1-bit) + hybrid query-doc dot product with LUT (4x fewer ops) + register-resident tables + SIMD/assembly (same post). C++ used for the hottest path ("we specifically had to write this part… in C++ instead of Rust").
- Exa 2.0 model trained "for over a month on our 144x H200 cluster" with "new embedding techniques discovered over past 6 months" (Exa 2.0); fine-tuned retrieval model + entity-matching for Company Search, hybrid retrieval for People Search (changelog Jan 2026).
- Compute: "Exacluster" 18 nodes × 8 H200 = 144 GPUs (Meet the Exacluster, next-gen search); Tom's Hardware cross-coverage with hardware detail (article); homepage references a B200 training cluster in flops (exa.ai) ([UNCERTAIN: size/flops not verified — do not quote numbers]).
7c. Vector DB + query infrastructure
- Requirements (vector DB post): billions of 4096-d vectors; metadata/keyword filters; <100ms; >500 QPS at reasonable cost. Result: billions of vectors <100ms, "less memory than a gaming PC."
- Clustering (100K clusters, ~1000x throughput) + inverted indexes per filterable value (date/domain/keyword) — chosen over HNSW for sharding (by cluster), filter efficiency, and cost (10x cheaper than cloud vector-DB quotes); custom query language for multi-stage pipelines.
- Canon orchestrator (post): pipeline-as-DAG; pull-based runtime (laziness + cancellation free); parallel independent nodes (latency minimization); parent-cancel kills children (no wasted compute); memoization across diamond dependencies; automatic timing/inputs/outputs tracing; serializable graph for pre-run inspection + post-hoc debugging ("why did query y fail to return url z"); typed/total graphs so agents generate valid pipelines. Know this cold — it's the freshest serving-side architecture made public.
- Latency ladder (docs + posts):
instant~150–250ms →fast~350–450ms →auto~1s →deep-lite~4s →deep4–15s →deep-reasoning12–50s; Deep Max above that. File 08 had most;deep-lite+resolvedSearchType/highlightScoresremovals +startCrawlDate/endCrawlDatedeprecation are new from changelog Jun 2026 (note: crawl-date params silently ignored from Apr 15 2026 — do not build portfolio filters on them). - Contents: Highlights (10x token-efficient), summaries, full text; Dynamic Highlights −95% tokens (preview). BM25 still in stack with 50% memory reduction (BM25 post, Tom An).
7d. Stack a candidate should know (adds to file 08's table)
Rust (vector DB, highlights server — with the C++ hotspot exception), TypeScript + Playwright + CDP (rendering), Python→Rust migration precedent, S3 + Lance + Ray Data + Nix/K8s/Ray, ClickHouse/Kafka/Flink/lakehouse (Data Systems JD), custom HTML parser, distributed machine/IP crawl fleet, torch/tch-rs + rayon (highlights history), tantivy/HNSW-equivalents for the portfolio (not Exa's prod), criterion/hyperfine/flamegraph culture. What Exa runs for frontier politeness/JS-rendering internals is not public — say so in interviews rather than guessing.
8. Quotable Scale / Storage / Scheduling Statements (Copy-Paste With Links)
- "Our crawlers track over 500 billion urls" — Series C post, May 20 2026.
- "We serve 80B pages, track 1.4T urls, and are on track to be Google-scale in early 2027… Google ~1T, Bing ~500B, Yandex ~200B. Brave… 40B… Most of the web is trash… crawl way more than you serve… Bing ~30k/s… Google ~500k/s [peak QPS]… already starting to provision for Bing-scale traffic… Within 2 years, agents will search at many millions per second." — Will Bryk, LinkedIn Aug 3 2026 (login may gate; backup: RuntimeWire Aug 3 2026). All figures are Exa's own estimates — label them as such.
- "Globally-sourced index of 1.4 trillion URLs and 100 billion documents" + "Exa retains none of Firefox's query data" — Firefox post, Aug 17 2026.
- "We now crawl + parse tens of billions of webpages and refresh them every minute" — Exa 2.0, Oct 10 2025.
- "We track, index, crawl, and monitor billions of webpages and serve them at high throughput" + "train… crawl billions of documents per day, build vector DBs from scratch for 10k+ QPS… on track to exceed Google-scale traffic and index size in 2027" — About + homepage. Note: "billions/day" vs. "100M+/day" in JD — JD is the team target/floor; site is the fleet claim. Do not conflate.
- "The number of documents on the web is massive (many petabytes)… crawl them across a distributed network of machines and IPs… custom-built html parser… store in s3" — next-gen search, Mar 11 2025.
- "Hundreds of billions of pages, petabytes of raw content before any processing" — exa-d, Jan 13 2026.
- "Search billions of vectors in under 100ms… using less memory than a gaming PC… Handle >500 queries per second" — vector DB, Dec 2024.
- Scheduling-adjacent (no direct crawl-scheduler numbers are public): use exa-d's "compute missing or invalid columns… backfills and incremental updates follow the same codepath" (exa-d) and Monitors' "interval (minimum 1 hour)… anchored to creation… date filtering and semantic dedup" (Monitors guide) as the closest public scheduling semantics. There is no public statement of Exa's recrawl periods, politeness budgets, or frontier algorithm — mark any such claim [UNCERTAIN].
9. Questions to Ask in the Interview (Smart, Non-Generic, Crawl-Specific)
Each references a public fact (link) so it reads as research, not flattery. Pick 4–5; never run the whole list.
- You track 1.4T URLs but serve 80B pages (post) — what decides the cut, and how does that selection model feed back into crawl priority vs. post-crawl filtering?
- exa-d unifies backfills and incremental updates (post) — where does the crawler's freshness signal enter that column graph, and what breaks first when a news-heavy vertical needs minute-level refresh (Exa 2.0 claim)?
- Canon cancels losing branches automatically (post) — does anything analogous exist on the crawl side (e.g., killing low-yield frontier expansions mid-cycle), or is crawl budgeting still open-loop?
- Your vector DB beat HNSW on sharding/filters/cost (post) — did the crawl/index partitioning co-evolve with cluster sharding, or is cluster assignment purely post-ingest?
- Matryoshka-256 + binary quantization (post) trades recall for memory — how do you detect when that tradeoff silently drops a vertical (e.g., papers vs. homepages), and what recrawl/re-embed lever do you pull?
- Firefox selection hinged on multilingual evals + latency profiling + privacy (case study) — which of the three stressed the crawl/index most, and what changed in the pipeline afterward?
- Grounding with Exa on Vertex (post) puts Highlights in Gemini's context — how do you keep Highlights faithful when source pages are JS-rendered or paywalled-snippeted, and where does that feedback reach the renderer fleet?
- Deep/Deep Max fan out dozens of sub-searches (Deep Max) — how do you provision QPS for spiky agent/RL traffic (your Bing-scale note (LinkedIn)) without letting adversarial query patterns blow up crawl-adjacent serving costs?
- Singapore owns retrieval-stack/web-scale infra (SG post, EDB) — how is crawl ownership split between SF and SG (frontier vs. rendering vs. pipelines), and where would this role's first project land?
- The JD says "100M+ pages/day" but the fleet claim is "billions/day" (JD vs. About) — is the JD number the per-team/shard target, and what metric actually owns crawl health week to week?
- "Most of the web is trash" (LinkedIn) — how do you keep quality models from over-fitting to head domains, and what's the long-tail recall check before a quality-model ship?
- Monitors promise semantic dedup + date filtering (guide) — is that dedup shared with the core index pipeline or a serving-layer add-on, and what did you learn about near-dup at 80B scale?
10. Gap Analysis — What File 08 Should Have Covered but Did Not
- Missed launches: Exa 2.0 (Oct 2025), Company/People Search launches (Jan 2026), Monitors (Mar 2026), Deep Max (Apr 2026), Google partnership (Apr 2026), Exa Agent + Connect (Jun 2026), publications index + category deprecations (Jul 2026), Agent-in-MCP (Jul 2026), Dynamic Highlights (Aug 2026). File 08 named some as products but not as dated launches with crawl implications.
- Missed media: NYSE/Megawna AI 40 (Aug 2026), BI "rebellious engineers" (Mar 2026 — the single best culture/hiring source), Bryk's Aug 3 2026 scale/QPS LinkedIn post, SG-launch LinkedIn post. File 08 had the prestige podcasts but not the hiring-manager-readable press.
- Headcount precision: file 08's "~100" is stale/midpoint without sourcing the spread (80 → ~200 across Mar→Sep 2026, five conflicting sources). Needed the range + source-quality ladder (§5).
- OSS hardness: file 08 gave repo names but no stars/dates/beta headers (MCP ~5k stars, exa-py Agent Monitors beta, agent-skills Jul 2026). A hiring manager can check these; numbers matter.
- Interview loop: file 08 inferred Firecrawl/Perplexity analogs but missed the Exa-specific third-party loop reports (techinterview.org ×2, Nora) and under-weighted the 1–2 day SF working trial that BI confirms from Bryk himself.
- Hiring-manager attribution: file 08 named Tyler Killian a bit too confidently from an About blurb. No JD names a manager — this file downgrades that to [UNCERTAIN] with a wider team map.
- Technical depth: file 08 summarized vector-DB tricks but missed Canon's runtime semantics, exa-d's Ray Actor/GPU-memory detail, the C++ hotspot exception, category deprecations, crawl-date param removal, and the 500B→1.4T→80B metric disentanglement (tracked vs. served).
- Quotables: file 08 had scale numbers but not the exact sentences to cite (§8) with primary links and estimate-caveats.
- Finance vertical shift: NYSE interview's coding/GTM → finance (banks/PE/hedge funds) pivot is new GTM context for "why crawl quality pays" narratives — absent from file 08.
- Jeff Wang + crawl-team voices: still a gap after both files — no public Wang interview or crawl-engineer talk found. Flagged explicitly so a third pass doesn't assume coverage.
11. Source List (Primary First)
Exa blog/changelog/docs: blog index · Dynamic Highlights · Firefox · publications · Agent · Series C · RL outcomes · Google partnership · Deep Max · Canon · Singapore · WebCode · exa-d · Deep revamp · Instant · Exa 2.0 · People benchmarks · Series B · Exacluster · BM25 · next-gen search · vector DB · highlights server · changelog · Search API guide · Agent guide · Connect · Monitors guide · MCP · pricing · About · careers · evals · Firefox case study · Google Cloud integration.
Jobs: SF crawler · SG crawler · Built In SF · Built In SG.
Media/investors/press: a16z episode · a16z audio · a16z memo · SuperAI YouTube · NYSE YouTube · Knuckle Up episode · Knuckle Up writeup · Latent Space · BI Singapore/rebellious · EDB · Mozilla · Bloomberg · Lightspeed · SiliconANGLE · RuntimeWire 80B · Bryk LinkedIn scale post · Bryk LinkedIn SG post.
OSS/community: exa-mcp-server · exa-py · agent-skills · Discord · X @ExaAILabs · X @WilliamBryk.
Third-party (use with caveats): techinterview trial · techinterview guide · Nora guide · Crustdata · SVIC profile · YC directory · JobsByCulture.
End. File 08 untouched. Re-verify Ashby + careers page before applying — listings and headcount move fast at this stage.
JD Gap Analysis & the "Overqualified" Plan
Checked live against the Ashby posting API on 2026-09-12. The kit targets the Web Crawling role; this file also covers the Backend role you linked.
1. Which role you actually sent
| URL | Actual title | Location | Comp |
|---|---|---|---|
41eb773d-9909-422c-b6b8-5bbdc407d318 (the link you sent) |
Software Engineer, Backend | SF, in-person | $180K–$350K + equity |
0298c908-e2ff-4dbb-a8cc-4899a9f6737a |
Software Engineer, Web Crawling | SF, in-person | $180K–$350K + equity |
9679c963-9d4b-4cfe-8bd9-45ab5d5e7b90 |
Software Engineer, Web Crawling | Singapore | SGD 90K–300K + equity |
The Backend role is broader: high-throughput low-latency systems, data pipelines at millions of docs/day, and optional projects like Google-level keyword search over 10B pages or a custom vector DB over 1B vectors in <100ms. The 14-doc kit is built for the Web Crawling role, and its files 06–07 already stretch into the Backend surface. Both roles sponsor visas (STEM OPT/OPT/H1B/O1/E3).
2. The JDs, verbatim
Web Crawling — Who you are
- Extensive experience building and scaling web crawlers, or would be excited to ramp up very quickly
- Experience with some high performance language (C++, Rust, etc.)
- Familiar with TypeScript, Playwright, modern web design, CDP (Chrome DevTools Protocol)
- Comfortable optimizing a system to an exceptional degree
- Care about the problem of finding high quality knowledge
Web Crawling — What you could do
- Build a distributed crawler handling 100M+ pages/day
- Optimize crawl politeness and rate limiting across thousands of domains
- Design systems to detect and handle dynamic content, JavaScript rendering, and anti-bot measures
- Create intelligent crawl scheduling and prioritization algorithms for maximum coverage efficiency
Backend — Desired experience
- Writing and maintaining high throughput, low latency systems
- Building data processing pipelines that deal with millions of documents per day
- Optimizing a system to an exceptional degree
- Plus: experience in a high performance language (C++, Rust, etc.)
3. Coverage matrix: kit vs JD
| JD requirement | Kit coverage | Status |
|---|---|---|
| High-performance language (Rust) | 01–03, 10 (14 code patterns + Cargo.toml), 09 | Exceeds |
| Distributed crawler at 100M+ pages/day | 04 (frontier), 06 (queues, partitioning, backpressure), 09 Project A | Meets (design); Gap in proven scale |
| Crawl politeness + rate limiting across thousands of domains | 04 (RFC 9309, governor, crawl-delay, 429/backoff), 10 pattern 3–5 | Exceeds (design + code) |
| Dynamic content, JS rendering, CDP | 05 (CDP, chromiumoxide, SPA-shell detection), 09 Project B | Meets |
| TypeScript + Playwright + modern web design | 05 covers Playwright/CDP concepts and the TS baseline, but there is no TypeScript learning path | Gap (G-1) |
| Anti-bot measures: detect and handle | 05 frames rendering only; no bot-detection/resilience material | Gap (G-2) |
| Intelligent crawl scheduling + prioritization | 04 frontier priorities; no deep scheduling/freshness material | Partial (G-3) |
| Millions of documents/day pipelines (Backend) | 06 (queues), 07 (parse/index), 09 Project C | Partial (G-4) |
| Keyword search over 10B pages; vector DB 1B vectors <100ms (Backend examples) | 07 (Tantivy, ANN/vector DBs at small scale) | Gap (G-5) for "built my own index" proof |
| Optimizing a system to an exceptional degree | 06 (flamegraph, samply, criterion, hyperfine), 10 (perf patterns) | Meets |
| Care about high-quality knowledge | 07 (evals, nDCG), 08/14 (Exa reading list), 12 (public signal) | Exceeds (if you publish) |
4. Verdict: is the roadmap more than enough?
As a curriculum, yes — it exceeds both JDs. Every named technology is covered, most with code patterns, project specs, and verified versions. No more study material is required to understand what these jobs need.
For "overqualified", no — because that is an evidence problem, not a study problem. The crawling JD says "extensive experience building and scaling web crawlers". A hiring bar at Exa is not "knows Tokio" — it is "ran something large, watched it break, fixed it, can prove the numbers". The kit gives you the ability; only operation at real numbers and public proof produce the qualification. Close the 5 gaps below, then run the proof-of-scale plan in §6.
5. Gap-fill list (add to the kit)
Implemented in file 16 (Rust mastery path) and file 17 (overqualified project editions); the notes below are the reference requirements those files build on.
G-1: TypeScript + Playwright learning path (needed: familiar with TS/Playwright)
| Resource | Link | Use |
|---|---|---|
| TypeScript Handbook | https://www.typescriptlang.org/docs/handbook/intro.html | Types, generics, narrowing — 3–4 evenings |
| Total TypeScript (Matt Pocock) free tutorials + paid course | https://www.totaltypescript.com/ | Fastest route to productive TS |
| Playwright docs: Getting Started | https://playwright.dev/docs/intro | TS-first test/browser API |
| Playwright: Network, Auto-waiting, Locators | https://playwright.dev/docs/network · https://playwright.dev/docs/actionability | The exact skills the JD names |
| Build a TS Playwright sidecar for Project B | 09 Project B stretch | Ship a browser-worker/ service in TS that the Rust scheduler calls over HTTP — mirrors Exa's mixed-language reality |
G-2: Anti-bot detection & resilience (needed: "detect and handle anti-bot measures")
Frame this as detection + graceful handling, never evasion: understand signals, back off, prefer permitted paths, escalate to official APIs when available.
| Topic | Where to learn | Note |
|---|---|---|
| How bot detection works (challenges, fingerprint surfaces, JS challenges) | Cloudflare bot-management docs; "bot detection" chapters in Zyte/Scrapfly engineering blogs | Study defensively; write down a detection taxonomy |
| Headless/browser signals (navigator, WebGL, fonts, timing) | Playwright docs (browser contexts, user-agent), web.dev | Use for parity testing of your renderer |
| Challenge handling policy | robots.txt + ToS + RFC 9309 semantics | Back off on 403/429/challenge pages; record as a metric, not a defeat |
| Distributed resilience (sessions, cookies, egress pools) | 06 (queues/backpressure) + 04 (politeness) | Egress diversity is legitimate in production crawling; document rate contracts per host |
G-3: Crawl scheduling & prioritization algorithms (needed: "maximum coverage efficiency")
| Topic | Resource |
|---|---|
| Frontier + recrawl theory | IR Book ch. 20 (https://nlp.stanford.edu/IR-book/html/htmledition/web-crawling-and-indexes-1.html) — read the scheduling sections twice |
| Change-rate estimation + freshness | Cho & Garcia-Molina, "Effective page refresh policies" — implement a simple Poisson estimator |
| Online page importance (OPIC) | Abiteboul et al. — implement 30 lines, benchmark vs FIFO |
| Sitemaps + change discovery | sitemaps.org protocol; sitemap lastmod as a scheduling signal |
| Bandit/RL scheduling | Exa's RL-search blog (https://exa.ai/blog/rl-search-outcomes) + a simple epsilon-greedy prioritizer in Project A |
G-4: Document pipelines at millions/day (Backend-first)
| Topic | Resource |
|---|---|
| Columnar formats + zero-copy | Arrow/Parquet docs; Lance (https://lancedb.com) |
| How Exa runs this | exa-d post (https://exa.ai/blog/exa-d) and Canon (https://exa.ai/blog/composing-a-search-engine) |
| Batch/stream engines | Ray Data tutorial; state handling in 06 |
| Stretch project | Rebuild a mini exa-d: crawl → normalize → columnar store → backfill patch on recrawl, measure documents/sec |
G-5: Build-your-own retrieval index (Backend stretch)
| Topic | Resource |
|---|---|
| Exa's approach | https://exa.ai/blog/building-web-scale-vector-db (Matryoshka, binary quantization, SIMD) |
| ANN fundamentals | HNSW paper, FAISS wiki, hnsw_rs/usearch Rust crates |
| Stretch project | Rust ANN index over 1M embeddings: recall@10 vs latency curve, <100ms target, committed benchmark |
6. The "overqualified" proof-of-scale plan (the part the kit cannot do for you)
| # | Evidence | Pass criteria |
|---|---|---|
| 1 | Continuous crawl operation | Project A runs 30+ days unattended on a permissive corpus; uptime + restart log published |
| 2 | Real numbers | 5–10M pages crawled total; throughput, p50/p99 latency, error taxonomy, bytes/GPU-hour published |
| 3 | Adversarial conditions | Fixture server exercises 429/403/5xx/challenge/timeouts; behavior documented + tested |
| 4 | Politeness audit | Per-host rate contracts, robots compliance report generated from crawl logs |
| 5 | Hybrid renderer | Project B with measured browser-use ratio and cost delta (HTTP vs render) |
| 6 | Scheduling | OPIC/priority scheduler beats FIFO on coverage-per-fetch in a reproducible benchmark |
| 7 | Retrieval | Project C with recall@3 ≥ 0.75 and p95 < 200ms; a second eval on a public set (BEIR subset) |
| 8 | Public writing | 4 posts: architecture, benchmark methodology, hybrid routing, crawl-to-search — one on HN/r/rust |
| 9 | Open source | 1–2 merged PRs (spider-rs, chromiumoxide, tokio ecosystem, exa-labs) |
| 10 | Referrals | 1 employee referral + 2 warm intros (Discord, OSS review threads, X) |
| 11 | Interview reps | 10+ mock systems-design answers using file 11; 3 recorded walkthroughs of your repos |
| 12 | Tailored application | Resume mirrors JD language; cover note cites 2 Exa blog posts + your numbers |
7. What NOT to do
- Do not add C++. The JD says "C++, Rust, etc." — Rust satisfies it; C++ dilutes time for no extra signal.
- Do not chase ML training. Neither JD asks for model training; 07's retrieval/evals are the useful edge.
- Do not extend the 12-week plan with more courses. After week 12, marginal knowledge has near-zero return; proof and distribution do not.
- Do not over-index on anti-bot evasion tooling. The framing in file 08 §legal stands: politeness and quality are the product.
Rust Mastery Path (Overqualified Edition)
Audience: developer following this kit toward a Web Crawling Engineer role at Exa (exa.ai). Date: September 2026. Toolchain: Rust stable 1.98.x, edition 2024. Relationship to kit: files 01 (fundamentals) and 02 (async/Tokio) are the BASE — "enough to ship." This file is the path beyond them to genuinely strong. File 09 projects are WHERE you prove each level. File 15 is WHY overqualified = public evidence, not more courses. Constraint honored: this file is the only file written. No existing files modified. Resource versions below verified Sep 2026 via web search during writing (see per-level table). Where file 09's 2026-09-11 audit already pinned a version, that value is reused and noted.
How to use this (levels, weekly time budget, relationship to files 01/02, honest total hours)
- What this is: six levels, each with a "you are here when" mastery bar, exercises with concrete acceptance criteria, and one proof artifact that lands in a repo or writeup. No level is done until its artifact exists and is linked from a README.
- What this is not: a course list. Each level names ONE primary source and a short list of drills. If you catch yourself on a third book for the same level, you are collecting, not learning (see Traps).
- Relationship to 01/02: 01 = Level 1's "ship it" subset (Book ch. 1-13, Rustlings, thiserror/anyhow, first proptest, first criterion bench). 02 = Level 6's "operate it" subset (Tokio tutorial, semaphore/JoinSet fetcher, timeouts, shutdown, tracing). This file tells you what "done properly" and "deep" mean on top of those, and when to circle back: finish 01's exit criteria first, then interleave Levels 2-4 with projects; treat 02 as Level 6's prerequisite, not its replacement.
- Weekly time budget: assumes 12-15 focused h/week (same as the 12-week plan). Mastery drills run as the morning track (~40% of time); file 09 projects run as the build track (~60%). The schedule section maps this onto weeks 1-12 without moving any project deadline.
- Honest total hours (measured, not marketed):
- L1 ~35-40h (mostly already inside file 01's Phase 1; +10h for the "done properly" delta)
- L2 ~25-30h · L3 ~25-30h · L4 ~20-25h · L5 ~15-20h · L6 ~20-25h (on top of 02)
- Core total: ~140-170h over weeks 1-12, of which ~80h overlap with work you are already doing (01/02/09). Net-new load: ~60-90h, i.e. the morning track.
- Extension weeks 13-16 (optional, deepest material): +40-50h. That is what "overqualified" actually costs. There is no version of this that costs 20 hours.
Level 1 — Fundamentals done properly
Primary source: The Book ch. 1-13 + Rustlings (file 01 core path). This level = file 01's exit criteria PLUS the lifetime/error/iterator depth most people fake.
You are here when: you can (a) fix any borrow-checker error in your own code without reaching for .clone() first and explain why the fix is correct (whose lifetime ended, who owns what); (b) write a lifetime-annotated function signature from scratch (fn longest<'a>(x: &'a str, y: &'a str) -> &'a str) and state when elision applies vs when it doesn't; (c) look at any Result/Option chain and rewrite it with ?, map/and_then, or a custom error without changing behavior.
Exercises (acceptance criteria are the definition of done)
- Borrow-checker kata (5 programs). Write five small programs that each trigger one of: E0382 (use of moved value), E0502 (mutable+immutable borrow), E0515 (returning reference to temporary), E0597 (borrowed value does not live long enough), E0499 (double mutable borrow). For each: keep the compiler error output, then commit the minimal fix.
- Acceptance: repo dir
l1-borrows/with 5errN.rs(failing) + 5fixN.rs(compiling), each fix ≤5 changed lines, each with a 2-line comment naming the rule violated.cargo buildgreen on all fixes.
- Acceptance: repo dir
- Lifetime elision drill. Take 10 std/tokio/reqwest signatures using elision (e.g.
fn get(&self, k: &str) -> Option<&V>) and write out the fully-elided-expanded form; then write 3 functions where elision is insufficient (multiple input lifetimes with output ambiguity, struct holding refs,implblock with named lifetimes) and annotate them.- Acceptance:
lifetimes.mdwith all 10 expansions correct (check withcargo expandor rust-analyzer hover) + 3 compiling examples + one sentence each for why elision failed.
- Acceptance:
- Result/Option rewrite. Take the
robots-checkexercise from file 01 and rewrite all error paths twice: once with explicitmatch, once with combinators/?. Addthiserrorenum with ≥4 variants and#[from]conversions.- Acceptance: both versions pass the same test suite; combinator version has zero
matchonResult/Optionin non-test code (verify by review);cargo clippy -- -D warningsclean.
- Acceptance: both versions pass the same test suite; combinator version has zero
- Iterator zero-alloc pass. Rewrite the
linksdeduper (file 01 ex. 4) using only iterator adapters to the collection boundary (onecollect()at the end). No indexed loops, nopushin aforloop.- Acceptance: identical output on the file-01 fixture (byte-diff test); benchmark (criterion or hyperfine) shows no regression vs the loop version.
Proof artifact: crawlkit repo from file 01 passing file 01's Phase 1 exit criteria, plus l1-borrows/ and lifetimes.md committed. This is the artifact every later level builds on — do not skip it.
Level 2 — Idiomatic intermediate
Primary source: Rust for Rustaceans (Jon Gjengset, No Starch, Dec 2021, 280pp — still current, no 2nd ed. as of Sep 2026; errata live, latest entry Jan 2026). Read ch. 1-3 (foundations, types, interfaces) + ch. 5-6 (error handling, testing) first; async ch. later with L6. Secondary: Effective Rust items 1-15 (types, traits, errors) as daily 10-minute reads. Builds on: file 01 error policy (thiserror in libs, anyhow in bins).
You are here when: you can (a) argue generics-vs-trait-objects for a concrete crawler seam (e.g. "fetch backends") with the cost model (monomorphization/bloat vs vtable/indirection, object-safety constraints) and pick correctly; (b) design an error enum a caller can actually match on (no String payloads, no leaked internals); (c) write a builder that makes invalid states unrepresentable at compile time.
Exercises
- Generics vs
dyndecision doc + both implementations. Define aFetcherabstraction with two backends (in-memory fixture, reqwest). Implement it once with generics (struct Crawler<F: Fetcher>) and once withdyn Fetcher. Measure: binary size (cargo bloatorls -lrelease bins), and write the tradeoff note.- Acceptance: both compile, same integration tests pass against both;
TRADEOFF.md≤1 page states when you'd pick each (rule of thumb: ≤3 known backends + hot path → generics; plugin-style/open set →dyn); names one object-safety constraint you hit or deliberately avoided (async fnin trait → stable since 1.75 only forimpl Trait,dynstill needs boxing orasync-trait— state which you used).
- Acceptance: both compile, same integration tests pass against both;
- Crawler-grade error taxonomy. Design
CrawlErrorfor project A (file 09 §A.5): variantsRobotsBlocked,RateLimited { retry_after },Retryable { status },Permanent { status },Timeout,Parse,Storage(#[source] _). Every variant mappable fromreqwest/iovia#[from]where sensible; binary boundary adds.context().- Acceptance: unit test matches on each variant (proves callers can match); no
anyhowin library signature;cargo docrenders clean variant docs; clippy clean.
- Acceptance: unit test matches on each variant (proves callers can match); no
- Typed builder.
CrawlConfigbuilder (file 09 §A.3 fields) with typestate or validated-build:build()returnsResult<_, ConfigError>and refusesglobal_concurrency == 0,host_rps <= 0.0,max_depthunbounded-without-cap. Defaults match file 09 (global_concurrency = 32,default_host_rps = 1.0).- Acceptance:
trybuildor unit tests prove invalid configs fail atbuild()with a named error (not a panic); README shows 5-line construction example; doc-tests pass.
- Acceptance:
- Workspace split. Split one project into a workspace:
crawler-core(lib: normalize, politeness, errors) +crawler-bin(anyhow + clap). Correctpub/pub(crate)boundaries, no circular deps.- Acceptance:
cargo workspaces-clean layout, lib has zero binary-only deps (no clap/anyhow in libCargo.toml);cargo test --workspacegreen.
- Acceptance:
Proof artifact: crawler-core library crate: error taxonomy + typed builder + generic fetch abstraction, with docs and tests. Reused verbatim by project A — this is the point.
Level 3 — Advanced systems Rust
Primary source: Rust Atomics and Locks (Mara Bos, O'Reilly Jan 2023; free online, read ch. 1-5 then 6-9 as needed). Secondary: The Rustonomicon (unsafe rules) + std::sync::atomic docs. Defer Pin/variance/HRTB until the L3 stretch — they matter for L6, not for shipping a queue.
Builds on: file 01 rule "zero unsafe" for the frontier — this level teaches you when that rule bends and what the receipt (Miri/loom) looks like.
You are here when: you can (a) explain Relaxed vs Acquire/Release vs SeqCst with a concrete litmus (message-passing flag) and say which your queue uses and why; (b) state the three things unsafe promises you must uphold (validity, aliasing/Stacked-vs-Tree-Borrows, initialization) for any block you write; (c) read a Pin error and know whether the fix is "pin it" or "restructure so nothing needs pinning."
Exercises
- Interior-mutability map. Build the per-host state map (file 02 §per-host politeness) three ways:
Mutex<HashMap>,RwLock<HashMap>,DashMap. Benchmark read-heavy access with criterion.- Acceptance: bench table (all three, same workload, N≥100k ops) committed; one-paragraph choice with numbers; no
MutexGuardheld across.awaitin the async variant (grep-able by review).
- Acceptance: bench table (all three, same workload, N≥100k ops) committed; one-paragraph choice with numbers; no
- Bounded MPMC queue (the capstone). Implement a bounded multi-producer multi-consumer queue for URLs, first with
Mutex<VecDeque>+Condvar, then a lock-free orcrossbeam-based version. Compare throughput and p99 under 8 producers/8 consumers.- Acceptance: both pass a stress test (1M items, no loss, no dup, FIFO-per-producer or documented ordering); bench table committed; lock-free version annotated with the ordering (
Acquire/Release) on every atomic op and a one-line justification each.
- Acceptance: both pass a stress test (1M items, no loss, no dup, FIFO-per-producer or documented ordering); bench table committed; lock-free version annotated with the ordering (
- Miri + loom receipts. Run the queue from ex. 2 under Miri (
cargo +nightly miri test, nightly required) and write loom permutation tests (RUSTFLAGS="--cfg loom" cargo test --release) for the spinlock/channel core (ch. 4-5 of Atomics and Locks).- Acceptance: Miri run log committed (clean, or failures triaged with issue link); loom tests exist and pass; a
SOUNDNESS.md≤1 page states: whatunsafe(if any) you used, its safety contract, what Miri does not prove (soundness over all callers — cite the Miri README caveat), and why thestd-only variant is the default in production code.
- Acceptance: Miri run log committed (clean, or failures triaged with issue link); loom tests exist and pass; a
- Memory-layout audit (stretch, do after ex. 2).
#[repr(C)]vs default layout onFrontierItem-shaped struct: measure size/align withstd::mem::{size_of, align_of}, reorder fields, document bytes saved. Readcargo expandoutput for one derive.- Acceptance: before/after sizes in a table; reordering justified field-by-field; no behavior change (tests green).
Proof artifact: bounded queue crate + SOUNDNESS.md + Miri log + loom tests. If you use any unsafe in project A/B later, this artifact is the license — link it from the PR.
Level 4 — Performance engineering
Primary source: The Rust Performance Book (Nicholas Nethercote; free, mdbook; repo actively maintained — author posting compiler-perf updates through Jul 2026). Read the benchmarking/profiling chapters first, optimization chapters second, and never in reverse. Secondary: criterion book + divan docs for harness specifics.
Builds on: file 01 ex. 8 (bench-normalize) and file 09 project D methodology (3 reps, median, machine spec).
You are here when: you can (a) take any "it's slow" report and return a flamegraph pointing at the actual hotspot within an hour, on macOS or Linux; (b) write a benchmark you trust (warmup, black_box, statistical comparison, no optimizer-fooled microbench); (c) say what the allocator, the branch predictor, and the I/O pattern each contribute — with numbers, not adjectives.
Exercises
- Profiler setup (macOS-first). Install
samply(cargo install samply) andcargo-flamegraph. Create the[profile.profiling] inherits = "release", debug = truecargo profile. Profile thetitleextractor (file 01 ex. 3) on a 10MB HTML fixture.- Acceptance: one
samply recordprofile link or screenshot + one flamegraph SVG committed; top-3 functions by samples listed inPERF.mdwith % each; the profile was taken on theprofilingprofile (command line recorded).
- Acceptance: one
- Benchmark you trust. Port the ex. 8 URL-normalizer bench to criterion (stats, baseline) AND divan (ergonomics). Save baselines. Deliberately write one bad benchmark (no
black_box) and show it reporting a nonsense number (e.g. near-zero ns from dead-code elimination).- Acceptance: both harnesses run via
cargo bench; bad-vs-good comparison committed with the explanation of what the optimizer did;criterion --save-baselineworkflow documented in README.
- Acceptance: both harnesses run via
- Zero-copy parse pass (the capstone). Optimize the HTML title/meta extractor or URL normalizer:
Cow<str>to avoid allocation on the common path,memchr-style scanning instead of char-walking where it matters, reuse buffers across documents. Three reps, median reported, machine spec footnoted (file 09 §D.7 commands).- Acceptance: before/after table (
ops/s or docs/s | p50 | peak RSS | allocs— allocs via divanAllocProfilerordhat/heaptrack if available); speedup ≥1.3× OR a written conclusion that the code was already memory-bound with profiler evidence (either outcome accepted, vibes not accepted); nounsafe, no readability regression without a comment justifying it.
- Acceptance: before/after table (
- SIMD spike (bounded, 4h max). Check
std::simdstatus yourself (nightly-only as of 1.98 — verify, don't trust memory), then implement one byte-scan (e.g. find<a) with thewidecrate (stable) vs scalar. Benchmark.- Acceptance: table with scalar vs
widenumbers; one paragraph: when SIMD is worth it in a crawler (long predictable scans) vs not (branchy parse, small inputs); nightlystd::simdexplicitly NOT a project dependency (state why: stability).
- Acceptance: table with scalar vs
Proof artifact: PERF.md in the parser repo: profiler outputs + before/after benchmark table + machine spec + what you'd try next. This page is directly reusable in project D's writeup.
Level 5 — Production-grade Rust
Primary sources: Zero to Production ch. on tracing/config (file 02 table) + Effective Rust items 18-32 (panics, tooling, deps) + cargo-deny/mutants manuals. No single book — this level is checklist-driven by design. Builds on: file 02 observability drill (ex. 7) and file 09 shared repo conventions (fmt+clippy CI, tracing EnvFilter, thiserror/anyhow policy).
You are here when: you can (a) hand someone a repo where git clone && cargo test && cargo run works, CI is green, advisories are clean, and logs/metrics answer "what is it doing right now"; (b) point at any test and say which pyramid layer it is and what bug class it catches that the layer below cannot; (c) cut a semver-compatible release without breaking downstream (or explain why it's 0.x and what that promises).
Exercises (each is small; all are required)
- Tracing + metrics baseline.
tracing-subscriberEnvFilter +#[instrument]on every fetch path function (file 02 pattern);metricscounters from file 09 §A.2 (pages_fetched_total{status}, latency histogram,queue_depthgauge) exposed on/metrics.- Acceptance:
RUST_LOG=debugrun shows host/status/bytes fields;curl /metricsoutput pasted in README; span cardinality bounded (no URL-valued labels — review-checkable).
- Acceptance:
- Testing pyramid, one of each. On
normalize.rs: unit tests (edge cases), proptest (idempotencenormalize(normalize(u)) == normalize(u), never-panics on arbitrary bytes), snapshot test (insta) for CLI output, fuzz target (cargo-fuzz, nightly) running ≥10M execs or 15 min with corpus committed.- Acceptance:
cargo test+cargo fuzz run normalize -- -max_total_time=900both documented; proptest regression files committed; fuzzer found ≥1 crash OR ran clean with corpus + statement of coverage (cargo fuzz coverage); each test has a one-line comment naming the bug class it guards.
- Acceptance:
- Supply chain + mutation gates.
cargo deny check(advisories/licenses/bans) andcargo auditclean in CI;cargo mutants -f <core file>on the politeness module with kill-rate recorded.- Acceptance: CI config committed with both checks;
deny.tomlexists (even if minimal); mutants report committed: kill rate + one surviving mutant either killed with a new test or documented as equivalent-mutant with reasoning.
- Acceptance: CI config committed with both checks;
- Docs + semver + CI.
cargo docwith examples that compile (doc-tests), README quickstart verified from a clean clone (record the session), CHANGELOG started, version bump following semver, CI running fmt + clippy-D warnings+ test + doc + deny on every push.- Acceptance:
cargo doc --no-depswarning-free; fresh-clone quickstart ≤5 min (timed once, honestly); CHANGELOG entry for the release.
- Acceptance:
Proof artifacts: (a) CI badge green on project A repo with all four gates; (b) TESTING.md pyramid map (layer → files → bug classes → commands); (c) mutants report. All three linkable from resume.
Level 6 — Concurrency and async, deep
Prerequisite: file 02 complete through Exercise 7 (10k fetcher + shutdown + tokio-console drill). Primary sources: Tokio internals docs ("Async in depth", shutdown, bridging topics) + fasterthanlime "Pin and suffering" + Ryhl "Async: What is blocking?" + "Actors with Tokio" (all in file 02 table). Rust for Rustaceans async ch. + Atomics and Locks ch. 1 background as needed. This level converts "I can use Tokio" into "I can explain Tokio" — the Exa interview level.
You are here when: you can (a) draw Tokio's scheduler (multi-thread worker, LIFO slot + global queue, work-stealing, budget/yield every ~128 ops) from memory and say how it shows up in p99; (b) classify any select! branch as cancel-safe or not and restructure when it isn't; (c) design backpressure end-to-end (source → queue → workers → sink) with every bound named and justified.
Exercises
- Scheduler mental model, verified. Write the ≤200-word Pin note from file 02 ex. 8 (what breaks without
Pin, whyspawnneedsSend + 'static), then add: scheduler diagram (ASCII) + prediction experiment — block one worker withstd::thread::sleep(2s)in a task, observe in tokio-console (busy time, self-wake warnings), replace withtokio::time::sleep, re-observe.- Acceptance: note + diagram + before/after
report.mdwith both console observations (file 02 ex. 7 pattern); prediction written before running, scored after.
- Acceptance: note + diagram + before/after
- Cancellation audit. Take the project-A scheduler loop and label every
.awaitinsideselect!branches cancel-safe/unsafe per docs; fix one genuinely unsafe spot (e.g. checkpoint write mid-cancel) withtokio::select!(biased;)restructuring orspawn+ channel handoff.- Acceptance: audit table (branch → safe/unsafe → doc link) in code comments or
CANCELLATION.md; test kills a crawl mid-flight (SIGINT fixture) and asserts checkpoint integrity + exit 0; second-SIGINT force-exit ≤200ms (file 02 ex. 5 criteria).
- Acceptance: audit table (branch → safe/unsafe → doc link) in code comments or
- Backpressure design. Rebuild the Exercise-3 bounded worker pool (file 02) with all four bounds explicit and tunable: global concurrency (semaphore N), queue depth (mpsc bound), per-host rate (governor/delay), retry budget (attempts × backoff cap). Load-test to the knee: sweep concurrency 8→512 against the mock server, plot throughput vs p95.
- Acceptance: knee curve table committed (concurrency | req/s | p95 | timeouts | RSS); chosen defaults justified from the curve, not from vibes; queue-full behavior documented (block vs drop vs shed-load — pick one, prove it with a test that fills the queue).
- Bridging sync work. Move HTML parsing off the runtime:
spawn_blockingpool vs dedicated thread + channel vsrayon. Benchmark parse-heavy workload (10k docs) under each; record runtime task starvation (tokio-console busy %) for the in-async-parse baseline.- Acceptance: three-way table (parse docs/s | fetch p95 impact | busy %); production choice documented (default:
spawn_blockingunless measured otherwise); the "parse inside async fn" baseline kept as the cautionary number.
- Acceptance: three-way table (parse docs/s | fetch p95 impact | busy %); production choice documented (default:
Proof artifacts: (a) report.md scheduler experiment; (b) CANCELLATION.md audit + shutdown test; (c) knee-curve table feeding project A's config.toml defaults. All three are interview exhibits — know them cold.
Resource table per level (verified Sep 2026)
Checked via web search during writing. "File 09 audit" = the 2026-09-11 toolchain audit cited in file 09 §0 (newer than some search results — audit wins on conflicts). If a link rots, search the title — all of these are first-result stable.
| Level | Resource | Link | Version/status Sep 2026 | Cost |
|---|---|---|---|---|
| 1 | The Rust Programming Language ("The Book") | https://doc.rust-lang.org/stable/book/ | 2024 edition, tracks stable 1.9x | Free |
| 1 | Rustlings | https://rustlings.rust-lang.org/ | ~94 exercises | Free |
| 1 | 100 Exercises To Learn Rust | https://rust-exercises.com/100-exercises/ | Free (+PDF) | Free |
| 1 | Rust by Example | https://doc.rust-lang.org/rust-by-example/ | Reference | Free |
| 1 | Rust reference: behavior-considered-undefined | https://doc.rust-lang.org/reference/behavior-considered-undefined.html | Normative UB list (read with L3) | Free |
| 2 | Rust for Rustaceans (Gjengset) | https://rust-for-rustaceans.com/ / https://nostarch.com/rust-rustaceans | Dec 2021, 280pp, ISBN 9781718501850, $49.99 print / $39.99 ebook; no 2nd ed.; errata live (Jan 2026 entry) | Paid |
| 2 | Effective Rust (Drysdale) | https://www.lurklurk.org/effective-rust/ (mirror https://effective-rust.com/) | 35 items; free online (CC BY-NC-ND 4.0); O'Reilly print Apr 2024, 298pp | Free online |
| 2 | Rust Design Patterns | https://rust-unofficial.github.io/patterns/ | Living book (PDF avail.; pattern added Dec 2025); repo github.com/rust-unofficial/patterns | Free |
| 2 | cargo-expand (dtolnay) | https://github.com/dtolnay/cargo-expand | Macro-expansion debugger; cargo install cargo-expand |
Free |
| 3 | Rust Atomics and Locks (Mara Bos) | https://marabos.nl/atomics/ (also https://mara.nl/atomics) | O'Reilly Jan 2023, 250pp, ISBN 9781098119447; free online; code https://github.com/m-ou-se/rust-atomics-and-locks | Free online |
| 3 | The Rustonomicon | https://doc.rust-lang.org/nomicon/ | Unsafe rules reference; read after Atomics ch. 1-5 | Free |
| 3 | loom (tokio-rs) | https://github.com/tokio-rs/loom | 0.7.2; [target.'cfg(loom)'.dependencies] loom = "0.7"; RUSTFLAGS="--cfg loom" cargo test --release; caveat: SeqCst treated as AcqRel (false alarms possible), load-buffering incomplete |
Free |
| 3 | Miri (rust-lang) | https://github.com/rust-lang/miri | Nightly: rustup +nightly component add miri, cargo +nightly miri test; POPL Jan 2026 paper; does NOT prove soundness (README caveat) |
Free |
| 3 | std::sync::atomic docs | https://doc.rust-lang.org/std/sync/atomic/ | Ordering semantics reference | Free |
| 4 | The Rust Performance Book (Nethercote) | https://nnethercote.github.io/perf-book/ (repo https://github.com/nnethercote/perf-book) | Living mdbook, maintained (author compiler-perf posts through Jul 2026) | Free |
| 4 | criterion | https://github.com/bheisler/criterion.rs (book https://bheisler.github.io/criterion.rs/book/) | 0.8.2 (file 09 audit agrees); MSRV 1.86; features = ["html_reports"]; gnuplot needed for charts |
Free |
| 4 | divan (nvzqz) | https://github.com/nvzqz/divan | 0.1.21; MSRV 1.80; harness = false bench targets |
Free |
| 4 | samply (+ Firefox Profiler) | https://github.com/mstange/samply | 0.13.1 (Feb 2025); cargo install samply; samply record ./bin; macOS+Linux+Windows; needs [profile.profiling] inherits="release", debug=true |
Free |
| 4 | cargo-flamegraph | https://github.com/flamegraph-rs/flamegraph | cargo flamegraph (Linux-first; macOS works, samply preferred there per file 06) |
Free |
| 4 | hyperfine (sharkdp) | https://github.com/sharkdp/hyperfine | Whole-binary timing; brew install hyperfine |
Free |
| 4 | portable_simd / std::simd | https://github.com/rust-lang/portable-simd / https://doc.rust-lang.org/std/simd/ | Nightly-only as of stable 1.98.1 (issue #86656, #![feature(portable_simd)]); stable alternative: wide crate |
Nightly / Free |
| 4 | iai-callgrind | https://github.com/iai-callgrind/iai-callgrind | Instruction-count benches; stable CI numbers | Free |
| 5 | tracing / tracing-subscriber | https://docs.rs/tracing / https://docs.rs/tracing-subscriber | EnvFilter + #[instrument]; console-subscriber for tokio-console (file 02) |
Free |
| 5 | proptest | https://github.com/proptest-rs/proptest | 1.11.0 (file 01 agrees) | Free |
| 5 | cargo-fuzz + Rust Fuzz Book | https://github.com/rust-fuzz/cargo-fuzz / https://rust-fuzz.github.io/book/ | cargo-fuzz 0.13.2; nightly + LLVM sanitizer; x86-64/aarch64 Unix only | Free |
| 5 | cargo-mutants (manual: mutants.rs) | https://github.com/sourcefrog/cargo-mutants / https://mutants.rs/ | crates.io 25.3.1 (Sep 2026); cargo install --locked cargo-mutants; semi-active spare-time maintenance (Aug 2026 note) — usable, expect slow issue response |
Free |
| 5 | cargo-deny / cargo-audit | https://github.com/EmbarkStudios/cargo-deny / https://github.com/RustSec/rustsec | deny 0.20.2 / audit 0.22.2; MSRV 1.88; cargo deny check in CI |
Free |
| 5 | cargo-llvm-cov / cargo-nextest | https://github.com/taiki-e/cargo-llvm-cov / https://nexte.st/ | Coverage + fast runner (file 01 setup) | Free |
| 5 | insta / trybuild | https://github.com/mitsuhiko/insta / https://github.com/dtolnay/trybuild | Snapshot + compile-fail tests | Free |
| 6 | Tokio docs: async-in-depth, shutdown, bridging, testing | https://tokio.rs/tokio/tutorial/async https://tokio.rs/tokio/topics/shutdown https://tokio.rs/tokio/topics/bridging | Canonical; read in that order | Free |
| 6 | "Pin and suffering" (fasterthanlime) | https://fasterthanli.me/articles/pin-and-suffering | Best Pin/Waker mental model | Free |
| 6 | Ryhl: "Async: What is blocking?" + "Actors with Tokio" | https://ryhl.io/blog/async-what-is-blocking/ https://ryhl.io/blog/actors-with-tokio/ | The two production rules (10-100µs budget; bounded channels) | Free |
| 6 | Crust of Rust: async/await + Decrusting tokio | https://www.youtube.com/@jonhoo | 2h34m + 3h32m; internals報酬 (watch at 1.5×, pause at poll/pin) | Free |
| 6 | Rust for Rustaceans async ch. | (same book as L2) | Re-read here — it lands differently after file 02 | Paid (owned) |
| all | This Week in Rust / Rust Blog | https://this-week-in-rust.org/ https://blog.rust-lang.org/ | Stay on stable (1.97 Jul 2026 → 1.98 Sep 2026 cadence); watch CVE/security advisories (e.g. Cargo CVE-2026-5223, fixed 1.96) | Free |
Weekly schedule: weeks 1-12 + optional weeks 13-16
Constraint: project deadlines from file 09 do NOT move. Mastery = morning track (~5h/wk), projects = build track (~8-10h/wk). "File" = this kit's numbered files.
| Weeks | Morning track (this file) | Build track (unchanged) | Done when |
|---|---|---|---|
| 1-2 | L1: file 01 core path + the 4 L1 drills (borrows kata, elision, Result rewrite, iterator pass) | File 01 exercises → crawlkit repo |
File 01 exit criteria + L1 artifact |
| 3-4 | L2 start: Rustaceans ch. 1-3 + error taxonomy (ex. 2) + builder (ex. 3); L6 prereq: file 02 weeks 3-4 schedule verbatim | File 02 fetcher (10k) + project A scaffold (frontier/schema from 09 §A.4) | File 02 ex. 1-5 green; crawler-core lib compiles |
| 5-6 | L2 finish (generics-vs-dyn ex. 1, workspace ex. 4) + L3 start (Atomics ch. 1-3, interior-mutability map ex. 1) | Project A MVP (politeness + fetch + persist, 09 §A.2) | Project A crawls 1k pages across 20 hosts; L2 artifact merged into A |
| 7-8 | L3 core (queue ex. 2 + Miri/loom ex. 3) + L5 start (tracing/metrics ex. 1, pyramid ex. 2 on normalize) | Project A acceptance (§A.6) + project B scaffold (classifier + CDP pool) | A v0.1.0 tagged; SOUNDNESS.md + TESTING.md drafted |
| 9 | L4 core (samply/flamegraph ex. 1, trusted-bench ex. 2, zero-copy pass ex. 3) | Project B acceptance (09 §B.6) + project C scaffold (schema §C.3) | PERF.md with before/after table; B parity CSV committed |
| 10-11 | L5 finish (deny/audit/mutants ex. 3, docs/CI ex. 4) + L6 core (scheduler ex. 1, cancellation audit ex. 2) | Project C acceptance (recall@3 ≥ 0.75, p95 < 200ms, 09 §C.6) | C demo live; CI green with all gates; CANCELLATION.md |
| 12 | L6 finish (backpressure knee curve ex. 3, bridging ex. 4) feeding A's config.toml defaults |
Project D writeup (09 §D.8 template) reusing PERF.md + knee tables | D writeup published; proof checklist (next section) ≥8/10 |
| 13-14 (ext, optional) | L3 stretch: layout audit, lock-free queue v2 (Seqlock or MPSC from Atomics ch. 10), loom --max-preemptions sweeps; L4 stretch: SIMD wide pass on tokenizer/chunker, perf stat (cache-miss) drill on Linux |
Harden A: 30-day soak plan starts (file 15 §6 item 1); BEIR-subset second eval for C (file 15 §6 item 7) | Soak log live; second-eval numbers published |
| 15-16 (ext, optional) | L6 stretch: custom Future executor v2 (file 02 ex. 8, but with Waker + timer), tokio-rs/tracing source read, mini-RFC: propose one scheduler default change with data |
OSS PRs (file 15 §6 item 9: spider-rs / chromiumoxide / tokio ecosystem); public posts 3-4 (file 15 §6 item 8) | ≥1 merged OSS PR; benchmark post on HN/r/rust |
Total: core 12 weeks × ~13h = ~155h; extension +~45h. If time-pressed, cut in this order: L4 ex. 4 (SIMD) → L3 ex. 4 (layout) → extension weeks → L5 ex. 4 polish. Never cut: L1 artifact, L2 taxonomy, L3 Miri/loom, L4 before/after table, L6 cancellation audit — those five are the interview exhibits.
Proof-of-mastery checklist
8-12 evidence items for resume/interview, per spec. Each is a link, not a claim. Target: ≥8/10 by week 12, 10/10 with extension.
-
crawlkitrepo: file-01 exit criteria + L1l1-borrows/+lifetimes.md(L1 artifact) -
crawler-corelib: error taxonomy + typed builder + generic-vs-dyntradeoff note (L2 artifact, reused by project A) - Bounded queue +
SOUNDNESS.md+ Miri log + loom tests (L3 artifact; linked from any PR usingunsafe) -
PERF.md: flamegraph/samply outputs + before/after bench table + machine spec (L4 artifact; feeds project D) - Parser fuzz corpus + clean ≥15-min run log (or triaged crash) + proptest regression files (L5 pyramid)
- CI green: fmt + clippy
-D warnings+ test + doc +cargo deny check+ mutants kill-rate report (L5 gates) - Scheduler
report.md(tokio-console before/after) +CANCELLATION.mdaudit + shutdown test (L6 artifacts) - Knee-curve table justifying
config.tomldefaults (concurrency/queue/rate/retry) (L6 → project A) - Project D writeup: Rust-vs-Python + HTTP-vs-browser tables, N=3 reps, limitations section (reuses items 4 + 8)
- Merged OSS PR (spider-rs / chromiumoxide / tokio ecosystem / exa-labs) + one benchmark/architecture post (file 15 §6 items 8-9 — this is what makes it "overqualified" instead of "well-studied")
Common traps
- Tutorial hell (more input, no artifacts). Symptom: third book open, zero new commits. Rule: one primary source per level; no new source until the current level's artifact is committed.
unsafetoo early. Symptom:unsafein project code before L3 ex. 3 exists. Rule from file 01 stands through L2: zerounsafein projects until you can show a Miri log and aSOUNDNESS.md. 99% of crawler code never needs it.- Course collecting. Symptom: 5 video playlists, no finished exercises. Videos here are named per level and time-boxed (watch at 1.5×, with a drill attached). A watched video with no artifact counts as zero.
- Skipping profiling (optimizing by smell). Symptom: "I switched to X because it's faster" with no flamegraph. Rule: no perf claim without profiler output + bench table + machine spec. L4 ex. 3 accepts "already memory-bound, here's the evidence" — it never accepts vibes.
- Toy-project sprawl. Symptom: 9 repos, none meeting file 09 acceptance. Cap:
crawlkit+crawler-core+ queue drill + projects A-D. Every new repo must name which checklist item it serves or it doesn't get created. - Async before ownership is solid. File 01's rule, repeated because it bites: if borrow errors still end in
.clone()sprinkles, L6 will feel like drowning. L1 bar first, then Tokio deep. - Confusing "read about" with "can do under interview pressure." Close every level by explaining its artifact aloud in 5 minutes (record once per level). Stutter = not done, regardless of green CI.
Interview-ready topics
What Exa can probe from each level, with the one-line answer to have ready. One line each — the artifact behind it does the talking.
- L1 — move vs copy: "Move transfers ownership and invalidates the old binding;
Copyis a compiler-blessed memcpy allowed only when no destructor or aliasing hazard exists." - L1 — borrow rules: "One mutable XOR any number of immutable borrows, all within the owner's lifetime — the compiler enforces no-aliasing so safe code is data-race-free."
- L1 — lifetimes/elision: "Lifetimes name how long borrowed data stays valid; elision covers the single-input-single-output case, ambiguity (multiple inputs, stored refs) needs annotations."
- L1 —
Result/Option: "Optionis absence,Resultis failure-with-reason;?propagates withFromconversion, combinators transform without unwrapping." - L2 — generics vs trait objects: "Generics monomorphize (fast, bigger binary, closed set);
dyndispatches via vtable (smaller, flexible, object-safety rules) — I measured both for our fetcher seam." - L2 — error design: "Libraries return typed
thiserrorenums callers can match; binaries useanyhow+ context;Stringerrors are a bug because callers can't program against them." - L2 — zero-cost abstractions: "Iterator chains compile to the same loop you'd hand-write (LLVM sees through them) — I verified byte-identical output and no bench regression."
- L3 —
RelaxedvsAcquire/Release: "Relaxedis atomicity without ordering; release-store + acquire-load builds a happens-before edge (message-passing);SeqCstadds a global order you pay for and usually don't need." - L3 —
unsafecontract: "unsafemeans I uphold validity, aliasing (Tree Borrows), and initialization by hand — Miri checks my tests' executions, never soundness over all callers." - L3 —
Send/Sync: "Send= safe to move across threads,Sync= safe to share (&T: Send);Rc/Cellare neither,Arc/Mutexbuy it back with runtime checks." - L4 — profiling first: "I profile before optimizing — samply/flamegraph found the hotspot (top function, X% of samples), the bench table shows the delta, machine spec footnoted."
- L4 — trustworthy benches: "Warmup,
black_boxagainst dead-code elimination, statistical comparison vs baseline, N=3 medians — and I can show you a bad bench that lies." - L4 — zero-copy: "Borrow (
&str/Cow) instead of owning on the hot path; fewer allocs beats cleverer code — our parse pass cut allocs, here are the numbers." - L5 — testing pyramid: "Unit for logic, proptest for invariants over arbitrary input, fuzz for crashes on hostile bytes, loom/Miri for concurrency/UB — each catches what the layer below can't."
- L5 — supply chain: "
cargo deny+auditgate every push; mutants tell me whether my tests actually assert anything — kill rate X%, survivors triaged." - L6 — Tokio scheduler: "Multi-thread runtime, per-worker LIFO run queue + global queue with work-stealing, cooperative budget (~128 ops) — hogging a worker shows up as p99, I caught it in tokio-console."
- L6 — cancellation: "Dropping a future cancels it at the next
.await; only cancel-safe ops insideselect!branches, checkpoint writes go behind a handoff — audited, tested with SIGINT." - L6 — backpressure: "Every stage bounded (semaphore, channel depth, per-host rate, retry budget); overload blocks or sheds by policy, never OOMs — the knee curve picked our defaults."
- Closer (have ready regardless of level): "Our crawler holds 1 rps/host politeness under 32-way global concurrency with measured p50/p95, resumes from SQLite after kill -9 with <1% refetch, and the writeup shows when HTTP suffices vs when we pay for the browser — here's the table."
17 — Overqualified Project Editions (A+ through F)
Audience: builder who has finished or scoped 09 Projects A–D and wants each project to read as "overqualified" against the live JD bullets. Date: September 2026. Builds on 09 (specs A–D), 10 (code patterns), 15 (JD gap analysis + G-1…G-5), 04 (politeness/frontier), 05 (browser/CDP), 07 (retrieval). This file is an upgrade layer only — it references those files by section instead of restating them. Constraint honored: no existing files modified. Crate versions verified Sep 2026 via bounded websearch (see Appendix); where verification was inconclusive, the 09/13 pinned value stands and is marked UNVERIFIED/verify-at-build.
0. The principle: what "overqualified" evidence looks like
Hiring bar (from 15 §2, §6): Exa's Web Crawling JD asks for extensive experience building and scaling crawlers — 100M+ pages/day, politeness across thousands of domains, dynamic content/JS/CDP, intelligent scheduling — plus TS/Playwright familiarity and "optimize to an exceptional degree". Backend JD adds high-throughput low-latency systems, pipelines at millions of docs/day, Google-level keyword search over 10B pages, custom vector DB over 1B vectors in <100ms. The kit (15 §3) already exceeds on knowledge; "overqualified" is an evidence problem, not a study problem (15 §4, §6).
Overqualified = scale model + measured numbers + adversarial robustness + public artifacts, applied to every project:
- Scale model. Nobody believes a laptop crawled 100M pages/day. What a reviewer believes: a measured local baseline (pages/s, p50/p95, RSS, error taxonomy on fixtures) + a credible capacity/extrapolation model (bandwidth, worker count, concurrency math, sharding, cost) that derives 100M/day (≈1,160 pages/s sustained) from your numbers with stated assumptions. Same for 10B-page keyword search and 1B-vector <100ms: small measured prototype + sharding/memory/latency math (see A+, C+, F).
- Measured numbers, N≥3. Every claim is a table: median across ≥3 reps, machine spec, versions, fixture description. No eyeballed Activity Monitor numbers (09 D.6). Before/after tables for every optimization ("exceptional degree" = deltas, not adjectives).
- Adversarial robustness. Fixture fleet that injects 429/403/5xx/challenges/timeouts/never-settling pages/spider traps; chaos + crash-resume (
kill -9) tests green in CI. Detection + backoff + permitted fallbacks; never evasion (04 "What to Skip", 05 Legal/Ethical §). - Public artifacts. One repo per project with 3-minute README,
benches/run.shone-command repro,results/*.csv, writeup/blog post, parity/eval CSVs committed (tied to file 12; see §9 Publishing plan).
What a laptop can prove vs what a model proves (be honest in every README):
| Claim | Laptop proves | Capacity/extrapolation model proves | Must disclose |
|---|---|---|---|
| 100M pages/day | per-worker pages/s, per-host politeness compliance, crash-resume, error taxonomy (A+) | worker count × pages/s, bandwidth (avg bytes × 1,160/s), frontier sharding, cost | fixture ≠ internet; single machine; no WAF; avg page-size assumption |
| Thousands-of-domains politeness | 5,000-host simulation + local fixture fleet, rate-contract audit from logs | host-partitioned sharding (hash by host, 04 Design summary), limiter memory math | simulated latency ≠ real DNS/TLS variance |
| JS rendering at scale | render ratio, cost multiplier (browser pages/s ÷ HTTP pages/s), classifier precision/recall | browser fleet sizing (RAM/400MB per instance, 05 Pool checklist), when-to-render policy | Chromium version pinned; fidelity limits |
| 10B-page keyword search | 1–10M-doc Tantivy prototype throughput/latency/recall (F) | shard count, postings memory (Exa BM25 post), WAND/merges, cost model | single-shard numbers extrapolated linearly only with stated bottleneck analysis |
| 1B-vector <100ms | 1M-vector self-built HNSW/IVF+PQ recall@10 vs p95 curve (C+) | quantization (binary/PQ), clustering/sharding, SIMD, rerank-recovery math (Exa vector-DB post) | recall@k at 1M ≠ recall at 1B; state k, ef, bytes/vec |
Rule: every extrapolation table has an Assumptions row and a What would break this row. That honesty is itself the senior signal.
How to use this file: each Project X+ section lists Upgrades over 09 (deltas only), then Acceptance criteria, Benchmark commands, README/report requirements. Build order and cut lines in §8; scorecard in §7; publishing in §9.
1. Project A+ — Distributed polite crawler (100M/day edition)
Upgrades over 09 Project A (§A.1–A.9). Reuses 10 patterns 1/3/4/5/8/9/10, 04 polite-crawling design, 15 G-3/G-4/G-6 items 1/2/4/6.
1.1 What changes vs 09-A (delta, not restatement)
- Multi-worker Docker Compose topology. 09-A is a single binary (§A.3). A+ splits into
scheduler / fetch-workers (N) / frontier-storebehind Compose: scheduler owns claim/assign (hash-by-host so all state for a host stays on one worker — 04 Design summary, 06 partitioning), fetch workers own reqwest + politeness buckets, frontier-store is Postgres (09-A stretch 1 becomes required:FOR UPDATE SKIP LOCKEDclaim queries). Single-binary mode kept behind--mode singlefor laptop repro. Artifact:docker-compose.yml+ARCHITECTURE.mddelta diagram. - Domain partitioning. Rendezvous-hash by host (09-A stretch 4,
rendezvous-hash 0.3) across workers sharing one Postgres; dueling-worker test asserts zero host overlap. Reference 04 host→queue table + Mercator ~3× back-queues-per-worker sizing. - 5,000-host politeness simulation + local fixture fleet. 09-A acceptance is 1,000 pages / 20 hosts (§A.6). A+ adds: (a) simulated 5,000-host run with synthetic latency/Retry-After/crawl-delay distributions asserting per-host rate compliance from
fetchestimestamps; (b) local fixture fleet (axum/wiremock) modeling the full taxonomy below. Simulation is cheap; fleet is truth. - 429/403/5xx/challenge taxonomy + per-host policy engine. Extends 10 pattern 5 + 09-A §A.9: per-status policy table (retryable vs terminal vs cooldown vs tombstone),
Retry-Afterboth formats clamped [1s, 10min], host auto-cooldown (403 rate >20% → 10-min cooldown), challenge pages (CAPTCHA/login/paywall markers) classified as stop-signals (05 Legal § — back off, metric, never evade). Policy lives in onepolicy.rswith deterministic unit tests (seeded RNG/clock). - OPIC/priority scheduling benchmark vs FIFO. Implements 30-line OPIC (15 G-3: Abiteboul et al.) + epsilon-greedy prioritizer + sitemap-
lastmodsignal + Cho & Garcia-Molina change-rate recrawl intervals (04 Freshness policy). Benchmark: coverage-per-fetch (relevant/changed URLs found per N fetches) OPIC vs FIFO on a synthetic changing corpus. Pass = OPIC beats FIFO with N≥3, published curve. - Crash-resume chaos tests. 09-A §A.6 item 6 (
kill -9→ <1% refetch) becomes a scripted chaos suite: mid-crawlkill -9scheduler/worker/Postgres-client, lease-expiry reclaim,resume-from-DB assertion; plus chaos-fixture completion ≥98% within retry budget. - Prometheus/Grafana dashboard. 09-A exposes
/metrics(§A.3, 10 pattern 10). A+ adds a committedgrafana/dashboard.json+prometheus.yml: fetch rate by host/status, 429 share, queue depth, robots-deny share, per-host in-flight, p99 fetch latency. Screenshots in README. - 100M pages/day capacity model. Measured local baseline (pages/s per worker at fixed concurrency) → extrapolation table: target 1,157 pages/s sustained; worker count = target ÷ per-worker throughput; bandwidth = target × avg bytes (state assumption, e.g. 30–80KB); concurrency math (per-host rps × host count bounds parallelism); Postgres claim throughput (leases/s) vs required; cost sketch. Template in §1.4.
1.2 Architecture delta (Compose)
seeds ──▶ scheduler (partition by host, OPIC scores, next_fetch_at heap)
├──▶ fetch-worker-1 (reqwest pool, governor buckets, robots gate, policy engine)
├──▶ fetch-worker-2 ( ... )
└──▶ frontier-store (Postgres; urls/fetches/robots_cache/hosts per 09-A §A.4 + 04 schema)
metrics (Prometheus scrapes all) ──▶ Grafana dashboard.json
Keep 09-A single-binary data flow (§A.3) inside each worker; scheduler only assigns host-partitions. Config: extend 09-A config.toml with [cluster] workers, partition_fn, claim_batch and [policy] per-status table.
1.3 Exact acceptance criteria (all must hold, tagged v0.2.0)
- 5,000-host simulation: zero host-overlap across workers; per-host inter-arrival ≥ configured delay ±10% on ≥99% of hosts (audited from DB timestamps; cf. 09-A §A.7 query).
- Fixture fleet taxonomy green: 429+Retry-After honored (gap ≥ header), 503-flaky→200 within budget, 403 terminal-per-URL + host cooldown fires, 5xx jittered backoff bounded (attempts ≤5, elapsed ≤60s), challenge/login fixtures recorded as stop, never retried as errors.
- OPIC vs FIFO: OPIC wins coverage-per-fetch at N∈{100,500,2000} fetches, 3 reps each; curve committed as CSV + chart.
- Chaos:
kill -9worker mid-crawl (≥500 queued) → restart resumes with <1%donerefetch; scheduler kill → no URL fetched twice (server-side log audit). - Dashboard:
docker compose up→ Prometheus + Grafana with all panels populated from a 10-min demo crawl; screenshots in README. - Capacity model:
docs/capacity-model.mdwith measured baseline + extrapolation table + assumptions/breaks rows; numbers consistent with committedresults/*.csv.
1.4 Benchmark commands + capacity-model template
# baseline: per-worker throughput (fixture, 2000 pages, mixed latency) — 3 reps
/usr/bin/time -l cargo run --release -- --config bench.toml --seeds bench-seeds.txt --max-pages 2000 2>&1 | tail -20
# politeness audit (per-host mean inter-arrival; adapt 09-A §A.7)
# OPIC vs FIFO
cargo run --release --bench-sched --policy fifo --budget 2000 > results/sched-fifo.csv
cargo run --release --bench-sched --policy opic --budget 2000 > results/sched-opic.csv
# chaos + resume
./benches/chaos.sh # kill -9 worker at 50%, assert resume stats
# metrics snapshot per run
curl -s localhost:9000/metrics > results/metrics-<run_id>.txt
Extrapolation table (publish with real baseline filled in):
| Quantity | Formula | Example (illustrative — replace with measured) |
|---|---|---|
| Target | 100M / 86400 | ≈1,157 pages/s sustained |
| Per-worker throughput | measured pages/s at concurrency C | e.g. 25 pages/s/worker (FILL) |
| Workers needed | 1157 ÷ per-worker | e.g. 47 workers (FILL) |
| Bandwidth | 1157 × avg_bytes (state p50 page KB) | e.g. ×50KB ≈ 58MB/s ≈ 460Mb/s (FILL) |
| Frontier claims/s required | ≥1157 leases/s + retries | Postgres SKIP LOCKED bench (FILL) |
| Bottleneck + break-row | slowest stage at 10× | FILL: DB claims / bandwidth / DNS |
1.5 README/report requirements
09-A §A.8 items 1–6 still required, plus: (a) Compose quickstart (docker compose up --build, 5-min demo), (b) policy-engine table with links to policy.rs, (c) politeness-audit excerpt (5,000-host compliance %), (d) OPIC-vs-FIFO chart + interpretation, (e) docs/capacity-model.md linked with assumptions/breaks, (f) Grafana screenshots, (g) honest limits (single-machine Compose ≠ production K8s; simulation ≠ internet).
1.6 Tech verification (bounded, Sep 2026)
sqlx 0.9.0current (crates.io front page; docs.rs builds Jul 2026) — 09 pinsqlx = "0.9"holds. Use features["postgres", "runtime-tokio", "migrate"]for A+ frontier-store;SKIP LOCKEDclaim queries per 09-A stretch 1.deadpool-sqlite 0.14.0current (docs.rs) — 09 pin holds for single-binary mode (--mode single).rendezvous-hash 0.3UNVERIFIED (search inconclusive; sile/rendezvous_hash repo exists) — confirm withcargo add rendezvous-hashat build; fallback is a 20-line HRW hasher over hosts (document if hand-rolled).- Postgres image: pin
postgres:17-alpine(or latest stable at build) in Compose; record digest inversions.txt.
2. Project B+ — Hybrid rendering + TypeScript Playwright sidecar + bot-detection resilience
Upgrades over 09 Project B (§B.1–B.9). Reuses 10 patterns 12, 05 full file, 15 G-1/G-2/G-6 items 5.
2.1 What changes vs 09-B
- TS Playwright worker-pool microservice (G-1). 09-B stretch 1 becomes required:
browser-worker/in TypeScript (Playwright library API, 05 Playwright §) exposingPOST /render {url, waitFor?}→{html, apiPayloads?, metrics}. Rust scheduler calls it over HTTP with semaphore-bounded concurrency (pool 2–4) and timeout+requeue. Ships withpackage.jsonpinned versions,Dockerfile, and parity tests. This mirrors Exa's mixed-language reality (15 G-1) and directly evidences "familiar with TypeScript, Playwright". - SPA/SSR classifier with precision/recall on a labeled set. 09-B classifier (
needs_js, §B.5) + 05 detection table + ketch/StormCrawler references become a scored artifact: ≥40 labeled fixtures (static, Next SSR-with-blob, Nuxt, SvelteKit, CSR shells, noscript, thin-error) withcargo test+npm testreporting precision/recall/F1 per class and a confusion matrix. SSR-with-blob must classifyStatic/parse-without-render (05 rule 2). - Render-cost benchmark. Extends 09-B §B.7: HTTP-only vs CDP vs Playwright-sidecar on identical 50-URL mixed fixture; metrics
pages/s, p50/p95 nav, peak RSS, fallback rate, probe recovery %, bytes/page with/without blocking. Publishes the cost multiplier honestly (expect 5–50×; 09-B §B.7). - CDP session pooling. 09-B
BrowserPool(§B.5) hardened per 05 Pool checklist: fixed browser processes × contexts-per-process, acquire/release semaphore,maxUses/maxAgerecycle, health-check ping, watchdog respawn,--headlessnew-mode flags, per-process--user-data-dir. Crash-injection test (kill -9browser mid-run on 300 URLs) green. - Challenge/anti-bot fixture suite + graceful-degradation policy (G-2). New
fixtures/challenges/: 403-wall, 429-wall, JS-challenge interstitial, CAPTCHA marker, login-wall, paywall, geo-wall. Policy (05 Legal §, 04 "What to Skip"): detect → classify as stop-signal → back off + metric + permitted fallback (sitemap/feed/API/embedded JSON) → never rotate IP/fingerprint/solve CAPTCHA. Each fixture asserts stop-not-render. README carries the ethics note (09-B §B.8 item 5) verbatim-strength. - Parity harness. 09-B
parity.rs(§B.5) extended to three-way parity (HTTP vs CDP vs Playwright) withbenches/parity.csv+ probe-recovery % per path; sharedextract_mainfor all paths (09-B §B.9 drift rule).
2.2 Architecture delta
URL ──▶ http_fetch ──▶ classifier (P/R measured) ── static ──▶ extract_main ──▶ store
│ ▲
│ shell shared extract
▼ │
┌─ CDP pool (chromiumoxide, contexts) ──────────┤
└─ TS Playwright sidecar (HTTP API, pool) ──────┘
▲ per-host shared token bucket covers BOTH paths (05 rule 5)
└── challenge/policy gate (stop-signals first — before either renderer)
2.3 Exact acceptance criteria
- Classifier: precision ≥0.90 / recall ≥0.85 on the ≥40-fixture labeled set; SSR-with-blob fixtures 100% no-render; confusion matrix committed.
- Sidecar:
cd browser-worker && npm ci && npm testgreen; Rust→sidecar round-trip on 30 mixed URLs with probe recovery ≥80% (cf. 09-B §B.6 item 1). - Static-only seed run →
render_mode=browsercount == 0 (09-B §B.6 item 2, unchanged). - Challenge suite: all 7 challenge fixtures assert stop-not-render + correct metric increment; no fixture triggers renderer launch (assert via launch counter).
- Crash injection: browser kill mid-render → pool recovers, URL requeued,
browser_restarts_totalincrements, 300-URL crawl completes with rows==inputs. - Cost table published with N=3 medians; sidecar-vs-CDP latency delta explained in one paragraph.
2.4 Benchmark commands
cargo test classify parity # unit + parity (non-browser)
cargo test -- --ignored # CDP tests (needs Chromium)
cd browser-worker && npm ci && npm test # TS sidecar tests
cargo run --release -- --mode http-only --seeds fixtures/mixed50.txt 2>&1 | tee results/http.log
cargo run --release -- --mode hybrid-cdp --seeds fixtures/mixed50.txt --pool-size 2 2>&1 | tee results/cdp.log
cargo run --release -- --mode hybrid-playwright --seeds fixtures/mixed50.txt --pool-size 2 2>&1 | tee results/pw.log
/usr/bin/time -l cargo run --release -- --mode hybrid-playwright --seeds fixtures/mixed50.txt 2>&1 | grep -E "maximum resident|real"
2.5 README/report requirements
09-B §B.8 items 1–5 plus: (a) sidecar quickstart (npm ci, env, ports) verbatim macOS; (b) classifier P/R table + confusion matrix + 3 misclassified-case autopsies; (c) three-way cost table + recommendation (when CDP vs sidecar vs HTTP+JSON capture); (d) challenge-policy section with fixture list and stop-signal wording; (e) modern-web-design note: which framework markers were observed and how embedded-data paths (__NEXT_DATA__, __NUXT__, XHR capture via waitForResponse/Network.getResponseBody) avoided renders — evidences "modern web design" JD phrase.
2.6 Tech verification (bounded, Sep 2026)
chromiumoxide 0.9.1still latest (docs.rs builds Jun 2026; 05 snapshot agrees) — 09 pinchromiumoxide = "0.9"holds. Watch the maintained forkchromey(spider-rs, v2.54.x, "originally forked from chromiumoxide") per 09 §0 note; adopt only if blocked.- TS sidecar:
playwrightnpm1.62.xlatest (npm versions page, Sep 2026); install withnpm i playwright@1.62+npx playwright install chromium. Library API per 05 Playwright § (chromium.launch,newContext,page.goto,waitForResponse,route.abort('blockedbyclient')). playwright-rs: docs.rs shows 0.15.1–0.17.x builds (Aug 2026; 0.15.1 bundles driver 1.61.1); 05's "0.18.0" not confirmed — pinplaywright-rs = "0.17"and check the crate'sPLAYWRIGHT_VERSIONconst at build so the Rust binding and the TS sidecar's driver don't skew parity numbers. B+ uses the TS sidecar as primary (G-1 evidence); playwright-rs is reference-only.- New headless is the only headless since Chrome 132 (05 Pitfalls); launch flags per 05 Pool checklist (
--disable-dev-shm-usagein containers, per-process--user-data-dir).
3. Project C+ — Crawl-to-search + self-built ANN index (1B-vector math)
Upgrades over 09 Project C (§C.1–C.9). Reuses 07 full file, 15 G-4/G-5/G-6 items 7, Exa BM25 + vector-DB + evals posts.
3.1 What changes vs 09-C
- Tantivy BM25 + hybrid (BM25 + dense) retrieval. 09-C stretch 1 (embeddings lane) becomes required:
POST /search(BM25) +POST /search/hybrid(RRFk=60fuse of BM25 top-50 + vector top-50, 07 Exercises 6). Optional cross-encoder rerank of fused top-50 with documented p95 cost. Score-fusion uses RRF only (never raw-score mixes — 07 Pitfalls). - Self-built ANN index in Rust over 1M vectors (G-5). Beyond 09-C's lancedb/usearch sidecar: implement your own HNSW or IVF+PQ index in Rust (from HNSW paper / FAISS wiki per 15 G-5; read
usearch/hnswlibfirst per 07). Scope honestly: in-RAM graph + binary/PQ quantization + k-means clustering stub; 1M embeddings (fastembed BGE-small 384d or stated model) indexed on laptop; brute-force recall oracle on a sample. This is the "custom vector DB" evidence at credible scale-down. - Recall@10 vs p95 latency curve, <100ms local target. Sweep
ef_search/nprobe/ quantization settings; plot recall@10 (vs brute force) against p95 query latency; mark the <100ms operating point on 1M vectors. Commit curve CSV + chart. - Extrapolation math to 1B vectors. From measured bytes/vec (f32 vs binary vs PQ), QPS/latency at 1M, and cluster/shard design (Exa vector-DB post: Matryoshka truncation, binary quantization, 100k clusters, SIMD, rerank recovery): derive shard count, RAM, expected latency budget (ANN probe + network fan-out + rerank), and cost sketch. Assumptions/breaks rows required (same honesty rule as §0).
- BEIR-subset eval with nDCG@10. 09-C's 20 hand queries (§C.6) stay; add a public BEIR subset (e.g. SciFact/NFCorpus-scale small split) with nDCG@10/MRR/recall@10 for BM25 / vector / hybrid / +rerank (07 Exercises 6/8 table shape). Documents the Exa evals-post critique awareness (false negatives, judge agreement) in one paragraph.
- Snippets/highlights + latency dashboard. 09-C snippets (§C.5
snippets.rs,SnippetGenerator≤240 chars, escaped) plus Exa Dynamic-Highlights-aware budget note; Prometheus/metrics+ Grafana panel for query p50/p95/p99, index size, doc count (mirrors A+ dashboard pattern).
3.2 Architecture delta
crawl.db ──▶ clean (dom_smoothie per 07) ──▶ chunks (text-splitter) ──┬──▶ tantivy (BM25)
└──▶ fastembed ──▶ self-ANN (HNSW/IVF+PQ, 1M) + sidecar ref (lancedb/usearch)
query ──▶ axum ──▶ BM25 top-50 + ANN top-50 ──▶ RRF(k=60) ──▶ [rerank top-50] ──▶ snippets ──▶ JSON (Exa-shaped per 07 Ex 7)
3.3 Exact acceptance criteria
- Index ≥2k crawl pages in Tantivy (09-C §C.6 item 1 kept) + 1M-vector self-ANN built on laptop; both sizes documented with wall time + RAM.
- Hand-eval recall@3 ≥0.75 (09-C kept) and BEIR-subset nDCG@10 reported for all four systems (BM25/vector/hybrid/+rerank); hybrid+rerank beats best single on nDCG@10 or the failure is autopsied.
- Latency: BM25 API p95 <200ms @2k docs (09-C kept); ANN p95 <100ms @1M vectors at stated recall@10 operating point; rerank p95 documented (target <300ms/50 candidates CPU — 07 Ex 6).
- Recall@10-vs-p95 curve committed (CSV + chart) with ≥5 operating points.
docs/vector-scale-model.md: 1B-vector extrapolation (bytes/vec → RAM, shards, latency budget, cost) with assumptions/breaks.- Snippets: every hit has
<b>highlight or documented fallback; escaping test with<script>fixture green.
3.4 Benchmark commands
cargo run --release -- index --db ../polite-crawler/crawl.db --index ./index
cargo run --release -- serve --index ./index --port 3000 &
curl -s -X POST localhost:3000/search -H 'content-type: application/json' -d '{"query":"rust async crawler","top_k":5}' | jq .
cargo run --release -- eval --k 3 --file eval/queries.jsonl
cargo run --release -- ann-build --vectors 1000000 --code pq16 && cargo run --release -- ann-bench --sweep ef > results/ann-curve.csv
cargo run --release -- eval-beir --subset scifact --systems bm25,vector,hybrid,rerank > results/beir.csv
oha -z 30s -q 50 -m POST -H 'content-type: application/json' -d '{"query":"crawl politeness","top_k":10}' http://localhost:3000/search
3.5 README/report requirements
09-C §C.8 items 1–6 plus: (a) hybrid endpoint demo curls for both /search and /search/hybrid; (b) four-system leaderboard (nDCG@10/MRR/recall@10) + BEIR subset link + seed; (c) ANN curve chart with operating point marked; (d) docs/vector-scale-model.md summary (shards/RAM/latency/cost in 5 lines); (e) "what I'd do with 10× data" extended to 1B (quantization → clustering → sharding → rerank recovery, Exa-post vocabulary).
3.6 Tech verification (bounded, Sep 2026)
tantivy 0.26.1current (crates.io front page; docs.rs builds Jul 2026) — 09 pintantivy = "0.26"holds. Single-shard numbers feed F's sharding math; Quickwit (07) is the documented scale-out, not a build target.usearch 2.26.2current (crates.io, Sep 2026) — 09 pinusearch 2.26holds; supports f32/f64/i8/u8/f16/binary with SIMD distance kernels (docs.rs). Use as the reference ANN the self-built index is benchmarked against.hnsw_rs 0.3.4current — read first if hand-rolling HNSW (pure-Rust reference impl of Malkov & Yashunin; multithreaded insert/search, mmap dump support).lancedb 0.38.0current (docs.rs) — 09 pin holds;connect(path).execute(),FixedSizeList<Float32>vector columns, auto IVF-PQ indexes. Sidecar/reference only in C+ (self-built index is the deliverable).fastembed: 09 pins 6.x but crates.io page seen at 5.13.3 (Apr 2026) — version line uncertain, VERIFY AT BUILD withcargo add fastembedand record; BGE-small 384d default model assumption must be re-checked against the installed version's model list.text-splitter 0.32.0current (crates.io, MSRV 1.86) — 09 §C.4 pin0.32holds; featurestokenizers/tiktoken-rsfor token-accurate chunking.
4. Project D+ — Benchmarks and writeups as distribution
Upgrades over 09 Project D (§D.1–D.9). Feeds 15 §6 items 8/11 and §9 below.
4.1 What changes vs 09-D
- Reproducible harness, N≥3 matrices. 09-D
benches/run.sh(§D.5) extended to cover every A+/B+/C+ benchmark in one matrix: Rust-vs-Python HTTP (500 URLs), HTTP-vs-CDP-vs-sidecar (50 mixed), scheduler FIFO-vs-OPIC, index/query/latency, ANN curve spot-check. All cells N≥3, medians + ranges,results/<date>-<machine>.csvin the 09-D §D.4 schema (addpolicy, shards, efcolumns as needed). - RSS/memory profiling. Every cell records peak RSS via
/usr/bin/time -l(09-D §D.7) + app-level histograms; browser cells also record per-instance MB and fleet-sizing derivation (RAM/400MB). - Before/after performance tables. Each optimization (limiter tuning, blocking, OPIC, chunking, quantization) ships a before/after table with delta % + absolute numbers — this is the "optimize to an exceptional degree" evidence.
- Blog-post templates. Four ready-to-fill templates committed as
writeup/templates/*.md: (i) architecture (A+), (ii) benchmark methodology (D+), (iii) hybrid routing + cost (B+), (iv) crawl-to-search + evals (C+). Each template has TL;DR / Method / Results / Tradeoffs / Recommendation / Limitations / Reproduce (09-D §D.8 shape). - HN/r/rust distribution checklist. Tied to file 12 (see §9): title options, comment-prep FAQ (5 hard questions + answers), timing, cross-post map (r/rust, r/webscraping, HN, Discord), repo-hygiene gate (CI green, quickstart <5min, screenshots).
4.2 Acceptance criteria
./benches/run.shregenerates allresults/on a fresh macOS clone (fixture ports + Chrome requirement documented; browser cells skip cleanly without Chrome with a clear message).- Every published table states N, median+range, machine spec, versions (
versions.txtper 09-D §D.7). - Writeup follows 09-D §D.8 template exactly, including Limitations & threats-to-validity and one workload where each loser wins.
- At least one flamegraph/samply or tokio-console capture (09-D stretch) with a before/after number tied to a real code change.
4.3 Benchmark commands
rustc --version; cargo --version; python3 --version; pip freeze > results/requirements.txt
system_profiler SPHardwareDataType | grep -E 'Chip|Memory'
hyperfine --warmup 1 --runs 3 \
'../polite-crawler/target/release/crawl --seeds fixtures/urls500.txt --max-pages 500' \
'python3 baseline.py --seeds fixtures/urls500.txt --max-pages 500'
hyperfine --warmup 1 --runs 3 \
'../hybrid-crawler/target/release/hybrid --mode http-only --seeds fixtures/mixed50.txt' \
'../hybrid-crawler/target/release/hybrid --mode hybrid --seeds fixtures/mixed50.txt --pool-size 2'
/usr/bin/time -l <cmd> 2>&1 | grep -E 'maximum resident|real|user|sys'
curl -s localhost:9000/metrics > results/metrics-<run_id>.txt
(Full matrix lives in benches/run.sh; commands above are the 09-D §D.7 core.)
4.4 README/report requirements
The writeup IS the deliverable (09-D §D.8): TL;DR → Method → Results (medians+ranges, N=3) → Tradeoffs → Recommendation matrix → Limitations → Reproduce. Plus results/ CSVs, versions.txt, dashboard screenshots, and links to the four blog drafts.
5. Project E — Mini exa-d pipeline (Backend JD extra)
Maps to Backend JD "data pipelines handling millions of docs/day" + 15 G-4; mirrors exa-d (incremental derived columns over columnar fragments) and Canon (typed DAG) posts from 07 Exa list.
5.1 Goal and why it matters
Prove pipeline thinking: crawl → parse → columnar store with derived columns (clean text, chunks, embeddings) → backfill-on-recrawl patching — at measured documents/sec with a credible millions/day model. This is the Backend-JD bridge project; keep it small (1–2 weeks) and tightly mapped to Exa's published architecture.
5.2 Scope (timeboxed: 1–2 weeks, ~15–25h)
- MVP: batch pipeline binary: reads
crawl.db/raw HTML →clean(dom_smoothie) →chunk(text-splitter) → writes Parquet (or Lance) dataset partitioned byhost/datewith columns{url, host, fetched_at, title, text, chunks, content_hash, etag}; recrawl input produces a backfill patch (changed docs rewritten, tombstones for 404-gone, derived columns recomputed only for changed hashes — the exa-d incremental idea);GET /stats-style manifest (row count, bytes, last-patch-at). - Columnar choice: Parquet via
parquet+arrowcrates (stable, verifiable) default; Lance (lancecrate) as documented alternative with one-paragraph tradeoff (versioned fragments + vector-native vs tooling maturity). Verify current versions in bounded batch; if Lance fights the build, ship Parquet and note Lance as future. - Out of scope: streaming engine (Ray/Datafusion), distributed execution, model training.
5.3 Architecture
crawl.db + raw/*.html ──▶ clean ──▶ chunk ──▶ [embed optional] ──▶ Parquet/Lance dataset (host/date partitions)
recrawl ──▶ diff by content_hash ──▶ backfill patch (changed only) ──▶ manifest + metrics
Map each stage to exa-d in ARCHITECTURE.md: raw fragments → derived columns → incremental patch → freshness job (table, one line per stage).
5.4 Acceptance criteria
- 100k-doc build from fixture/crawl output in one command; wall time + docs/s published (target: measure honestly; model derives workers for 1M+/day).
- Recrawl with 10% changed + 2% gone: patch recomputes ≤12% of derived rows (proves incrementality); tombstones excluded from reads after grace job.
- Determinism: identical input → identical dataset hash (byte-stable ordering documented).
docs/exa-d-mapping.md: stage→exa-d-concept table + what was deliberately not built.
5.5 Benchmark commands
cargo run --release -- build --db crawl.db --out ./lake --format parquet
cargo run --release -- backfill --db crawl-recrawl.db --lake ./lake --out ./results/backfill.json
ls -lh ./lake && cat ./results/backfill.json
/usr/bin/time -l cargo run --release -- build --db crawl.db --out ./lake 2>&1 | grep -E "maximum resident|real"
Publish: docs_total | wall_s | docs/s | bytes/doc | peak_RSS | patch_recompute_%.
5.6 README requirements
Quickstart (build + backfill + read one row), dataset schema listing, docs/s table + millions/day extrapolation (workers = target ÷ measured docs/s, with I/O caveat), exa-d mapping table, limits/future (streaming, partitioning at 100M scale).
5.7 Tech verification (bounded, Sep 2026)
parquet(arrow-rs official Rust impl): docs.rs seen at 55.x–59.x (arrow-rs releases ~monthly) — do NOT pin from this file;cargo add parquet arrowat build and record exact versions inversions.txt. Default features cover snappy/zstd/lz4; addasync+object_storeonly if the lake reads from S3.lance 10.x–11.x(docs.rs latest-builds page) — E default is Parquet; Lance is the documented alternative (versioned fragments, 100× random-access claim per crate docs, Arrow/DuckDB compatible). Attempt Lance only after Parquet MVP is green.dom_smoothie 0.18.0current (docs.rs builds Aug 2026) — clean-text stage default per 07; featuresaho-corasick/serdeas needed.
6. Project F — "10B-page keyword search in a month" feasibility package (design doc + scaled prototype)
Maps to Backend JD example "Google-level keyword search over 10B pages" + 15 G-5; draws on Exa BM25 post (postings encoding, WAND, zstd) and Quickwit sharding (07). This is a design + scaled-prototype project, not a 10B-doc build.
6.1 Goal and why it matters
Show systems-design seniority: architecture, sharding math, cost model, and a scaled-down Tantivy prototype (1–10M docs) published as a design writeup a reviewer can argue with. Interviews for search roles are won on this document.
6.2 Deliverables
DESIGN.md(the artifact): requirements (10B pages, 1-month build constraint per JD example framing, query SLO), architecture (crawl→parse→partition→index→serve fan-out), sharding math (docs/shard from single-shard prototype throughput + RAM; shard count; replication), postings-memory math (per-doc bytes from prototype index size; encoding/WAND/zstd notes per Exa BM25 post), serving fan-out latency budget (scatter/gather p50/p95 model), merge-policy + freshness story, cost model (machines × hours + storage + serving), risks/open questions.- Scaled prototype: 1–10M-doc Tantivy index (Common Crawl subset or synthetic crawl at whatever scale the laptop sustains; 1M minimum viable, 10M stretch) with build throughput (docs/s), index bytes/doc, single-shard query p50/p95, recall spot-checks. Numbers feed the DESIGN math; prototype README states corpus source + machine spec.
- Cost + timeline table: build cost (index hours × machine rate), serving cost/month at stated QPS, storage (index bytes × replication), 1-month staffing/milestone sketch (what parallelizes, what doesn't).
6.3 Acceptance criteria
- Prototype ≥1M docs indexed with measured docs/s, bytes/doc, p50/p95 (top_k=10); numbers committed.
- DESIGN.md sharding math closes: shard count × per-shard RAM ≤ stated fleet RAM; fan-out latency budget ≤ SLO with stated headroom; cost table has no "magic" rows (every row cites prototype number or labeled assumption).
- Limitations section names the three biggest risks (e.g., merge storms, tail-latency fan-out, freshness vs rebuild) with mitigations.
- One reviewer (Discord/r/rust/HN draft) asked a hard question that changed the doc — recorded in changelog.
6.4 Benchmark commands
cargo run --release -- index --corpus ./cc-subset --shard 0 --index ./index-s0 # per-shard build
du -sh ./index-s0 && cargo run --release -- eval --k 10 > results/prototype-eval.csv
oha -z 30s -q 50 -m POST -d '{"query":"...","top_k":10}' http://localhost:3000/search
6.5 README requirements
DESIGN.md is the README's parent: 10-line executive summary up top, then links to prototype numbers, math tables, cost model, risks, and "what I'd cut under time pressure" (ties to §8 cut lines).
6.6 Tech verification (bounded, Sep 2026)
- Tantivy single-shard prototype numbers (docs/s, bytes/doc from §3) are the input; no new crates needed for DESIGN.md itself.
- Quickwit (distributed engine on Tantivy, 07): reference for splits/metastore/search-fan-out vocabulary only — cite the repo, don't deploy it.
- Common Crawl subset: use the columnar index +
cc-index-tabledocs referenced in 04 (Open-Source table) to pull a 1–10M-URL slice; record exact crawl ID + index-table query in the prototype README. If access fights back, fall back to synthetic crawl output from A+ (document the substitution).
7. The Overqualified Scorecard
Every JD bullet → project → artifact → the number a reviewer sees in ≤3 min. Cite 15 §2 verbatim bullets.
Web Crawling JD
| JD bullet | Project | Artifact (file/path) | Number reviewer sees |
|---|---|---|---|
| Build a distributed crawler handling 100M+ pages/day | A+ | docs/capacity-model.md + results/*.csv + Compose demo |
per-worker pages/s → workers-for-100M; bandwidth; claims/s |
| Optimize crawl politeness and rate limiting across thousands of domains | A+ | policy.rs + politeness audit + Grafana dashboard |
5,000-host compliance %; 429/Retry-After gaps; robots-deny count |
| Design systems to detect and handle dynamic content, JavaScript rendering, and anti-bot measures | B+ | classifier P/R table + challenge suite + parity CSV | P≥0.90/R≥0.85; 7/7 challenge stops; probe recovery ≥80% |
| Create intelligent crawl scheduling and prioritization algorithms for maximum coverage efficiency | A+ | OPIC-vs-FIFO curve + results/sched-*.csv |
coverage-per-fetch delta at N=100/500/2000 |
| Familiar with TypeScript, Playwright, modern web design, CDP | B+ | browser-worker/ (TS) + CDP pool + framework-marker note |
npm test green; 3-way cost table; render ratio |
| Optimize a system to an exceptional degree | D+ (+all) | before/after tables + flamegraph + writeup | delta % per optimization; p50/p95/RSS deltas |
Backend JD
| JD bullet | Project | Artifact | Number reviewer sees |
|---|---|---|---|
| High throughput, low latency systems | A+ / C+ | capacity model + API load tests | pages/s; API p50/p95 @50rps |
| Data pipelines handling millions of documents/day | E | lake build + backfill report | docs/s → workers-for-1M/day; patch recompute % |
| Google-level keyword search over 10B pages | F (+C+) | DESIGN.md + 1–10M-doc prototype | docs/s; bytes/doc; shards; cost |
| Custom vector DB over 1B vectors in <100ms | C+ | self-built ANN + curve + scale model | recall@10 @ p95<100ms (1M); 1B shards/RAM/latency math |
Cross-cutting (both JDs)
| Signal | Project | Artifact | Number |
|---|---|---|---|
| Care about high-quality knowledge | C+ / D+ | BEIR nDCG@10 + eval harness + failure autopsies | nDCG@10 ×4 systems; 3 autopsies |
| Public proof + communication | D+ / §9 | 4 blog posts + HN/r/rust thread + OSS PRs | links + discussion metrics |
8. Scheduling: weeks 3–16 (sequence, dependencies, cut lines)
Assumes 09 A–D MVP baselines land first or are built in tandem; A+ through F slot into weeks 3–16. Total ~14 weeks at 8–12 h/week. Dependencies explicit; cut lines say what to drop under time pressure (cut from the bottom of each cell).
| Weeks | Focus | Depends on | Ship | Cut line (drop in this order) |
|---|---|---|---|---|
| 3–5 | A+ core: Compose + partitioning + policy engine + 5k sim | 09-A MVP (frontier, politeness per 04) | workers + policy.rs + sim audit | drop Grafana (keep /metrics) → drop Postgres (SQLite + sharding demo) → keep single-binary + sim only |
| 6–7 | A+ scheduling + chaos + capacity model; D+ harness skeleton | A+ core | OPIC-vs-FIFO CSV + chaos.sh + capacity-model.md | drop OPIC (ship FIFO + recrawl intervals) → drop chaos automation (manual kill test) |
| 8–10 | B+: classifier + CDP pool + TS sidecar + challenges | 09-B MVP (hybrid per 05); TS basics (15 G-1) | sidecar + P/R table + 3-way cost table | drop sidecar (CDP only + TS learning noted) → drop challenge suite to 3 fixtures → drop blocking ablation |
| 11–12 | C+: hybrid retrieval + BEIR + snippets + dashboard | 09-C MVP (Tantivy per 07) | /search/hybrid + BEIR table | drop rerank (hybrid only) → drop public BEIR (hand-eval only) |
| 13–14 | C+ self-ANN + 1B math; E mini exa-d | C+ retrieval; 07 ANN reading | ANN curve + vector-scale-model.md; lake + backfill | drop self-ANN (sidecar usearch/lancedb + math) → drop E to Parquet-build only (no backfill) |
| 15 | F prototype + DESIGN.md | C+ numbers (bytes/doc, qps) | DESIGN.md + ≥1M-doc numbers | drop to 1M docs (no 10M) → DESIGN-only with cited estimates |
| 16 | D+ writeups + distribution + scorecard audit | all results CSVs | 4 posts + HN/r/rust + this scorecard filled | drop to 2 posts + writeup.md only; never cut N≥3 + limitations sections |
Dependency rules: B+ needs A+ politeness primitives (reuse, don't fork — 09-B §B.2); C+ needs A+ crawl output; F needs C+ prototype numbers; D+ runs continuously (append each project's CSVs as they land). If behind by >1 week at any gate, apply that cell's cut line immediately — a finished smaller claim beats an unfinished larger one.
9. Publishing plan: what to publish and where (tied to file 12)
File 12 (12-public-signal-community.md) owns channels/cadence/community norms; this section maps A+–F artifacts onto it. Check 12 for exact subreddit/Discord etiquette before posting.
- Repos (one per project, CI green, quickstart <5min):
polite-crawler(A+),hybrid-crawler+browser-worker/(B+),crawl-search(C+),crawler-bench+writeup.md(D+),mini-exa-d(E),10b-search-design(F). Each README follows its section's requirements above; each hasbenches/run.sh,results/,versions.txt. - Blog posts (4, one per D+ template §4.1): (i) distributed polite crawler + capacity model, (ii) benchmark methodology (Rust-vs-Python, HTTP-vs-browser), (iii) hybrid routing + TS sidecar + challenge policy, (iv) crawl-to-search + self-ANN + BEIR. Publish on personal blog + dev.to cross-post; one flagship to HN and r/rust per 12's distribution checklist (D+ §4.1 item 5: titles, FAQ prep, timing).
- Distribution checklist per post: repo-hygiene gate → post to r/rust (benchmarks/methodology angle) and r/webscraping (politeness/scheduling angle) → HN (one attempt, flagship only) → Discord (Rust, Spider, Crawlee per 04 Communities) → X thread with charts. Log outcomes (views, questions asked) in
writeup/distribution-log.md. - OSS + referrals (15 §6 items 9–10): 1–2 merged PRs in spider-rs/chromiumoxide/Tokio-ecosystem/exa-labs repos encountered during A+–C+; referral/intro work runs in parallel from week 8 (12's networking guidance).
- Application tie-in (15 §6 item 12): resume mirrors JD phrases (09 §0 list); cover note cites 2 Exa blog posts + your scorecard numbers (§7 table, one line per bullet).
Appendix: version-verification log (bounded research, Sep 2026)
Each entry: crate/tool → verified version + date + source, or UNVERIFIED (search failed twice → 09/13 pin stands). All batches landed; no PENDING items remain.
- Batch 1 (A+ store/cluster):
sqlx 0.9.0✓ (crates.io + docs.rs, Sep 2026) — 09 pin holds.deadpool-sqlite 0.14.0✓ (docs.rs) — 09 pin holds.rendezvous-hash 0.3UNVERIFIED (one inconclusive search; second attempt skipped per time budget) — confirm viacargo addat build, 20-line HRW fallback documented in §1.6. Postgres: pin image + digest at build (§1.6). - Batch 2 (B+ browser/TS):
chromiumoxide 0.9.1✓ (docs.rs, latest) — 09/05 pins hold; forkchromey 2.54.xnoted as contingency.playwrightnpm1.62.x✓ (npm, Sep 2026).playwright-rsPARTIAL: 0.15.1–0.17.x confirmed on docs.rs, 05's "0.18.0" not confirmed — pin 0.17 + checkPLAYWRIGHT_VERSIONat build (§2.6). - Batch 3 (C+/E retrieval/lake):
tantivy 0.26.1✓,usearch 2.26.2✓,hnsw_rs 0.3.4✓,lancedb 0.38.0✓,text-splitter 0.32.0✓,dom_smoothie 0.18.0✓ (all crates.io/docs.rs, Sep 2026).fastembedUNCERTAIN (6.x per 09 vs 5.13.3 page seen) — verify at build (§3.6).parquet/arrow: monthly arrow-rs releases, pin at build (§5.7).lance 10.x–11.x✓ (docs.rs) — alternative only. - Batch 4 (F scale refs): no new version pins needed; Quickwit + Common Crawl index-table docs cited as references (§6.6).
End of file 17.
18 — The Syllabus: What to Learn, Exactly Where, and the JD Project List
The spine of the kit. Part I is the daily Rust mastery track; Part II is the parallel crawling/systems track — run both at the same time. Every row is a topic, the exact source (chapter, section, exercise, example file), and one concrete drill. Part III is the frozen list of projects you build for the Exa JD — nothing else gets built.
Files 01–07 stay as the link/annotation libraries; files 09 and 17 hold the detailed specs for the Part III projects. This file is the only one you need open while working.
Part I — Rust Curriculum: what to learn and exactly where
Toolchain: stable 1.98.x, edition 2024. Every row = topic + exact source + one concrete drill.
Week 1 — Toolchain + Book ch. 1-6 + Rustlings half 1
| Topic | Exact source | Drill |
|---|---|---|
| Toolchain | rustup — https://rustup.rs/ | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh; rustup show (stable 1.98.x) |
| Toolchain | rustfmt + Clippy — https://doc.rust-lang.org/clippy/ | rustup component add rustfmt clippy; cargo fmt && cargo clippy -- -D warnings on scratch crate |
| Editor | rust-analyzer — https://rust-analyzer.github.io/ | Install extension rust-lang.rust-analyzer; verify inline types and "Expand macro" |
| Book ch. 1 "Getting Started" | https://doc.rust-lang.org/stable/book/ch01-00-getting-started.html | cargo new hello && cargo run; edit src/main.rs |
| Book ch. 2 "Programming a Guessing Game" | https://doc.rust-lang.org/stable/book/ch02-00-guessing-game-tutorial.html | Type it in; add 3-lives loop and reject non-numbers |
| Book ch. 3 "Common Programming Concepts" | https://doc.rust-lang.org/stable/book/ch03-00-common-programming-concepts.html | Write fizzbuzz(n: u32) -> Vec<String> with a #[test] |
| Book ch. 4 "Understanding Ownership" | https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html | Borrow kata: err1.rs (E0382), err2.rs (E0502); minimal fix1.rs, fix2.rs |
| Book ch. 5 "Using Structs to Structure Related Data" | https://doc.rust-lang.org/stable/book/ch05-00-structs.html | Define Url { scheme, host, path, query } plus fn host_lower(&self) -> String |
| Book ch. 6 "Enums and Pattern Matching" | https://doc.rust-lang.org/stable/book/ch06-00-enums.html | enum CrawlState { Queued, Fetching, Done(u16), Failed(String) } + exhaustive match |
| Rustlings half 1 | groups 00_intro, 01_variables, 02_functions, 03_if + quizzes/quiz1.rs |
Run rustlings until quiz1 is green |
| Rustlings half 1 | groups 04_primitive_types, 05_vecs, 06_move_semantics, 07_structs, 08_enums |
Complete all; commit after each group |
| Rustlings half 1 | groups 09_strings, 10_modules, 11_hashmaps + quizzes/quiz2.rs |
Complete through quiz2 |
| Video | "Crust of Rust: Lifetime Annotations" (Jon Gjengset) — https://www.youtube.com/@jonhoo | Watch at 1.5x with Book ch. 4 open; pause at each annotation |
Week 2 — Book ch. 7-13 + Rustlings half 2 + 100 Exercises
| Topic | Exact source | Drill |
|---|---|---|
| Book ch. 7 "Packages, Crates, and Modules" | https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html | Split hello into src/lib.rs + src/main.rs + src/config.rs |
| Book ch. 8 "Common Collections" | https://doc.rust-lang.org/stable/book/ch08-00-common-collections.html | Word-frequency HashMap over a 1k-line file |
| Book ch. 9 "Error Handling" | https://doc.rust-lang.org/stable/book/ch09-00-error-handling.html | fn read_urls(path: &Path) -> Result<Vec<String>, io::Error> using ? |
| Book ch. 10 "Generic Types, Traits, and Lifetimes" | https://doc.rust-lang.org/stable/book/ch10-00-generics.html | Write fn longest<'a>(x: &'a str, y: &'a str) -> &'a str; list the 3 elision rules |
| Book ch. 11 "Writing Automated Tests" | https://doc.rust-lang.org/stable/book/ch11-00-testing.html | Unit tests + tests/cli.rs integration test + one doc test |
| Book ch. 12 "An I/O Project: Building a Command Line Program" | https://doc.rust-lang.org/stable/book/ch12-00-an-io-project.html | Build minigrep end-to-end (ch12-01 through ch12-06) |
| Book ch. 13 "Functional Language Features: Iterators and Closures" | https://doc.rust-lang.org/stable/book/ch13-00-functional-features.html | Refactor minigrep to iterators; record the ch13-04 loop-vs-iterator result |
| Rustlings half 2 | groups 12_options, 13_error_handling |
Complete all |
| Rustlings half 2 | groups 14_generics, 15_traits, 16_lifetimes + quizzes/quiz3.rs |
Complete through quiz3 |
| Rustlings half 2 | groups 17_tests, 18_iterators, 19_smart_pointers |
Complete all |
| Rustlings half 2 | groups 20_threads, 21_macros, 22_clippy, 23_conversions |
Complete all; defer 24_async to weeks 15-16 |
| 100 Exercises | 01_intro, 02_basic_calculator, 03_ticket_v1 — https://rust-exercises.com/100-exercises/ |
Run wr after each section |
| 100 Exercises | 04_traits, 05_ticket_v2, 06_ticket_management |
Run wr; commit the my-solutions branch |
| Video | "Crust of Rust: Iterators" (Jon Gjengset) — https://www.youtube.com/@jonhoo | Rewrite links deduper with adapters only, one collect() at the end |
Week 3-4 — CLI + error handling + testing basics
| Topic | Exact source | Drill |
|---|---|---|
| Rust CLI Book 1.1 "Project setup" + 1.2 "Parsing command line arguments" | https://rust-cli.github.io/book/tutorial/setup.html ; https://rust-cli.github.io/book/tutorial/cli-args.html | Scaffold grrs with clap derive |
| Rust CLI Book 1.3 "First implementation" + 1.4 "Nicer error reporting" | https://rust-cli.github.io/book/tutorial/impl-draft.html ; https://rust-cli.github.io/book/tutorial/errors.html | Implement search; add anyhow::Context |
| Rust CLI Book 1.5 "Output for humans and machines" + 1.6 "Testing" | https://rust-cli.github.io/book/tutorial/output.html ; https://rust-cli.github.io/book/tutorial/testing.html | stdout/stderr split; assert_cmd tests |
| Rust CLI Book 1.7 "Packaging and distributing a Rust tool" | https://rust-cli.github.io/book/tutorial/packaging.html | cargo install --path .; run grrs from PATH |
| Rust CLI Book 2.3 "Exit codes" + 2.1 "Signal handling" | https://rust-cli.github.io/book/in-depth/exit-code.html ; https://rust-cli.github.io/book/in-depth/signals.html | Map each GrrsError to an exit code; verify with echo $? |
| Rust CLI Book 2.2 "Using config files" + 2.6 "Rendering documentation for your CLI apps" | https://rust-cli.github.io/book/in-depth/config-files.html ; https://rust-cli.github.io/book/in-depth/docs.html | Add --config TOML; snapshot --help |
Command-Line Rust ch. 1-2: 01_hello, 02_echor |
https://github.com/kyclark/command-line-rust/tree/main/01_hello ; .../02_echor | Implement echo clone; cargo test green |
Command-Line Rust ch. 3-4: 03_catr, 04_headr |
https://github.com/kyclark/command-line-rust/tree/main/03_catr ; .../04_headr | Implement cat/head clones; diff output vs BSD tools |
Command-Line Rust ch. 5-6: 05_wcr, 06_uniqr |
https://github.com/kyclark/command-line-rust/tree/main/05_wcr ; .../06_uniqr | Implement wc/uniq clones; tests green |
Command-Line Rust ch. 7-8: 07_findr, 08_cutr |
https://github.com/kyclark/command-line-rust/tree/main/07_findr ; .../08_cutr | Implement find/cut clones; tests green |
| thiserror | derive Error — https://docs.rs/thiserror/latest/thiserror/derive.Error.html |
enum CrawlError with >=4 variants, #[from] + #[source] |
| anyhow | Context trait — https://docs.rs/anyhow/latest/anyhow/trait.Context.html |
.context()/.with_context() at binary boundary only; no anyhow in lib |
| std::error::Error | https://doc.rust-lang.org/std/error/trait.Error.html | Hand-implement Display + Error for one type; compare with derive |
| proptest book | "Getting started" + "Strategy Basics" + "Shrinking Basics" + "The proptest! macro" — https://proptest-rs.github.io/proptest/proptest/getting-started.html | proptest normalize(normalize(u)) == normalize(u); commit regression file |
Week 5-6 — Intermediate language
| Topic | Exact source | Drill |
|---|---|---|
| Book ch. 14 "More about Cargo and Crates.io" | https://doc.rust-lang.org/stable/book/ch14-00-more-about-cargo.html | Workspace: crawlkit + crawler-core + crawler-bin |
| Book ch. 15 "Smart Pointers" | https://doc.rust-lang.org/stable/book/ch15-00-smart-pointers.html | Build Rc<RefCell<Vec<String>>> frontier toy, then Arc<Mutex<...>> |
| Book ch. 16 "Fearless Concurrency" | https://doc.rust-lang.org/stable/book/ch16-00-concurrency.html | mpsc worker pool over 8 threads; compare with Arc<Mutex<Vec>> |
| Book ch. 18 "Object Oriented Programming Features" | https://doc.rust-lang.org/stable/book/ch18-00-oop.html | Define trait Fetch; use Box<dyn Fetch> + the ch18-03 state pattern |
| Book ch. 19 "Patterns and Matching" | https://doc.rust-lang.org/stable/book/ch19-00-patterns.html | Rewrite a match using let...else, @, guards, ..= |
| Book ch. 20 "Advanced Features" | https://doc.rust-lang.org/stable/book/ch20-00-advanced-features.html | Read 20-01 Unsafe, 20-02 Advanced Traits, 20-05 Macros; zero unsafe in projects |
| Effective Rust ch. 1 Types, Items 1-9 | Item 1 "Use the type system to express your data structures" ... Item 9 "Consider using iterator transforms instead of explicit loops" — https://www.lurklurk.org/effective-rust/ | Apply Item 6 newtype to Host; Item 7 builder to CrawlConfig |
| Effective Rust ch. 2 Traits, Items 10-13 | Item 10 "Familiarize yourself with standard traits" ... Item 13 "Use default implementations to minimize required trait methods" | Implement FromStr, Display, Default, Drop on crawler types |
| Effective Rust ch. 3 Concepts, Items 14-20 | Item 14 "Understand lifetimes" ... Item 20 "Avoid the temptation to over-optimize" | Fix one borrow error with Item 15 reasoning; ban panics in lib code (Item 18) |
| Effective Rust ch. 4 Dependencies, Items 21-26 | Item 21 "Understand what semantic versioning promises" ... Item 26 "Be wary of feature creep" | cargo tree -d; re-export url (Item 24); trim default features |
| Effective Rust ch. 5 Tooling, Items 27-32 | Item 27 "Document public interfaces" ... Item 32 "Set up a continuous integration (CI) system" | #![warn(missing_docs)]; doc-tests; clippy clean |
| Effective Rust ch. 6 Beyond Standard Rust, Items 33-35 | Item 33 "Consider making library code no_std compatible" ... Item 35 "Prefer bindgen to manual FFI mappings" | Read; write the no_std decision for crawler-core |
| Book + video | "Learn Rust With Entirely Too Many Linked Lists" (https://rust-unofficial.github.io/too-many-lists/) + "Crust of Rust: Smart Pointers" | Implement list ch. 1-3 (Box) and ch. 4 (Rc<RefCell>) |
Week 7-8 — Rust for Rustaceans
| Topic | Exact source | Drill |
|---|---|---|
| R4R ch. 1 "Foundations" | https://nostarch.com/rust-rustaceans | Notes on memory layout, variance, elision cases |
| R4R ch. 2 "Types" | Rust for Rustaceans, ch. 2 | Compare generic vs associated-type design of Fetch; record size_of |
| R4R ch. 3 "Designing Interfaces" | Rust for Rustaceans, ch. 3 | Design the Fetcher seam; make invalid states unrepresentable |
| R4R ch. 4 "Error Handling" | Rust for Rustaceans, ch. 4 | Typed error enum with #[source], no String payloads |
| R4R ch. 5 "Project Structure" | Rust for Rustaceans, ch. 5 | Workspace, feature flags, pub(crate) visibility audit |
| R4R ch. 6 "Testing" | Rust for Rustaceans, ch. 6 | Integration + doc tests + trybuild compile-fail test |
| R4R ch. 7 "Macros" | Rust for Rustaceans, ch. 7 | Write one macro_rules! + one proc-macro derive; inspect with cargo expand |
| R4R ch. 8 "Asynchronous Programming" | Rust for Rustaceans, ch. 8 | Defer full read to weeks 15-16; here classify Future/Pin vocabulary only |
| R4R ch. 9 "Unsafe Code" | Rust for Rustaceans, ch. 9 | Write a safety-contract comment for one unsafe example (not shipped) |
| R4R ch. 10 "Concurrency (and Parallelism)" | Rust for Rustaceans, ch. 10 | Scoped threads vs Arc; demo Send/Sync bounds |
| R4R ch. 11 "Foreign Function Interfaces" | Rust for Rustaceans, ch. 11 | Call libc getpid() via extern "C"; run under cargo test |
| R4R ch. 12 "Rust Without the Standard Library" | Rust for Rustaceans, ch. 12 | Compile a #![no_std] lib crate on host |
| R4R ch. 13 "The Rust Ecosystem" | Rust for Rustaceans, ch. 13 | Pin dependency versions; draft the cargo deny policy (used week 13) |
Week 9-10 — Rust Atomics and Locks + Miri + loom
| Topic | Exact source | Drill |
|---|---|---|
| Atomics ch. 1 "Basics of Rust Concurrency" | https://marabos.nl/atomics/basics.html | Three-way host-state map bench: Mutex<HashMap>, RwLock<HashMap>, DashMap |
100 Exercises 07_threads |
https://rust-exercises.com/100-exercises/07_threads/ | Run wr; complete the threads section |
| Atomics ch. 2 "Atomics" | https://marabos.nl/atomics/atomics.html | Stop flag + progress counter with AtomicBool/AtomicUsize |
| Atomics ch. 3 "Memory Ordering" | https://marabos.nl/atomics/memory-ordering.html | Write the message-passing litmus; label Relaxed vs Release/Acquire |
| Atomics ch. 4 "Building Our Own Spin Lock" | https://marabos.nl/atomics/building-spinlock.html | Implement SpinLock<T> + SpinLockGuard |
| Atomics ch. 5 "Building Our Own Channels" | https://marabos.nl/atomics/building-channels.html | One-shot channel plus blocking send/recv |
| Miri README | https://github.com/rust-lang/miri | rustup +nightly component add miri; cargo +nightly miri test on the queue crate; save the log |
| Rustonomicon "Aliasing" | https://doc.rust-lang.org/nomicon/aliasing.html | Write SOUNDNESS.md: validity, aliasing, initialization + Miri caveat |
| Atomics ch. 6 "Building Our Own "Arc"" | https://marabos.nl/atomics/building-arc.html | Implement Arc/Weak; test drop counts |
| Atomics ch. 7 "Understanding the Processor" | https://marabos.nl/atomics/hardware.html | Run the cache-coherence/reordering experiment; record machine results |
| Atomics ch. 8 "Operating System Primitives" | https://marabos.nl/atomics/os-primitives.html | Read futex (Linux) and os_unfair_lock (macOS) sections |
| Atomics ch. 9 "Building Our Own Locks" | https://marabos.nl/atomics/building-locks.html | Implement the ch. 9 mutex + condvar; bench vs std::sync::Mutex |
| Atomics ch. 10 "Ideas and Inspiration" | https://marabos.nl/atomics/inspiration.html | Read semaphore/RCU/seqlock; sketch one design |
| loom README | https://github.com/tokio-rs/loom | Add [target.'cfg(loom)'.dependencies] loom = "0.7"; RUSTFLAGS="--cfg loom" cargo test --release |
Week 11-12 — Performance
| Topic | Exact source | Drill |
|---|---|---|
| Perf Book "Benchmarking" | https://nnethercote.github.io/perf-book/benchmarking.html | Port normalize bench to criterion with black_box |
| Perf Book "Profiling" | https://nnethercote.github.io/perf-book/profiling.html | samply record on the profiling build; list top-3 sampled functions |
| Perf Book "Build Configuration" | https://nnethercote.github.io/perf-book/build-configuration.html | Add [profile.profiling] inherits = "release", debug = true |
| Perf Book "Heap Allocations" | https://nnethercote.github.io/perf-book/heap-allocations.html | Count allocations with divan AllocProfiler |
| Perf Book "Type Sizes" + "Standard Library Types" | https://nnethercote.github.io/perf-book/type-sizes.html ; .../standard-library-types.html | size_of::<FrontierItem>() before/after field reorder |
| Perf Book "Iterators" + "Bounds Checks" | https://nnethercote.github.io/perf-book/iterators.html ; .../bounds-checks.html | Replace String with Cow<'_, str> on the worst hot path |
| Perf Book "Inlining" + "Hashing" | https://nnethercote.github.io/perf-book/inlining.html ; .../hashing.html | Swap SipHash for FxHashMap in the hot map; bench delta |
| criterion book "Getting Started" + "Command-Line Options" + "HTML Report" | https://bheisler.github.io/criterion.rs/book/getting_started.html | cargo bench -- --save-baseline main; open HTML report |
| criterion book "Benchmarking With Inputs" + "Comparing Functions" + "Benchmarking async functions" | https://bheisler.github.io/criterion.rs/book/user_guide/benchmarking_with_inputs.html | Parameterize 1k/100k URLs; async bench of mock fetch |
| divan docs | #[divan::bench], Bencher, black_box, AllocProfiler — https://docs.rs/divan/latest/divan/attr.bench.html |
Rewrite one bench in divan; compare ergonomics with criterion |
| samply README | "Turn on debug info for full stacks" — https://github.com/mstange/samply | Commit one samply profile link or screenshot |
| hyperfine README | https://github.com/sharkdp/hyperfine | hyperfine --warmup 3 './old' './new'; paste before/after table |
| Perf Book "I/O" + "Parallelism" + "General Tips" | https://nnethercote.github.io/perf-book/io.html ; .../parallelism.html ; .../general-tips.html | BufReader/BufWriter pass through the NDJSON reader |
Week 13-14 — Production engineering
| Topic | Exact source | Drill |
|---|---|---|
| Rust Fuzz Book "Setup" + "Tutorial" | https://rust-fuzz.github.io/book/cargo-fuzz/setup.html ; .../tutorial.html | cargo +nightly fuzz init; first `fuzz_target!( |
| Rust Fuzz Book "Guide" + "Structure-Aware Fuzzing" | https://rust-fuzz.github.io/book/cargo-fuzz/guide.html ; .../structure-aware-fuzzing.html | Fuzz URL/robots parser with arbitrary; commit corpus |
| Rust Fuzz Book "Coverage" + "Fuzzing in CI" | https://rust-fuzz.github.io/book/cargo-fuzz/coverage.html ; .../ci.html | cargo fuzz coverage normalize; wire a CI fuzz job |
| cargo-mutants "Installation" + "Getting started" + "Using the results" | https://mutants.rs/installation.html ; .../getting-started.html ; .../using-results.html | cargo mutants -f src/politeness.rs; record kill rate |
| cargo-mutants "Skipping untestable code" + "Config file" | https://mutants.rs/skip.html ; https://mutants.rs/config-file.html | Add mutants config; kill or document one surviving mutant |
| cargo-deny checks | Advisories/Bans/Licenses/Sources — https://embarkstudios.github.io/cargo-deny/checks/advisories/index.html | cargo deny init && cargo deny check; commit deny.toml |
| cargo-deny CI action | https://github.com/EmbarkStudios/cargo-deny-action | Add uses: EmbarkStudios/cargo-deny-action@v2 to CI |
| insta docs "Getting Started" + "Snapshot Types" | https://insta.rs/docs/quickstart/ ; https://insta.rs/docs/snapshot-types/ | Snapshot CLI JSON output; cargo insta review |
| insta docs "Redactions" + "Testing CLIs" | https://insta.rs/docs/redactions/ ; https://insta.rs/docs/cmd/ | Redact timestamps; insta-cmd assert on binary output |
tracing #[instrument] |
https://docs.rs/tracing/latest/tracing/attr.instrument.html | Instrument every fetch fn with skip(client) + fields(host, status, bytes) |
| tracing-subscriber EnvFilter | https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html | RUST_LOG=info,crawler=debug; show span fields |
| console-subscriber + tokio-console | https://docs.rs/console-subscriber/latest/console_subscriber/ ; https://github.com/tokio-rs/console | RUSTFLAGS="--cfg tokio_unstable"; screenshot a blocked task |
| CI example | https://github.com/actions-rust-lang/setup-rust-toolchain | CI: fmt + clippy -D warnings + test + doc + deny; nightly fuzz/mutants job |
| Test runner | cargo-nextest — https://nexte.st/docs/ | cargo nextest run; compare failure output vs cargo test |
Week 15-16 — Deep async
| Topic | Exact source | Drill |
|---|---|---|
| Tokio tutorial "Setup" + "Hello Tokio" + "Spawning" | https://tokio.rs/tokio/tutorial/setup ; .../hello-tokio ; .../spawning | Scaffold crawler-lab; spawn 10k echo tasks |
| Tokio tutorial "Shared state" + "Channels" | https://tokio.rs/tokio/tutorial/shared-state ; .../channels | Arc<Mutex<_>> shared stats; mpsc worker pool |
| Tokio tutorial "I/O" + "Framing" | https://tokio.rs/tokio/tutorial/io ; .../framing | Implement read/write halves + Connection frame codec |
| Tokio tutorial "Async in depth" + "Select" + "Streams" | https://tokio.rs/tokio/tutorial/async ; .../select ; .../streams | Hand-poll a future; race two futures; StreamExt::next |
| Tokio topics "Graceful Shutdown" + "Bridging with sync code" + "Unit Testing" | https://tokio.rs/tokio/topics/shutdown ; .../bridging ; .../testing | CancellationToken + TaskTracker drain test with paused time |
mini-redis src/bin/server.rs + src/shutdown.rs |
https://github.com/tokio-rs/mini-redis/blob/master/src/bin/server.rs ; .../src/shutdown.rs | Replicate the shutdown fan-out pattern |
mini-redis src/connection.rs + src/frame.rs + src/parse.rs |
https://github.com/tokio-rs/mini-redis/blob/master/src/connection.rs ; .../src/frame.rs ; .../src/parse.rs | Read the frame codec; add parse unit tests |
mini-redis src/db.rs + src/cmd/mod.rs + src/cmd/subscribe.rs |
https://github.com/tokio-rs/mini-redis/blob/master/src/db.rs ; .../src/cmd/mod.rs ; .../src/cmd/subscribe.rs | Explain why std Mutex is safe across .await here |
| async book Part 1, "Concurrent programming" through "Channels, locking, and synchronization" | https://rust-lang.github.io/async-book/part-guide/concurrency.html ; .../concurrency-primitives.html ; .../sync.html | Notes: executor vs threads; implement join/select equivalents |
| async book Part 1, "Futures" + "Runtimes" + "Async iterators (streams)" | https://rust-lang.github.io/async-book/part-guide/futures.html ; .../runtimes.html ; .../streams.html | Future returning Ready on 3rd poll + ~30-line executor |
| async book Part 2, "Pinning" + "Cancellation and cancellation safety" + "Structured concurrency" | https://rust-lang.github.io/async-book/part-reference/pinning.html ; .../cancellation.html ; .../structured.html | Build the select! cancel-safety audit table |
| Video | "Decrusting the tokio crate" (Jon Gjengset, 3h32m) — https://www.youtube.com/watch?v=o2ob8zkeq2s + "Crust of Rust: async/await" | Watch blocking/cancellation/sync sections at 1.5x |
| fasterthanlime | "Pin and suffering" — https://fasterthanli.me/articles/pin-and-suffering ; "Catching up with async Rust" — https://fasterthanli.me/articles/catching-up-with-async-rust | Write the <=200-word Pin note; list async-fn-in-trait implications |
| fasterthanlime + Ryhl | "Understanding Rust futures by going way too deep" ; "Request coalescing in async Rust" ; "Async: What is blocking?" ; "Actors with Tokio" | Ship the 10k fetcher capstone; compute the concurrency knee curve |
Reference shelf
| Name | Link | Use |
|---|---|---|
| The Book, 3rd ed. | https://doc.rust-lang.org/stable/book/ | Canonical ch. 1-21 reference |
| Rustlings | https://rustlings.rust-lang.org/ | Compiler-error drills |
| 100 Exercises To Learn Rust | https://rust-exercises.com/100-exercises/ | TDD second pass with wr |
| Rust by Example | https://doc.rust-lang.org/rust-by-example/ | Syntax/std lookup |
| Rust CLI Book | https://rust-cli.github.io/book/ | clap, errors, exit codes, packaging |
| Command-Line Rust | https://github.com/kyclark/command-line-rust | Test-first CLI projects |
| Effective Rust | https://www.lurklurk.org/effective-rust/ | 35 actionable idiom items |
| Rust for Rustaceans | https://nostarch.com/rust-rustaceans | Post-beginner language depth |
| Rust Atomics and Locks | https://marabos.nl/atomics/ | Atomics, locks, memory ordering |
| The Rustonomicon | https://doc.rust-lang.org/nomicon/ | Unsafe rules and aliasing |
| The Rust Performance Book | https://nnethercote.github.io/perf-book/ | Benchmarking then optimizing |
| criterion book | https://bheisler.github.io/criterion.rs/book/ | Statistical bench harness |
| divan docs | https://docs.rs/divan/latest/divan/ | Lightweight bench API |
| samply | https://github.com/mstange/samply | CPU profiler with Firefox Profiler UI |
| hyperfine | https://github.com/sharkdp/hyperfine | Whole-binary timing |
| proptest book | https://proptest-rs.github.io/proptest/ | Property tests + shrinking |
| Rust Fuzz Book | https://rust-fuzz.github.io/book/ | cargo-fuzz workflow |
| cargo-mutants book | https://mutants.rs/ | Mutation testing |
| cargo-deny book | https://embarkstudios.github.io/cargo-deny/ | Supply-chain checks |
| insta docs | https://insta.rs/docs/ | Snapshot testing |
| tracing docs | https://docs.rs/tracing/latest/tracing/ | Spans/events + #[instrument] |
| tracing-subscriber docs | https://docs.rs/tracing-subscriber/latest/tracing_subscriber/ | EnvFilter + fmt layers |
| tokio-console | https://github.com/tokio-rs/console | Runtime task debugger |
| Tokio tutorial | https://tokio.rs/tokio/tutorial | Tokio end-to-end |
| mini-redis | https://github.com/tokio-rs/mini-redis | Reference async crate |
| Asynchronous Programming in Rust | https://rust-lang.github.io/async-book/ | Async mental model |
| Miri | https://github.com/rust-lang/miri | UB/aliasing detection |
| loom | https://github.com/tokio-rs/loom | Concurrency permutation testing |
| Too Many Linked Lists | https://rust-unofficial.github.io/too-many-lists/ | Ownership/Rc/RefCell practice |
| Crust of Rust (Jon Gjengset) | https://www.youtube.com/@jonhoo | Intermediate deep dives |
Weekly checkpoints
| Week | Binary pass/fail |
|---|---|
| 1 | Book ch. 1-6 read; Rustlings 00_intro through 11_hashmaps + quiz1/quiz2 green; cargo clippy -- -D warnings clean |
| 2 | Book ch. 7-13 read; all Rustlings groups through 23_conversions green; 100 Exercises 01_intro-06_ticket_management pass under wr |
| 3 | Rust CLI Book grrs builds, installs via cargo install --path ., and exits non-zero on bad input |
| 4 | Command-Line Rust 01_hello-08_cutr tests pass; one project uses thiserror in lib + anyhow in main |
| 5 | cargo test --workspace green on crawlkit + crawler-core + crawler-bin; clippy clean |
| 6 | Typed CrawlConfig builder rejects concurrency == 0 in a test; TRADEOFF.md generic-vs-dyn note committed |
| 7 | R4R ch. 1-6 read; crawler-core error taxonomy test matches every variant |
| 8 | R4R ch. 7-13 read; trybuild compile-fail test passes; SOUNDNESS.md draft committed |
| 9 | Spin lock + one-shot channel pass cargo +nightly miri test with a saved clean log |
| 10 | loom permutation test passes under RUSTFLAGS="--cfg loom"; every atomic has an ordering annotation |
| 11 | criterion baseline saved (--save-baseline main); samply profile of the top hotspot committed |
| 12 | PERF.md has divan numbers + hyperfine before/after; one measured >=1.3x win or profiler-backed memory-bound conclusion |
| 13 | cargo fuzz run >=15 min clean (or crash triaged); mutants kill rate recorded with one survivor resolved |
| 14 | CI green on fmt + clippy -D warnings + test + doc + cargo deny check; deny.toml committed |
| 15 | 10k-URL fetcher: high-water AtomicUsize never exceeds MAX_CONCURRENCY; Ctrl+C drain test exits 0 |
| 16 | Knee-curve table + CANCELLATION.md committed; proof-of-mastery checklist >=8/10 |
Sources
Part II — Crawling & Systems Curriculum: what to learn and exactly where
Rules: tables only; one line per cell; sources resolved in the Sources table at the bottom.
Weeks 3-4 — Async networking: Tokio tutorial chapters (exact names), mini-redis source files to read (exact paths), tokio-rs examples repo files, CancellationToken/TaskTracker docs pages, tracing tutorial sections
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| Futures + async syntax | Rust Book Ch. 17 (§17.1-17.6); Tokio Tutorial — Async in depth | Write a Future that returns Ready on 3rd poll; unit test asserts poll count == 3 |
| Runtime + spawning | Tokio Tutorial — Overview, Setup, Hello Tokio, Spawning | Spawn 10 tasks printing IDs; drain with JoinSet::join_next |
| Shared state | Tokio Tutorial — Shared state | Arc<Mutex<HashMap>> first with std::sync::Mutex, then tokio::sync::Mutex; note the borrow/compile delta |
| Bounded channels / backpressure | Tokio Tutorial — Channels; tokio::sync::mpsc docs |
Producer feeds 1e6 synthetic items into mpsc::channel(1_000); assert queue ≤1,000 and RSS <200 MB |
| Async I/O + framing | Tokio Tutorial — I/O, Framing | Implement a Frame codec for length-prefixed lines; cargo test round-trip |
| select! | Tokio Tutorial — Select; tokio::select! docs |
Race ctrl_c() vs 10s timer; add biased;, note which branches are cancel-safe |
| Streams + fan-out | Tokio Tutorial — Streams; futures StreamExt::buffer_unordered |
Fetch 10k mock URLs with buffer_unordered(64); record p50/p95 and peak RSS |
| Semaphore + JoinSet | tokio::sync::Semaphore docs; tokio::task::JoinSet docs |
AtomicUsize high-water mark proves in-flight ≤64 then ≤512; abort half and drain |
| mini-redis server core | mini-redis src/server.rs, src/db.rs |
Trace one connection from accept → task → command; 10-line written trace |
| mini-redis shutdown + protocol | mini-redis src/shutdown.rs, src/connection.rs, src/frame.rs, src/parse.rs |
Copy the broadcast shutdown pattern; test two concurrent writers |
| Cancellation + drain | tokio-util CancellationToken docs; tokio-util TaskTracker docs; Tokio Graceful Shutdown topic |
Ctrl-C mid-run: stop new work, drain ≤5s, print summary, zero live tasks in tokio-console |
| tracing spans | Tokio Getting started with Tracing; Tokio Next steps with Tracing | #[instrument(skip(client), fields(host, status, bytes))]; run RUST_LOG=info,crawler=debug |
| tokio-console | tokio-console repo; console-subscriber docs | RUSTFLAGS="--cfg tokio_unstable"; inject std::thread::sleep(2s); screenshot the blocked task |
| Tokio examples | tokio examples tinyhttp.rs, proxy.rs, chat.rs, graceful-shutdown.rs, custom-executor.rs |
Read proxy.rs; adapt its bidirectional copy into the crawler mock server |
Weeks 5-6 — HTTP + extraction: reqwest doc pages (Client, ClientBuilder fields, RequestBuilder, Response, error module), scraper docs (Html, Selector, ElementRef, element_ref selector methods), serde derive + field attrs pages, url crate docs sections (Url::parse/join, form_urlencoded), lol_html examples, dom_smoothie README API, wiremock docs pages, blake3 docs
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| One Client per process | reqwest Client docs |
Build once; clone into 64 tasks; wiremock asserts connection reuse |
| ClientBuilder fields | reqwest ClientBuilder docs (connect_timeout, read_timeout, timeout, pool_max_idle_per_host, pool_idle_timeout, redirect, cookie_store, https_only, gzip, brotli, zstd, deflate, retry) |
build_client() test: stalled body dies at read_timeout; redirect loop stops at Policy::limited(5) |
| Requests + responses | reqwest RequestBuilder docs (header, query, timeout, send); reqwest Response docs (status, headers, url, bytes, bytes_stream, error_for_status) |
Conditional GET with If-None-Match; hash bytes() with blake3; reject non-text/html before parse |
| Error taxonomy | reqwest Error docs |
Map 404 → Permanent, 429/timeout/connect → Retryable; assert zero retries on 404 |
| Built-in retries | reqwest retry module docs |
wiremock 503,503,200; count attempts == 3 and budget respected |
| HTML parse | scraper Html docs (parse_document, select, root_element) |
Extract all a[href] into owned Strings in a sync scope; never hold Html across .await |
| Selectors + elements | scraper Selector docs (parse, matches); scraper ElementRef docs (text, attr, html, inner_html, select) |
LazyLock<Selector> reused 1,000×; 20-card fixture extracts title/href; malformed fixture never panics |
| serde | serde derive docs; serde field attrs docs (rename_all, default, skip_serializing_if, flatten) |
JSONL PageRecord with Option fields; insta snapshot |
| url parsing | url Url docs (parse, join, query_pairs, set_fragment) |
Table test of 15 normalize cases + proptest idempotence |
| Query encoding | url form_urlencoded docs (append_pair, parse) |
Round-trip canonical query; assert deterministic ordering |
| Streaming rewrite | lol_html docs; lol_html examples/defer_scripts, examples/mixed_content_rewriter |
Pipe Response::bytes_stream into HtmlRewriter; tag split across chunks still extracts href |
| Readability | dom_smoothie repo README (Readability::new, Config, Article, .parse()) |
Clean 3 fixtures; raw vs extracted byte counts; JSON-LD skipped when invalid |
| Mock server | wiremock docs (MockServer, Mock, ResponseTemplate, matchers) |
Tests for bot UA header, gzip negotiation, 429 Retry-After, timeout; zero live network |
| Content hash | blake3 docs (blake3::hash, to_hex) |
Two URLs / one body → one dedup record; keep raw + normalized-text hashes separate |
Weeks 7-8 — Politeness & frontier: RFC 9309 exact section numbers (user-agent matching, groups, allow/disallow, crawl-delay), Google robots.txt doc pages, sitemaps.org protocol sections, IR Book ch. 20 exact subsection numbers (20.1-20.4 with titles), Mercator paper sections, governor docs pages, backoff crate README, texting_robots docs API, sqlx SQLite guide sections, rusqlite quickstart, conditional GET docs (RFC 9110 §13 sections)
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| REP user-agent matching + groups | RFC 9309 §2.2.1 (The User-Agent Line) | Implement case-insensitive longest product-token match + * fallback; pass RFC Figure 3 |
| REP allow/disallow | RFC 9309 §2.2.2 (The "Allow" and "Disallow" Lines) | Octet-prefix longest match, Allow wins ties, /robots.txt implicitly allowed; pass RFC §5.2 |
| REP special chars + other records | RFC 9309 §2.2.3 (Special Characters), §2.2.4 (Other Records) | Support *, $, #; ensure Sitemap: never terminates a group |
| REP access results | RFC 9309 §2.3.1.2 (Redirects, ≥5 hops), §2.3.1.3 ("Unavailable" 4xx), §2.3.1.4 ("Unreachable" 5xx), §2.3.1.5 (Parsing Errors) | Fixture per status: 404 allow-all, 5xx complete disallow + backoff, truncated parse still uses parseable rules |
| REP caching + limits | RFC 9309 §2.4 (Caching, ≤24h), §2.5 (Limits, ≥500 KiB) | Truncate at 512,000 bytes; paused-clock test expires cache at 24h |
| Google interpretation | Google robots.txt spec (Handling of errors and HTTP status codes; Grouping of lines and rules; Order of precedence for rules; URL matching based on path values) | Replicate its precedence examples; disallow: /*.htm beats allow: /page on /page.htm |
| Crawl-delay reality check | Google robots.txt spec (Syntax: crawl-delay not supported); Bing crawl-rate blog |
Honor Crawl-delay when present; never treat absence as permission to hammer |
| Sitemaps | sitemaps.org protocol (XML tag definitions; Using Sitemap index files; Sitemap file location; Informing search engine crawlers) | Parse gzipped sitemap index → deduped URL list; enforce 50,000 URLs / 50 MB; read lastmod |
| Frontier theory | IR Book Ch. 20: §20.1 Overview (§20.1.1 must-provide, §20.1.2 should-provide), §20.2 Crawling (§20.2.1 Crawler architecture, §20.2.1.1 Distributing the crawler, §20.2.2 DNS resolution, §20.2.3 The URL frontier), §20.3 Distributing indexes, §20.4 Connectivity servers | Draw front queues (priority) + back queues (per-host) + host→queue table + next-allowed heap |
| Mercator architecture | Mercator §3.1 Mercator's components, §3.2 The URL frontier, §3.5 Content-seen test, §3.8 URL-seen test, §3.10 Checkpointing | Size back queues ≈3× workers; note head/tail disk buffers and checkpoint format |
| Mercator hazards | Mercator §5.1 URL aliases, §5.2 Session IDs embedded in URLs, §5.3 Crawler traps | Trap tests: calendar links, ?sessionid=, infinite pagination; enforce max depth + max URLs/host |
| Per-host limiting | governor docs (RateLimiter::keyed, Quota::per_second, DefaultKeyedRateLimiter, until_ready) |
Default 1 rps/host; local mock 429s above 2 rps; crawler converges below 2 rps in 10s |
| Backoff with jitter | backoff docs (ExponentialBackoff, jitter, Error::retry_after) |
Seeded-RNG test: every sleep ∈ [0, min(cap, base·2^n)]; 404 attempts == 1; total budget capped |
| Robots crate API | texting_robots docs (Robot::new, Robot::allowed) |
RobotsCache per host; fixture suite from RFC §2.2 cases; diff against google/robotstxt tests |
| Persistent frontier | sqlx sqlite module (SqliteConnectOptions: journal_mode, synchronous, busy_timeout, create_if_missing; SqlitePool, SqliteTransaction); rusqlite docs quickstart |
WAL + single writer; kill -9 mid-crawl → no URL fetched twice, pending+done counts reconcile |
| Conditional GET | RFC 9110 §8.8 (8.8.2 Last-Modified, 8.8.3 ETag), §13.1.1-13.1.5 (If-Match/If-None-Match/If-Modified-Since/If-Unmodified-Since/If-Range), §13.2.2 (Precedence); MDN conditional requests | Store validators; second pass ≥90% 304s and zero bodies; 304 bumps recrawl interval geometrically |
| Retry-After | RFC 9110 §10.2.3; RFC 6585 §4 (429) | Parse delta-seconds and HTTP-date; clamp [1s, 10min]; delay = max(jittered_backoff, Retry-After) |
Weeks 9-10 — Rendering & CDP: getting-started-with-cdp repo lesson list, CDP domain docs (Page, Network, Runtime, Fetch - key methods), chromiumoxide README + examples (exact example names), Playwright docs pages (locators, auto-waiting, network, browser contexts, tracing), fantoccini README examples, SPA detection heuristics from ketch detect.go + StormCrawler JsRenderingDetector
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| CDP wire protocol | getting-started-with-cdp README "Protocol Fundamentals"; wsclient.js; SEND.js |
Launch Chrome --remote-debugging-port=9222; curl /json/version, /json/list, /json/protocol; send Target.setDiscoverTargets by hand |
| Targets + sessions | getting-started-with-cdp README "Targets & Sessions"; sessions.js |
Target.attachToTarget {flatten:true} → Page.navigate; every page message carries sessionId |
| Hierarchy + stability | getting-started-with-cdp README "Session Hierarchy", "Stable vs Experimental methods" | List which domains a page session sees vs browser session; mark experimental calls |
| Page domain | CDP Page domain (navigate, lifecycleEvent, loadEventFired, captureScreenshot, addScriptToEvaluateOnNewDocument, getLayoutMetrics) |
Hand-rolled client: navigate → wait loadEventFired → decode base64 screenshot to PNG |
| Network domain | CDP Network domain (enable, requestWillBeSent, responseReceived, loadingFinished, getResponseBody, setUserAgentOverride) |
Capture one XHR/JSON body with timings; switch UA mid-session |
| Runtime domain | CDP Runtime domain (evaluate, returnByValue:true, consoleAPICalled, exceptionThrown) |
Extract document.documentElement.outerHTML post-hydration; diff length vs raw HTTP body |
| Fetch interception | CDP Fetch domain (enable with patterns/requestStage, requestPaused, continueRequest, failRequest, fulfillRequest, getResponseBody) |
Block image/font at Request stage; count paused requests; measure bytes/time over 3 runs |
| chromiumoxide setup | chromiumoxide README; examples/evaluate.rs, examples/screenshot.rs |
Launch, goto, Page::content; always spawn the handler pump task (document the silent-hang failure) |
| chromiumoxide interception + pool | examples/interception.rs, examples/block-navigation.rs, examples/fetcher.rs, examples/console-logs.rs |
Context-per-job reuse, maxUses=25, health ping, kill -9 page recovery, requeue once |
| Playwright baseline | Playwright locators; Playwright auto-waiting (actionability) | Extract 10/10 quotes from /js/ with locators; HTTP-only gets 0/10; table transport × wall ms × bytes |
| Playwright network/contexts/tracing | Playwright network; Playwright browser contexts; Playwright trace viewer | context.route abort image/media/font with abort('blockedbyclient'); open trace of one failed render |
| fantoccini | fantoccini README; examples/basic.rs, examples/wait.rs |
Run geckodriver; Client::source after JS; document no-interception limitation |
| SPA detection signals | ketch extract/detect.go (visible text ≥200 chars, meaningfulBlocks > 2, script/text 3× low-text and 8× high-text gates, spaMarkers list) |
Rust classify(html) -> Static|LikelyShell|Ambiguous; 12 fixtures; ≥11/12 correct; SSR-with-blob returns Static |
| Hybrid routing | StormCrawler external/playwright/src/main/java/org/apache/stormcrawler/protocol/playwright/parsefilter/JsRenderingDetector.java; core/src/main/java/org/apache/stormcrawler/protocol/DelegatorProtocol.java |
Port the decision: static → HTTP, shell → bounded browser queue, decision cached per host |
| Embedded-data escape hatch | ketch detect.go markers (__next_data__, __nuxt__, __next_f, astro-island, q:container) |
Parse __NEXT_DATA__ JSON and extract 20 records with no Chrome process launched (assert) |
Week 11 — Distributed + observability: Docker Compose getting-started sections, NATS JetStream concepts pages + async-nats examples (exact names), Kafka introduction sections, metrics-rs README + metrics-exporter-prometheus docs, OpenTelemetry Rust getting-started, tokio-console README, samply README, DDIA chapters (exact numbers + titles, which 4 chapters to read and in what order), HelloInterview web-crawler breakdown sections
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| Compose basics | Docker Compose Quickstart Steps 1-2 (Set up the project; Define and start your services) and Step 7 (Inspect and debug your running stack) | compose.yaml with web+redis; docker compose up -d, logs -f, exec web env |
| Compose health + volumes + modularity | Docker Compose Quickstart Step 3 (health checks + depends_on: service_healthy), Step 5 (named volumes), Step 6 (include) |
Split broker/workers into infra.yaml; healthcheck gate; docker compose config shows resolved config |
| Compose scaling + watch | Docker Compose Quickstart Step 4 (Compose Watch) | docker compose up -d --scale worker=3; docker compose kill worker; assert no lost URLs |
| JetStream model | NATS JetStream concepts (How It Works; Streams; Consumers; Beyond Streams and Consumers) | nats stream add + nats consumer add --pull --ack explicit; kill consumer, restart, replay |
| JetStream Rust worker | async-nats examples jetstream_pull.rs, jetstream_push.rs, docs_learn_jetstream_pull_consumers_consume_continuous.rs |
Worker: fetch batch → write state → ack last; crash pre-ack → redelivery test |
| JetStream dedup + KV | async-nats examples docs_learn_jetstream_publishing_dedup.rs, kv.rs, docs_learn_jetstream_worker_pool_worker.rs |
Publish every URL twice with a message ID; assert exactly one state row; KV for host locks |
| Kafka model | Kafka Introduction sections "How does Kafka work in a nutshell?" and "Main Concepts and Terminology" | 10-line mapping of topics/partitions/keys onto host-partitioned frontier; note ordering guarantee |
| Metrics facade | metrics-rs repo README (counter!, gauge!, histogram!) |
Emit pages_fetched_total{status}, fetch_duration_seconds, frontier_queue_depth, retries_total{reason} |
| Prometheus endpoint | metrics-exporter-prometheus docs (PrometheusBuilder::install / install_recorder) |
curl localhost:9000/metrics; scrape into Prometheus container; panel in Grafana |
| Traces over OTLP | OpenTelemetry Rust getting started | Spans crawl.url, fetch, parse, enqueue; OTLP/HTTP → collector → Jaeger; one cross-service trace |
| Live task console | tokio-console README; console-subscriber docs | Run crawl under --cfg tokio_unstable; no task >1s busy time; screenshot |
| Profiling | samply README | [profile.profiling] inherits="release", debug=true; samply record ./target/profiling/crawler; one hotspot fixed |
| DDIA (read all 4 in this order) | DDIA 2e Ch. 6 Replication → Ch. 7 Sharding → Ch. 8 Transactions → Ch. 12 Stream Processing (1e equivalents: Ch. 5/6/7/11; 2e titles verified) | One page per chapter: which design decision in your crawler each chapter justifies (acks, partitions, idempotency, replay) |
| Crawler design breakdown | HelloInterview Web Crawler: "High-Level Design" (Frontier Queue, Crawler, DNS, S3 Text Data); "Potential Deep Dives" 1) fault tolerance, 2) politeness and robots.txt, 3) scale to 10B pages; "Final Design" | Whiteboard the final design; add DLQ, SQS visibility-timeout backoff, and domain-partitioned SQS/Kafka mapping |
Weeks 12-14 — Search & retrieval: Tantivy quickstart + examples (basic_search, snippet, faceted search - exact example filenames), IR Book chapters (numbers + titles for inverted index, TF-IDF, BM25, evaluation), BEIR README, fastembed-rs README API, qdrant-client Rust quickstart, HNSW paper sections, hnsw_rs + usearch docs, nDCG/MRR definitions (IR book ch. 8 sections)
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| Tantivy quickstart | Tantivy docs front-page example | Schema title(TEXT|STORED), body(TEXT|STORED), url(STRING|STORED), crawled_at(DATE); index 1,000 pages; top-10 p50 <20 ms |
| Basic search | tantivy examples/basic_search.rs |
Swap sample docs for your JSONL; cargo run --example basic_search; commit output |
| Snippets | tantivy examples/snippet.rs; tantivy::snippet module docs |
SnippetGenerator::create, set_max_num_chars(240), to_html(); escape crawled HTML; <5 ms added p95 |
| Facets + advanced examples | tantivy examples/faceted_search.rs, aggregation.rs, custom_tokenizer.rs, index_from_multiple_threads.rs, deleting_updating_documents.rs |
Add host facet; parallel indexing; tokenizer ablation table (recall@10 per config) |
| IR: inverted index | IR Book Ch. 1 (Boolean retrieval) | Build postings by hand for 10 docs; implement AND/OR merge; count comparisons |
| IR: TF-IDF | IR Book Ch. 6 (Scoring, term weighting & the vector space model) | Implement tf-idf + cosine; rank 10 queries; compare against BM25 ordering |
| IR: BM25 | IR Book Ch. 11 (Probabilistic information retrieval) | Implement Okapi BM25; sweep k1 ∈ {1.2, 1.5, 2.0}, b ∈ {0, 0.5, 0.75}; nDCG table over 25 queries |
| IR: evaluation | IR Book Ch. 8: §8.1 IR system evaluation, §8.2 Standard test collections, §8.3 Evaluation of unranked retrieval sets, §8.4 Evaluation of ranked retrieval results (nDCG), §8.5 Assessing relevance — MRR is not defined in Ch. 8; use the MRR row below | Implement P@k, R@k, MAP, nDCG@10; 25 labeled queries; failures triaged |
| MRR + metric selection | Pinecone offline evaluation; Weaviate retrieval evaluation metrics | Add MRR@10; one table per metric for BM25/vector/hybrid/rerank; note when each metric misleads |
| BEIR harness | BEIR repo README | Run one small BEIR subset (e.g. SciFact) through BM25/vector/hybrid/rerank; nDCG@10 committed as CSV |
| Embeddings | fastembed-rs README (TextEmbedding::try_new, embed; SparseTextEmbedding; TextRerank::rerank) |
Embed 1,000 chunks with BGE-small (384d) <60 s CPU; rerank top-50; p95 documented |
| Qdrant | qdrant-client docs quickstart; qdrant rust-client examples/query.rs |
Create collection, upsert 384d vectors, HNSW index, filter by host; ANN recall@10 ≥0.95 vs brute force; p95 <50 ms |
| HNSW theory | HNSW paper §4 Algorithm Description, §4.1 Influence of the construction parameters, §4.2 Complexity analysis (4.2.1 Search, 4.2.2 Construction, 4.2.3 Memory cost), §5.1-5.4 Performance Evaluation | Sweep M, ef_construction, ef_search; plot recall@10 vs p95; mark operating point |
| ANN crates | hnsw_rs docs; usearch docs (Index options, metrics, f16/i8/binary, save/load) |
Build 100k vectors in each; save/load; recall vs brute-force oracle at k=10 |
Weeks 14-16 — Data pipelines + ANN build: Arrow/Parquet Rust crates docs, LanceDB Rust quickstart, Ray Data key concepts pages, exa-d blog structure (sections to study), binary quantization + Matryoshka (Exa vector-db blog sections), SIMD in Rust (std::simd status + wide crate README)
| Topic | Source (exact page/chapter/file) | Drill |
|---|---|---|
| Arrow memory model | arrow docs (Array, RecordBatch, Schema; arrow::array, arrow::compute) |
Build a RecordBatch of 10k crawled rows; filter/sum via arrow::compute; record ns/row |
| Parquet writer | parquet docs (ArrowWriter, SerializedFileWriter, WriterProperties) |
Write host/date-partitioned dataset from the crawl; read one row back; bytes/doc recorded |
| Parquet tuning | parquet docs encoding + compression sections (WriterProperties::set_compression, set_encoding) |
Snappy vs zstd size/time at 100k docs; commit the table |
| LanceDB | lancedb docs quickstart (connect, create_table, FixedSizeList<Float32>, nearest_to) |
Local dir dataset with vector + text columns; create IVF_PQ index; query nearest 10 |
| Ray Data objects | Ray Data key concepts "Datasets and blocks" | Mapping table: Dataset/Block ↔ fragment/column in exa-d; write to docs/exa-d-mapping.md |
| Ray Data planning | Ray Data key concepts "Operators and Plans" | Print one logical plan; identify ReadOp → TaskPoolMapOperator; note OperatorFusionRule |
| Ray Data streaming | Ray Data key concepts "Streaming execution model" | Note which stages block (shuffle: sort, groupby) vs stream; map tokenize→embed stages |
| exa-d constraints | exa-d blog "Constraints to optimize for" (1. Typed columns and declarative dependencies, 2. Surgical Updates and Full Rebuilds, 3. Efficient + Parallel Execution) | Write the constraint→design table for your own pipeline |
| exa-d layers | exa-d blog "The Logical Layer: The Dependency Graph", "The Storage Layer: Structuring Data for Precise Updates", "The Execution Layer: Compute Only What is Necessary" | Implement missing-column diff over a Parquet dir; patch only fragments missing a column |
| exa-d execution | exa-d blog "Pipelined Execution on Ray Data", "DAG Example" | Rebuild the A–E DAG example in Python; assert convergence after injected partial failure |
| Matryoshka truncation | Exa vector-db blog "Optimization 1: Approximate the embedding" | Truncate 384d → {64,128,256}; recall@10 vs brute force for each; memory math table |
| Binary quantization | Exa vector-db blog "Optimization 2: Compress each dimension" | Sign-quantize documents (keep float query); compare Hamming vs float dot-product recall |
| Dot product + clusters + rerank | Exa vector-db blog "Optimization 3: Dot-product optimization", "Optimization 4: Max out the hardware", "Optimization 5: Don't search through everything", "Recovering recall", "Filtering" | 4-element lookup-table dot product; 1k k-means clusters; inverted indexes for filters; rerank top-100 |
| SIMD in Rust | std::simd docs (nightly-only portable_simd, experimental API); wide repo README (f32x8, f32x16) |
Dot product with wide::f32x8; criterion bench vs scalar; then port to std::simd on nightly and compare |
Reference shelf — docs to keep open
| Name | Link | Use in one line |
|---|---|---|
| Tokio Tutorial | https://tokio.rs/tokio/tutorial | Chapter map for the async runtime |
| Tokio topics index | https://tokio.rs/topics | Shutdown, tracing, bridging, testing pages in one place |
| tokio docs | https://docs.rs/tokio/latest/tokio/ | Semaphore, JoinSet, mpsc, select!, time |
| tokio-util docs | https://docs.rs/tokio-util/latest/tokio_util/ | CancellationToken + TaskTracker |
| mini-redis | https://github.com/tokio-rs/mini-redis | Readable async server reference |
| reqwest docs | https://docs.rs/reqwest/latest/reqwest/ | Client, ClientBuilder, Response, retry |
| scraper docs | https://docs.rs/scraper/latest/scraper/ | Html, Selector, ElementRef |
| url docs | https://docs.rs/url/latest/url/ | Parsing, joining, form_urlencoded |
| serde docs | https://serde.rs/ | derive + field attributes |
| wiremock docs | https://docs.rs/wiremock/latest/wiremock/ | Offline HTTP boundary tests |
| RFC 9309 | https://www.rfc-editor.org/rfc/rfc9309.html | Normative robots.txt rules |
| Google robots.txt spec | https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec | Real-world interpretation + errors |
| sitemaps.org protocol | https://www.sitemaps.org/protocol.html | URL discovery format + limits |
| IR Book (HTML) | https://nlp.stanford.edu/IR-book/html/htmledition/irbook.html | Free IR text (Ch. 1, 6, 8, 11, 20) |
| Mercator paper | https://marc.najork.org/papers/wwwmerc.pdf | Frontier/politeness architecture of record |
| RFC 9110 | https://www.rfc-editor.org/rfc/rfc9110.html | Validators, preconditions, Retry-After |
| CDP home | https://chromedevtools.github.io/devtools-protocol/ | Domain/method reference |
| getting-started-with-cdp | https://github.com/aslushnikov/getting-started-with-cdp | Smallest CDP mental model |
| Playwright docs | https://playwright.dev/docs/intro | Locators, actionability, network, tracing |
| chromiumoxide | https://github.com/mattsse/chromiumoxide | Async Rust CDP client + examples |
| ketch | https://github.com/1broseidon/ketch | SPA-shell detection heuristics |
| StormCrawler | https://github.com/apache/stormcrawler | DelegatorProtocol + JsRenderingDetector |
| NATS JetStream concepts | https://docs.nats.io/concepts/jetstream | Streams, consumers, acks, KV |
| async-nats docs | https://docs.rs/async-nats/latest/async_nats/ | Rust JetStream client |
| Docker Compose docs | https://docs.docker.com/compose/ | Local multi-service fleet |
| metrics-rs | https://github.com/metrics-rs/metrics | Metrics facade |
| OpenTelemetry Rust | https://opentelemetry.io/docs/languages/rust/ | Traces/metrics/logs setup |
| tokio-console | https://github.com/tokio-rs/console | Live async task inspection |
| samply | https://github.com/mstange/samply | Sampling profiler (macOS-friendly) |
| DDIA | https://dataintensive.net/ | Systems vocabulary (2e TOC via O'Reilly) |
| HelloInterview Web Crawler | https://www.hellointerview.com/learn/system-design/problem-breakdowns/web-crawler | Crawler design interview reference |
| Tantivy docs | https://docs.rs/tantivy/latest/tantivy/ | BM25 engine quickstart + API |
| BEIR | https://github.com/beir-cellar/beir | Retrieval benchmark harness |
| fastembed-rs | https://github.com/Anush008/fastembed-rs | Local ONNX embeddings + rerank |
| qdrant-client | https://docs.rs/qdrant-client/latest/qdrant_client/ | Vector DB client |
| HNSW paper | https://arxiv.org/abs/1603.09320 | ANN graph algorithm |
| LanceDB | https://docs.rs/lancedb/latest/lancedb/ | Embedded vector/columnar store |
| Ray Data | https://docs.ray.io/en/latest/data/data.html | Distributed data processing model |
| exa-d blog | https://exa.ai/blog/exa-d | Incremental derived-column pipeline design |
| Exa vector-db blog | https://exa.ai/blog/building-web-scale-vector-db | Matryoshka + binary quantization + clustering |
| std::simd | https://doc.rust-lang.org/std/simd/ | Portable SIMD status (nightly) |
| wide | https://github.com/Lokathor/wide | Stable portable SIMD types |
Weekly checkpoints — one binary pass/fail per week
| Week | Pass/fail test |
|---|---|
| 3 | crawler-lab fetches 10k mock URLs at concurrency 64 with in-flight never exceeding 64 and prints p50/p95 |
| 4 | Ctrl-C mid-run drains ≤5s, prints summary, exits 0; tokio-console shows zero live tasks; RUST_LOG spans include host/status/bytes |
| 5 | build_client() passes offline tests: UA header, gzip, timeout, redirect cap, non-HTML skip; 5-site scraper emits clean JSONL |
| 6 | cargo test green offline across fixtures + wiremock; normalizer proptest idempotent; dedup stats printed; blake3 dedupe demo passes |
| 7 | Robots gate passes RFC fixture matrix; sitemap index (gzipped) yields every URL once; frontier property test: zero host-overlap over 10k URLs |
| 8 | kill -9 mid-crawl then restart: no URL fetched twice, counts reconcile; ≥90% 304s on second pass; Retry-After honored ±100 ms |
| 9 | Hand-rolled CDP client navigates + screenshots; 12-fixture SPA classifier ≥11/12 correct with reason strings; embedded __NEXT_DATA__ extraction with no Chrome |
| 10 | Hybrid crawl of 100 URLs: browser renders ≤ shells+10%, static render count == 0; Playwright blocked-resource byte table committed; fantoccini example runs |
| 11 | docker compose up brings up broker+workers+Prometheus+Grafana; duplicate publish → one state row; crash before ack → redelivery; one Jaeger trace across stages |
| 12 | Tantivy query p50 <20 ms over 1k docs; snippet <b> highlights escaped; BM25 implementation matches Tantivy ranking on 10 queries |
| 13 | 25-query eval table (BM25/vector/RRF/hybrid+rerank) with nDCG@10; BEIR subset CSV committed; MRR@10 computed |
| 14 | Qdrant ANN recall@10 ≥0.95 vs brute force at p95 <50 ms; HNSW sweep curve committed with operating point marked |
| 15 | Parquet lake build: 100k docs with docs/s + bytes/doc; backfill patch recomputes ≤12% of derived rows; docs/exa-d-mapping.md committed |
| 16 | Truncation + binary-quantization recall tables; 4-element lookup-table dot product benchmarked vs scalar; wide::f32x8 dot product ≥2× scalar |
Sources
Part III — The JD Project List (frozen — this is the endgame)
The projects below are the only things you build for the portfolio. Titles are final; use them in READMEs, resume, and outreach. Specs already exist — this list is what you keep in front of you while working through Parts I and II.
Foundation drills (weeks 1-4 — practice, not portfolio, no polish needed)
| # | Title | Repo | Scope | Spec |
|---|---|---|---|---|
| F1 | URL Normalizer | urlsmith |
Parse/join/canonicalize URLs; dedupe 10k inputs by normalized form | 01 ex. 1-2 |
| F2 | Robots Checker | roboctl |
Fetch + parse robots.txt; answer allowed(url, ua) with crawl-delay |
01 ex. 5 |
| F3 | Title Extractor | headline |
Extract title/meta/canonical/JSON-LD from HTML fixtures | 01 ex. 3 |
| F4 | Structured Exporter | tabula |
Emit crawl results as CSV + JSON with typed errors | 01 ex. 4 |
Portfolio projects (11 titles)
| # | Title | Repo | JD bullet it proves | Published number | Build weeks | Spec |
|---|---|---|---|---|---|---|
| 1 | crawlerd — Distributed Crawl Engine | crawlerd |
Build a distributed crawler that can handle 100M+ pages per day | Capacity model to 100M/day + measured local pages/s | 3-5 | 09 A, 17 §1 |
| 2 | politia — Politeness & Rate-Limiting Engine | politia |
Optimize crawl politeness and rate limiting across thousands of domains | 5,000-host politeness simulation report + crawl-delay/429 audit | 4-5 | 04, 17 §1 |
| 3 | census — Coverage Scheduler | census |
Create intelligent crawl scheduling and prioritization algorithms | Coverage-per-fetch vs FIFO improvement (%) + recrawl policy sim | 5-6 | 04, 17 §1 |
| 4 | lumen — Hybrid Render Pipeline | lumen-crawl |
Detect and handle dynamic content, JavaScript rendering, CDP/Playwright | SPA classifier precision/recall + render-cost delta table | 6-7 | 05, 09 B, 17 §2 |
| 5 | aegis — Challenge Resilience Harness | aegis |
Detect and handle anti-bot measures (responsibly) | Failure taxonomy + backoff effectiveness report | 7-8 | 17 §2 |
| 6 | watchtower — Crawl Control Plane | watchtower |
Optimize a system to an exceptional degree | Uptime, p99 fetch latency, error budget dashboards | 8-9 | 06, 09 A.7 |
| 7 | indexer — Hybrid Search Service | indexer-rs |
Search/retrieval quality (Exa's core problem) | recall@10 / nDCG@10 (BEIR subset) + p95 query latency | 9-10 | 07, 09 C, 17 §3 |
| 8 | anneal — ANN Index from Scratch | anneal |
Backend JD: custom vector DB over 1B vectors in <100ms | recall@10 vs p95 <100ms on 1M vectors + 1B extrapolation | 10-12 | 17 §3 |
| 9 | webstore — Mini Exa-d Pipeline | webstore |
Backend JD: pipelines handling millions of documents/day | Documents/sec + backfill correctness report | 12-14 | 17 §5 |
| 10 | tenb — Search at 10B Pages (feasibility) | tenb-search |
Backend JD: Google-level keyword search over 10B pages | Design doc + cost model + 1-10M doc prototype numbers | 14-15 | 17 §6 |
| 11 | crwl-bench — Crawler Benchmark Series | crwl-bench |
Proof/distribution for all bullets | Reproducible matrices (Rust vs Python, HTTP vs browser) + 4 writeups | 11-16 | 09 D, 17 §4 |
Title → resume line (paste-ready)
| # | Resume line |
|---|---|
| 1 | Built crawlerd, a Rust/Tokio distributed crawler (scheduler, fetch workers, persisted frontier, domain partitioning) with a measured 100M pages/day capacity model. |
| 2 | Built politia, a robots-aware politeness engine (per-host token buckets, crawl-delay, 429/Retry-After backoff) validated on a 5,000-host simulation. |
| 3 | Built census, a crawl prioritizer (OPIC + freshness/recrawl) beating FIFO on coverage-per-fetch in a reproducible benchmark. |
| 4 | Built lumen, an HTTP-first crawler with a Playwright/CDP fallback worker pool and an SPA classifier (precision/recall reported), cutting browser usage while preserving coverage. |
| 5 | Built aegis, a challenge-resilience harness classifying 403/429/5xx/challenge responses and enforcing graceful-degradation policy. |
| 6 | Built watchtower, the crawler's observability plane: Prometheus/Grafana dashboards, SLOs, trace spans, and chaos crash-resume tests. |
| 7 | Built indexer-rs, a hybrid BM25 + dense retrieval service with snippets; BEIR-subset nDCG@10 and p95 latency reported. |
| 8 | Built anneal, a from-scratch ANN index (HNSW/IVF+PQ) in Rust: recall@10 vs p95 latency curve, <100ms at 1M vectors, 1B-vector cost model. |
| 9 | Built webstore, a mini exa-d columnar pipeline (crawl → parse → Parquet/Lance) with backfill patching at N docs/sec. |
| 10 | Wrote tenb-search, a 10B-page keyword-search feasibility study with sharding math, cost model, and a working 10M-doc Tantivy prototype. |
| 11 | Published crwl-bench, a reproducible crawler benchmark series (Rust vs Python, HTTP vs browser) with 4 technical writeups. |
Rust repos to study (per project — Rust-first, all links verified Sep 2026)
Read these while building; every one is Rust and maps to a JD bullet. "Study" names the module/file/area worth reading.
Foundation drills
| Repo | Study | For |
|---|---|---|
| Smerity/texting_robots | Parser + matcher internals; RFC 9309 line handling | roboctl |
| rust-scraper/scraper | Html::parse_document, Selector::parse, ElementRef traversal, fixture tests |
headline |
| cloudflare/lol-html | Streaming rewriter, Settings, element handlers — zero-copy extraction |
headline, indexer |
| servo/rust-url | WHATWG parse/serialize rules, Url::join, percent-encoding |
urlsmith |
1 · crawlerd — Distributed Crawl Engine
| Repo | Study | JD link |
|---|---|---|
| spider-rs/spider | spider/src/website.rs crawl loop, worker/scheduler modules, CLI + subscribe channel |
distributed crawler |
| hyperium/hyper | Pooling, HTTP/1.1 vs 2, backpressure in the client stack | fetch path |
| tower-rs/tower | Service layering: retry, timeout, load-shed, rate-limit middleware | backpressure |
2 · politia — Politeness & Rate-Limiting
| Repo | Study | JD link |
|---|---|---|
| boinkor-net/governor | RateLimiter, quota cells, jitter; token-bucket math |
per-host limits |
| Smerity/texting_robots | crawl-delay + user-agent matching | robots compliance |
| ihrwein/backoff | ExponentialBackoff builder: base, jitter, max interval |
429/5xx backoff |
| TrueLayer/reqwest-middleware | reqwest-retry + reqwest-tracing; retryable vs terminal classification |
retry taxonomy |
| moka-rs/moka | TinyLFU eviction + TTL cache design | robots/ETag cache |
| hickory-dns/hickory-dns | Resolver caching, TTLs, retries for thousands of hosts | DNS politeness |
3 · census — Coverage Scheduler
| Repo | Study | JD link |
|---|---|---|
| spider-rs/spider | How crawl budgets and link discovery are organized | scheduling |
| garro95/priority-queue | Indexed heap with decrease-key — the frontier data structure | prioritization |
4 · lumen — Hybrid Render Pipeline
| Repo | Study | JD link |
|---|---|---|
| mattsse/chromiumoxide | Browser::launch, Page::navigate/evaluate, Network events; examples/ |
CDP |
| rust-headless-chrome/rust-headless-chrome | Tab::wait_for_element, navigation lifecycle, page pooling |
SPA waits |
| jonhoo/fantoccini | WebDriver client design, capabilities/session management | fallback driver |
| padamson/playwright-rust | Page/context APIs mirroring Playwright TS — the sidecar contract | Playwright parity |
5 · aegis — Challenge Resilience (study mechanics for detection + degradation, never bypass)
| Repo | Study | JD link |
|---|---|---|
| 0x676e67/rquest | How a Rust client reproduces browser TLS/HTTP fingerprints — build your detection taxonomy from it | anti-bot handling |
| 0x676e67/wreq | Behavior profiles, session/redirect handling in a newer codebase | anti-bot handling |
| rust-headless-chrome/rust-headless-chrome | Real-browser fingerprint surfaces via CDP — parity fixtures | detection tests |
6 · watchtower — Control Plane
| Repo | Study | JD link |
|---|---|---|
| metrics-rs/metrics | counter!/gauge!/histogram!, recorder design, Prometheus exporter |
/metrics |
| tokio-rs/tracing | #[instrument], span fields, EnvFilter; span lifecycle tests |
tracing |
| tokio-rs/console | Task/waker introspection when tasks stall | debugging |
| open-telemetry/opentelemetry-rust | OTLP export, span/metric providers | distributed traces |
| vectordotdev/vector | Source → transform → sink architecture, backpressure | control plane |
7 · indexer — Hybrid Search Service
| Repo | Study | JD link |
|---|---|---|
| quickwit-oss/tantivy | IndexWriter, Searcher, segments; examples/snippet.rs |
BM25 + snippets |
| meilisearch/meilisearch | Indexing pipeline, incremental updates, API layers | service design |
| qdrant/qdrant | Segment + filter architecture for the dense side | hybrid retrieval |
8 · anneal — ANN Index from Scratch
| Repo | Study | JD link |
|---|---|---|
| InstantDomain/instant-distance | Minimal readable HNSW in Rust — read first | your index |
| jean-pierreBoth/hnswlib-rs | HNSW insert/search heaps, layer assignment | your index |
| qdrant/qdrant | Production HNSW with quantization + filtering | quantization |
| unum-cloud/USearch | SIMD distance kernels (Rust API, C++ core — note the split) | SIMD perf |
9 · webstore — Mini Exa-d Pipeline
| Repo | Study | JD link |
|---|---|---|
| lance-format/lance | Columnar format, versioning, fragment patching = backfills | mini exa-d |
| apache/arrow-rs | Array layouts, zero-copy, Parquet encoding | columnar store |
| risingwavelabs/risingwave | Streaming state, checkpoints, backpressure at scale | pipeline ops |
10 · tenb — Search at 10B Pages
| Repo | Study | JD link |
|---|---|---|
| quickwit-oss/quickwit | Distributed search over object storage: splits, metastore, merge policy | 10B architecture |
| quickwit-oss/tantivy | Segment merge + doc store internals | index math |
| apache/arrow-rs | Parquet as the storage substrate for the cost model | cost model |
11 · crwl-bench — Benchmark Series
| Repo | Study | JD link |
|---|---|---|
| sharkdp/hyperfine | CLI benchmark methodology: warmup, outlier detection, stats output | harness |
| criterion-rs/criterion.rs | Statistical comparison, baselines, regression detection | microbenchmarks |
| mstange/samply | Profiling workflow + symbolication for before/after claims | profiling |
Everyone reads these (foundation for all projects)
| Repo | Study | For |
|---|---|---|
| tokio-rs/mini-redis | server.rs, shutdown.rs, db.rs — production Tokio patterns |
async patterns |
| tokio-rs/tokio | Runtime internals, select!, channels, time |
core runtime |
| servo/html5ever | HTML5 tokenizer/tree-builder internals | parser depth |
| rusqlite/rusqlite | SQLite binding, statement caching | persistence |
Rules
- Nothing else gets built. Toy scrapers, tutorial clones, ML training, and anti-bot bypass tooling are out of scope.
- Every project ships with: README (architecture diagram, quickstart, results table), tagged release, and its "published number" above.
- Parts I and II are the only learning material; this list is the only build material. If a week has no reading from the syllabus and no project from this list, it does not exist.