~ / guides / Rust Web Scraping: The Complete Guide

Rust Web Scraping: The Complete Guide

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • The default Rust scraping stack is two crates: reqwest fetches the page over HTTP and scraper parses the HTML with CSS selectors. A static scrape is about 30 lines.
  • reqwest is async and runs on the tokio runtime, so a single program can fetch hundreds of pages concurrently with no extra plumbing.
  • For JavaScript-rendered pages, drive a headless browser with fantoccini over WebDriver, grab the rendered HTML, then parse it with the same scraper code.
  • Versions as of June 2026: reqwest 0.13.4, scraper 0.27.0, tokio 1.52.3, fantoccini 0.22.1.
  • Parsing is the easy part. Staying unblocked across thousands of requests is the harder job, and that is what a scraper API handles.

I have written scrapers in several languages, and Rust is the one I reach for when the scraper has to run as a service: a long-lived crawler, a high-volume job on a small box, or a single binary I can drop on a server with nothing else to install. This guide is the map for that path. Which crates do what, a full reqwest-plus-scraper walkthrough you can compile, how to parse with CSS selectors, how to fetch pages concurrently with async, how to handle JavaScript-rendered sites, and how to keep a scraper alive once a site pushes back. The code uses the documented API for each crate at its current 2026 version. I point the examples at books.toscrape.com, a sandbox built for scraping practice.

Is Rust a good language for web scraping?

Yes, Rust is a strong choice when throughput, memory use, or single-binary deployment matter more than how fast you write the first draft. The stack is small: reqwest for HTTP, scraper for parsing, tokio for async concurrency. A static scrape is around 30 lines, the compiler rejects code that mishandles a missing element, and the result is one native executable with no interpreter or virtual environment to ship alongside it.

Three things make Rust pull its weight here:

Python has far more scraping tutorials and a faster path to a one-off script, which I cover in the Python guide. Rust wins when the scraper is a service or a high-volume crawler rather than a quick analysis script.

What are the main Rust web scraping crates?

Four crates cover the whole field, and the right set depends on whether the page needs JavaScript and how much request control you want. Static HTML needs only reqwest and scraper. JavaScript-rendered pages need a browser engine, which means fantoccini.

CrateWhat it doesBest forRuns JavaScript
reqwestAsync HTTP client: fetches pages, sets headers, handles cookiesDownloading HTML and hitting JSON APIsNo
scraperParses an HTML string and queries it with CSS selectorsExtracting fields from static pagesNo
tokioThe async runtime reqwest runs onConcurrent fetching, any async programNo
fantocciniDrives a headless browser over the WebDriver protocolJavaScript pages, clicks, form fillsYes

A few notes from using all four:

How do you set up a Rust scraping project?

Create a project with Cargo and add the three core crates, two of which need feature flags. cargo new scaffolds the project, then cargo add writes the dependencies into Cargo.toml. reqwest’s text-fetching path is fine with defaults, and tokio needs its full feature to enable the runtime macro.

cargo new book-scraper
cd book-scraper
cargo add reqwest --features json
cargo add scraper
cargo add tokio --features full

That produces a Cargo.toml dependency block like this:

[dependencies]
reqwest = { version = "0.13", features = ["json"] }
scraper = "0.27"
tokio = { version = "1", features = ["full"] }

Current versions on crates.io as of June 2026:

CrateVersionNotes
reqwest0.13.4Async HTTP client; needs a tokio runtime
scraper0.27.0CSS-selector HTML parsing, built on html5ever
tokio1.52.3Async runtime; enable the full feature
fantoccini0.22.1WebDriver client; needs a running chromedriver/geckodriver

Two notes on the feature flags. The json feature on reqwest pulls in serde_json so you can call .json() on a response, which you want the moment you hit an API instead of an HTML page. The full feature on tokio is the simplest choice for a scraper: it turns on the multi-threaded runtime and the #[tokio::main] macro. You can trim it to specific features later to cut compile time.

How do you scrape a website with reqwest and scraper?

The loop is three steps: fetch the page text with reqwest, parse it into a document with Html::parse_document, then iterate matching elements with a Selector. reqwest does the network half and scraper does the parsing half, so a complete static scrape is one short main.

