A port of Crawlee to Rust, focused on what Rust is better at than JavaScript for crawling: fast HTTP crawling, HTML parsing and JSON processing. It keeps Crawlee's architecture and behavior; the JavaScript and Python versions remain the reference.
Status: early.
BasicCrawler,HttpCrawlerandHtmlCrawlerwork end to end, and storage is written to./storagein the same format as Crawlee for JS and Python. See What's not there yet.
use crawlee::{EnqueueLinksOptions, HtmlContext, HtmlCrawler};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let crawler = HtmlCrawler::builder()
.max_requests_per_crawl(50)
.request_handler(|ctx: HtmlContext| async move {
// Parsed lazily, once, on the blocking thread pool.
let title = ctx.with_html(|doc| doc.title()).await?;
ctx.push_data(&serde_json::json!({ "url": ctx.url().as_str(), "title": title }))?;
// Links are extracted by streaming the body, without a DOM.
ctx.enqueue_links(EnqueueLinksOptions::new()).await?;
Ok(())
})
.build()?;
let stats = crawler.run(["https://crawlee.dev"]).await?;
println!("{} pages", stats.requests_succeeded);
Ok(())
}These templates are ready to run; see templates/README.md:
cd templates/getting-started && cargo run # HtmlCrawler on crawlee.dev → storage/results.json
cd templates/html && cargo run # router with list and detail pages
cd templates/json-api && cargo run # typed JSON from the Hacker News APITo start your own project from one of them:
cargo generate --git https://github.com/apify-projects/crawlee-rs templates/html --name my-crawler.
This is the same workload in both implementations, crawling a local fixture site of 58 KB product
listings. For each page the handler parses the DOM, extracts the title and 20 products (3 selectors
each), pushes one dataset item and runs enqueue_links (about 80 links per page, most of them
duplicates). Concurrency is pinned (min = max) in both, and storage is in memory.
| Scenario | Implementation | Pages/s | CPU per page | Peak RSS |
|---|---|---|---|---|
| 5,000 pages, no latency, concurrency 50 | crawlee-rs HtmlCrawler (reqwest) |
2,326 | 1.32 ms | 120 MiB |
Crawlee for JS CheerioCrawler (impit) |
87 | 13.46 ms | 386 MiB | |
| 2,000 pages, 100 ms latency, concurrency 100 | crawlee-rs | 856 | 1.36 ms | 72 MiB |
| Crawlee for JS | 81 | 14.47 ms | 373 MiB |
- Setup: 4 vCPUs, one run each. Measured with
crates/crawlee/examples/bench_*.rsandconformance/bench/cheerio-crawl.mts; see conformance/README.md. - CPU per page is the comparable metric. Rust spends about 10× less CPU per page.
- Throughput also reflects cores: Rust uses every core, while Node runs the handler on one.
- In the latency run, JS is still CPU-bound on a single core. Rust sits close to the latency ceiling, which at concurrency 100 and 100 ms is 1,000 pages/s.
- Caveats:
- The JS numbers come from running Crawlee from its TypeScript sources with Node 22 type stripping. That changes load time, not steady-state speed.
- JS uses its default client,
impit, which impersonates browsers. Rust uses plain reqwest.
| Crate | Crawlee for JS counterpart | Contents |
|---|---|---|
crawlee |
crawlee |
Re-exports everything below |
crawlee-utils |
@crawlee/utils, @apify/utilities |
normalize_url, enqueue strategies, public-suffix domains, glob/regex URL filters, streaming link extraction (lol_html), HTML attribute entity decoding, charset prescan, robots.txt and sitemap parsing, Retry-After |
crawlee-core |
@crawlee/core, @crawlee/types, @crawlee/fs-storage |
Request (with the JS wire format), Dataset, KeyValueStore, RequestQueue, the 4-trait StorageBackend contract, the file-system and in-memory backends, request-scoped StorageTransaction, Configuration (CRAWLEE_* variables, crawlee.json), RequestLoader / RequestManagerTandem, EventManager, RecoverableState, system info (cgroup-aware), Services |
crawlee-http-client |
@crawlee/http-client |
The HttpClient / Transport traits, the shared redirect and cookie logic, a reqwest transport |
crawlee-basic |
@crawlee/basic |
BasicCrawler: task loop, retries and error classification, sessions and proxies, router, statistics, enqueue_urls filtering, the typed context pipeline, autoscaling (ConcurrencySystem, SystemStatus, load signals), robots.txt, Sitemap / SitemapRequestLoader, ThrottlingRequestManager |
crawlee-http |
@crawlee/http, @crawlee/cheerio |
HttpCrawler (typed JSON via serde), HtmlCrawler (lazy DOM, streaming enqueue_links), body decoding |
crawlee-impit (integrations/) |
@crawlee/impit-client |
A transport with browser TLS and HTTP/2 fingerprints. It is kept outside the workspace because impit needs patched dependencies; see its README |
Storage works as in Crawlee for JS. By default, crawlers write to ./storage:
storage/
datasets/default/000000001.json, ...
key_value_stores/default/<key>, <key>.__metadata__.json
request_queues/default/<hash>.json
- Shared format. The format comes from crawlee-storage, the Rust core behind the file-system storage of Crawlee for JS and Python. Each of the three reads what the others wrote.
- Purging. When the first crawler of a process starts, the default storages and the unnamed (aliased) ones are emptied. Named storages are kept.
- Restarts. A request queue keeps its handled requests across runs. With
CRAWLEE_PURGE_ON_START=false, a crawl resumes where it stopped. - Settings:
CRAWLEE_STORAGE_DIR,CRAWLEE_PURGE_ON_START,CRAWLEE_PERSIST_STORAGE(falsekeeps everything in memory), or the same keys incrawlee.json. - In code:
Services::from_configuration(...), orServices::in_memory()for tests.
Services also holds the EventManager, as in JS. It emits:
PersistStateevery 60 s and at the end of a run;SystemInfoevery second: CPU and memory, read from cgroups in a container;StatusMessage.
Migrating and Aborting pause the crawl and save the statistics; the Apify SDK will emit
them. On PersistState, crawlers save their state to the default key-value store, under the
keys and in the record formats of Crawlee for JS:
| Key | What |
|---|---|
CRAWLEE_CRAWLER_STATISTICS_{id} |
Statistics. A resumed crawl continues its counters and runtime |
CRAWLEE_SESSION_POOL_STATE_{id} |
Sessions, with their cookies in tough-cookie's JSON |
CRAWLEE_STATE / CRAWLEE_STATE_{id} |
ctx.use_state(default) / crawler.use_state(default) |
RecoverableState is the building block for all three, and is public for custom state.
- robots.txt.
.respect_robots_txt(true)(or.robots_txt_user_agent("MyBot")) skips requests that robots.txt disallows. They are checked when enqueued and before they are fetched. robots.txt is cached per origin; the matching is a port ofrobots-parser. - Rate limits.
ThrottlingRequestManagerkeeps a request queue per domain. When a domain answers 429, its requests are held back: for theRetry-Afterdelay, or with an exponential backoff of 2 s, 4 s, … up to 60 s.- The request is retried without spending a retry or blaming its session.
- robots.txt
Crawl-delayis honoured as a minimum interval between requests to the domain. .same_domain_delay(d)sets such an interval for every site, and adds a throttling manager when the crawler has none.
- Sitemaps.
Sitemap::loadfetches sitemaps, nested and gzipped ones included, and returns their pages.SitemapRequestLoadercrawls them in the background. Put it in front of a request queue withRequestManagerTandem.- Its progress is saved, so a resumed crawl continues where it stopped.
The goal was to keep Crawlee v4's architecture and change it only where Rust needs a different design.
- Context pipeline. v4's
ContextPipelinemerges property bags at runtime. Here it is a typed chain of middlewares (Middleware<In>, composed withThen):BasicContext → HttpContext → HtmlContext. Each layer derefs to the one below, soctx.push_data,ctx.request()andctx.url()all work on anHtmlContext. - Handlers take the context by value. They are plain
|ctx| async move { ... }closures, with no lifetimes andSend + 'staticfutures.- The context owns its
Requestand hands it back to the crawler when dropped. Changes the handler (orerror_handler) makes to the request are therefore kept for the retry, as in JS. - This works even when the handler times out or panics.
- The context owns its
- Transactions. Storage writes made through the context are journaled and applied only when
the handler succeeds, as in v4.
- Items are serialized once, when pushed, into
RawValuebytes. There is nostructuredClonestep, and a backend (such as a future Apify one) can send those bytes as they are.
- Items are serialized once, when pushed, into
- HTML without
unsafe.scraperis built with atomic tendrils, so the DOM isSend.with_htmlcan parse it once on the blocking pool and cache it on the context.- Handlers stay
Send, and parsing runs in parallel on every core without stalling the I/O threads.
- Links without a DOM.
enqueue_linksstreams the body throughlol_html. It decodes attribute entities with the WHATWG algorithm, using html5ever's entity table, and falls back to the DOM only for selectorslol_htmlcannot stream. - Zero-copy bodies. A response body is
Bytesfrom the socket to the handler. Valid UTF-8 is validated with SIMD and borrowed, not copied. - Errors. Handlers return
anyhow::Result<()>. To steer retries, returnSessionError,NonRetryableError,RetryRequestError,CriticalErrororRequestThrottledError; they are found anywhere in the error chain. The retry logic portsrequestFunctionErrorHandler. - Autoscaling ports
ConcurrencySystemwith the same defaults:- It runs between
min_concurrency(1) andmax_concurrency(200).HttpCrawlerandHtmlCrawlerstart at 10. - Every 10 s it scales by 5 % up or down, based on memory, CPU, storage rate limits and runtime responsiveness.
- Runtime responsiveness stands in for the event loop signal of Node. It measures how late a timer task fires on the tokio runtime.
fixed_concurrency(n)pins it, andmax_requests_per_minutecaps the rate.
- It runs between
#![forbid(unsafe_code)]holds in every crate. The lint is set at the workspace level.
conformance/oracle runs Crawlee for JS from its sources as an oracle and writes golden files
to conformance/golden. crates/crawlee/tests/golden.rs replays all of them against the Rust code.
- Currently all 755 checks match, with an empty allow-list. The cases cover:
normalizeUrland unique keys;- request ids;
- the
RequestJSON, includinguserData.__crawlee; tldtsdomains;- enqueue strategies;
- link extraction as
CheerioCrawlerdoes it; - the charset prescan;
- KVS JSON formatting;
- robots.txt matching (
robots-parser), sitemap XML parsing andRetry-After.
- Deliberate differences are listed in conformance/allowed-differences.md. A golden-file mismatch that is not on that list fails CI.
These are planned in this order, following docs/plan.md:
- Remaining request sources and options:
RequestList;extend_context,extend_timeout,skip_navigationinHttpCrawler;- the
stalledreadiness state of throttling; - CSV export.
- Side-by-side crawl comparison (level 3 of the conformance plan): the same crawl run by both implementations against the fixture server, comparing their outputs. Today the two are compared function by function (golden files) and by benchmark.
- Later: an Apify SDK crate on top of the Rust
apify-client; browser and adaptive crawlers.
cargo test --workspace # everything, including the golden files
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
# Regenerate golden files (needs a crawlee checkout with `pnpm install` done)
CRAWLEE_JS_DIR=../crawlee node --experimental-transform-types --no-warnings conformance/oracle/generate-golden.mtsApache 2.0, like Crawlee.