This program reads the first catalogue page and prints the title, price, and rating for every book on it:

use scraper::{Html, Selector};

#[derive(Debug)]
struct Book {
    title: String,
    price: String,
    rating: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://books.toscrape.com/catalogue/page-1.html";

    // 1. Fetch the page and read the body as a String.
    let html = reqwest::get(url).await?.text().await?;

    // 2. Parse the HTML once into a queryable document.
    let document = Html::parse_document(&html);

    // 3. Build selectors once, outside the loop. parse() returns a
    //    Result, so a typo in the selector fails loudly here.
    let card_sel = Selector::parse("article.product_pod").unwrap();
    let title_sel = Selector::parse("h3 a").unwrap();
    let price_sel = Selector::parse(".price_color").unwrap();
    let rating_sel = Selector::parse(".star-rating").unwrap();

    let mut books = Vec::new();

    for card in document.select(&card_sel) {
        // The full title lives in the anchor's "title" attribute,
        // not the truncated link text.
        let title = card
            .select(&title_sel)
            .next()
            .and_then(|a| a.value().attr("title"))
            .unwrap_or_default()
            .to_string();

        let price = card
            .select(&price_sel)
            .next()
            .map(|p| p.text().collect::<String>())
            .unwrap_or_default();

        // The rating is encoded in a class like "star-rating Three".
        let rating = card
            .select(&rating_sel)
            .next()
            .and_then(|r| r.value().attr("class"))
            .map(|c| c.replace("star-rating", "").trim().to_string())
            .unwrap_or_default();

        books.push(Book { title, price, rating });
    }

    for book in &books {
        println!("{:?}", book);
    }
    println!("{} books scraped", books.len());

    Ok(())
}

Each part of that maps to one job:

This is the approach I would ship for a static page. The selectors are the only part that changes per site, so the pattern transfers to any target whose data is in the initial HTML. To find selectors, open the page in your browser, right-click an element, and choose Inspect.

I have not compiled this program here, because the Rust toolchain is not installed in my writing environment. The code uses the documented reqwest and scraper APIs at their June 2026 versions. Run it against the books sandbox first with cargo run, where there is no risk of a block, before you point it at a live target.

How do you parse HTML with CSS selectors in Rust?

The scraper crate gives you CSS selectors through three methods you will use constantly: select to iterate matches, text to read content, and value().attr to read attributes. CSS is the only selector language scraper supports natively, so unlike some libraries there is no XPath fallback to learn.

MethodWhat it doesExample
Selector::parse("css")Compile a CSS selector (returns a Result)Selector::parse("h3 a")
document.select(&sel)Iterate every element matching the selectorfor el in document.select(&sel)
element.select(&sel)Same, scoped to one element’s subtreecard.select(&price_sel)
element.text()Iterator over descendant text nodesel.text().collect::<String>()
element.value().attr("name")Read an attribute value (returns Option)a.value().attr("href")
element.value().name()The element’s tag nameel.value().name()

A few patterns I use on almost every job:

use scraper::{Html, Selector};

let document = Html::parse_document(&html);

// First match only: use .next() on the iterator.
let h1_sel = Selector::parse("h1").unwrap();
let title = document
    .select(&h1_sel)
    .next()
    .map(|el| el.text().collect::<String>())
    .unwrap_or_default();

// An attribute, for example a link target or an image source.
let link_sel = Selector::parse("article.product_pod h3 a").unwrap();
let href = document
    .select(&link_sel)
    .next()
    .and_then(|el| el.value().attr("href"));

// All matches: collect the iterator into a Vec.
let price_sel = Selector::parse(".price_color").unwrap();
let prices: Vec<String> = document
    .select(&price_sel)
    .map(|el| el.text().collect::<String>())
    .collect();

Two things worth knowing. First, select() returns a lazy iterator, so nothing is allocated until you call .next(), .collect(), or loop over it. That keeps memory flat even on large pages. Second, value().attr() returns an Option<&str> rather than panicking on a missing attribute, so the compiler forces you to decide what an absent href should do. That is the safety the type system buys you. For a deeper reference on building robust selectors, including the XPath equivalents you would use in other languages, see my XPath and CSS selectors guide.

How do you crawl multiple pages concurrently in Rust?

Reuse one Client, then fetch pages in parallel with tokio rather than one after another. Creating a fresh client per request throws away connection pooling, so you build a reqwest::Client once and clone it (clones share the same pool). To fetch concurrently, spawn each request as a tokio task and await them together. This is where Rust earns its place: the async model turns a serial crawl into a parallel one with a few lines.

use scraper::{Html, Selector};
use std::time::Duration;

async fn scrape_page(
    client: reqwest::Client,
    url: String,
) -> Result<Vec<String>, reqwest::Error> {
    let html = client.get(&url).send().await?.text().await?;
    let document = Html::parse_document(&html);
    let sel = Selector::parse("article.product_pod h3 a").unwrap();

    let titles = document
        .select(&sel)
        .filter_map(|el| el.value().attr("title").map(String::from))
        .collect();
    Ok(titles)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build the client once. Set a realistic User-Agent here.
    let client = reqwest::Client::builder()
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
        .timeout(Duration::from_secs(15))
        .build()?;

    let base = "https://books.toscrape.com/catalogue/page-";
    let mut handles = Vec::new();

    // Spawn pages 1..=10 as concurrent tasks.
    for page in 1..=10 {
        let client = client.clone();
        let url = format!("{base}{page}.html");
        handles.push(tokio::spawn(scrape_page(client, url)));
    }

    let mut total = 0;
    for handle in handles {
        let titles = handle.await??; // join the task, then unwrap its Result
        total += titles.len();
    }

    println!("scraped {total} titles across 10 pages", );
    Ok(())
}

Two details make this safe and polite on a real target:

This fires all ten requests at once, which is fine on the sandbox but aggressive on a live site. On a production crawler, cap concurrency with a tokio::sync::Semaphore and add a delay between waves, the same way you would respect a rate limit in any language. The “spawn tasks, join them” shape is the core of every Rust crawler I have built. For the broader crawler design across languages, my Scrapy guide covers the scheduler-and-pipeline model that Rust leaves you to assemble yourself.

How do you scrape JavaScript-heavy sites in Rust?

Drive a headless browser with fantoccini, because the scraper crate cannot run JavaScript. When a page loads its data with JavaScript after the initial response, that data is absent from the HTML reqwest downloads, so your selectors find nothing. The Rust answer is fantoccini, which controls a real browser through the WebDriver protocol. You navigate to the page, let the scripts run, pull the rendered HTML out of the browser, then parse that HTML with the same scraper code you already wrote.

fantoccini talks to a WebDriver server, so you start one first. With Chrome that is chromedriver:

chromedriver --port=4444

Then connect, navigate, and read the rendered source:

use fantoccini::ClientBuilder;
use scraper::{Html, Selector};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Connect to the running WebDriver server.
    let client = ClientBuilder::native()
        .connect("http://localhost:4444")
        .await?;

    // Load the page in a real browser and let its JavaScript run.
    client.goto("https://books.toscrape.com/").await?;

    // Pull the fully rendered HTML out of the live DOM.
    let html = client.source().await?;

    // Parse with the identical scraper API you use on static pages.
    let document = Html::parse_document(&html);
    let sel = Selector::parse("article.product_pod h3 a").unwrap();
    let titles: Vec<String> = document
        .select(&sel)
        .filter_map(|el| el.value().attr("title").map(String::from))
        .collect();

    println!("{} titles", titles.len());

    client.close().await?; // shut the browser session down
    Ok(())
}

What matters here:

When the data loads asynchronously after the first paint, wait for a selector before reading the source with client.wait().for_element(...), fantoccini’s explicit-wait builder. And when you need to click, fill forms, or step through a multi-page flow, client.find(Locator::Css("...")) returns an element you can .click() or .send_keys() on, the same model as Selenium. The quick test for which path you need: fetch a page with reqwest and search the body for a value you can see in the browser. When it is missing, the page is JavaScript-rendered and you move to fantoccini. My Selenium guide covers the browser-automation model in more depth if you want the cross-language picture.

How do you scrape in Rust without getting blocked?

Look like a normal browser, slow down, and spread requests across IP addresses. Parsing is the easy part. Staying unblocked across thousands of requests is a separate engineering problem. A site flags scrapers through three signals: requests that arrive too fast, headers that look automated, and too much traffic from one IP. Each has a countermeasure that works whether you use reqwest or fantoccini.

Block signalWhat triggers itFix
Bad headersA default or missing User-Agent, no other headersSet a realistic User-Agent and browser-like headers on the client
Request rateDozens of requests per second from one clientCap concurrency with a semaphore, add delays, respect 429s
Single IPThousands of requests from one addressRotate IPs or proxies, or use a scraper API
No JavaScriptHTTP-only fetches that never run scriptsRender with fantoccini when the site checks

In reqwest you set headers on the client builder, so every request carries them. A default_headers map plus a user_agent covers the basics:

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE};

let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml"));
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));

let client = reqwest::Client::builder()
    .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
    .default_headers(headers)
    .build()?;

// To route through a proxy, add:
// .proxy(reqwest::Proxy::all("http://user:pass@proxy-host:port")?)

These measures carry a small project a long way. The trouble starts at scale: rotating a large IP pool, solving CAPTCHAs, and handling per-site quirks becomes its own engineering job, and in Rust you are assembling that plumbing by hand because the ecosystem has no Scrapy-style framework that bundles it. That is where a scraper API like ChocoData fits the category, with a universal endpoint plus 453 dedicated endpoints that return the page so your Rust code keeps working unchanged while the blocking is handled upstream. You point reqwest at the API instead of the target, parse the response with scraper the same way, and skip the proxy and CAPTCHA work:

use scraper::{Html, Selector};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("CHOCODATA_KEY")?;
    let client = reqwest::Client::new();

    // Pass the target URL and key as query params; render runs JS their side.
    let html = client
        .get("https://api.chocodata.com/v1")
        .query(&[
            ("api_key", api_key.as_str()),
            ("url", "https://books.toscrape.com/"),
            ("render", "true"),
        ])
        .send()
        .await?
        .text()
        .await?;

    // Identical scraper parsing: only the source of the HTML changed.
    let document = Html::parse_document(&html);
    let sel = Selector::parse("article.product_pod h3 a").unwrap();
    let titles: Vec<String> = document
        .select(&sel)
        .filter_map(|el| el.value().attr("title").map(String::from))
        .collect();

    println!("{} titles via the API", titles.len());
    Ok(())
}

The parsing layer never changes. You swap where the HTML comes from. Check ChocoData’s own docs for the exact parameter names and current pricing before you wire it in. I cover the full build-versus-buy tradeoff, and the legal side, in the web scraping guide and in how to scrape without getting blocked.

Where should you go next?

Start on the books sandbox, get the reqwest-plus-scraper example printing clean structs, then point it at your real target and watch for blocks. When they appear, set a User-Agent and cap your concurrency first, and reach for fantoccini or a scraper API only when you actually need them.

For the broader picture beyond Rust, my web scraping pillar guide covers the concepts that apply in every language: selectors, anti-bot defenses, the legal lines, and when to build versus buy. If you also work in Python, the BeautifulSoup and Scrapy guides cover the closest equivalents to the Rust tools here.

FAQ

Is Rust a good language for web scraping?

Yes, when throughput or a single deployable binary matters. Rust compiles to one native executable with no runtime to install, the reqwest + tokio combination handles thousands of concurrent requests on modest hardware, and the type system catches whole classes of parsing bugs at compile time. The tradeoff is iteration speed: Python's BeautifulSoup gets a one-off scrape running faster. Reach for Rust when the scraper is a long-running service or a high-volume crawler rather than a quick script.

Can the scraper crate handle JavaScript-rendered pages?

No. The scraper crate parses a static HTML string and does not run scripts, and reqwest only fetches the raw HTML the server returns, so anything injected by JavaScript is absent. For those sites drive a headless browser with fantoccini over WebDriver, read the rendered page with client.source(), then parse that HTML with the same scraper selectors.

Why does reqwest need tokio?

reqwest's Client is async: its methods return futures that do nothing until a runtime polls them. tokio is that runtime. In practice you add the #[tokio::main] attribute above main, which lets you write async fn main and use .await inside it. A blocking client exists behind a feature flag for simple synchronous scripts, but the async path is the documented default and the one that scales to concurrent crawling.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.