Guide contents

Lesson 11 of 12

Concurrent Web Crawler

We build linkcheck, which crawls a website and reports broken links: pages that return an error status, time out, or don't exist.

Text
crawled 10 pages
  depth 0  200  /
  depth 1  200  /about
  depth 1  200  /blog/
  depth 1  ---  /shop
  ...
broken links:
  /shop        500 Internal Server Error  (linked from /)
  /blog/slow   timed out                  (linked from /blog/)
  /team        404 Not Found              (linked from /about)

A crawler is a good concurrency exercise: many slow network requests that can run in parallel, but not too many, since hammering a site with hundreds of simultaneous requests is rude and can get you blocked.

Step Topics
1. finding links regex with LazyLock, resolving relative URLs with Url::join, filtering by scheme and host
2. fetching pages one shared reqwest::Client, timeouts, status codes, content types, redirects
3. the crawler breadth-first crawling by depth, JoinSet, Semaphore, HashSet of visited URLs

The examples crawl a small website served by axum inside the same program, so they need no internet access and always give the same results.

Terminal
cargo new linkcheck
cd linkcheck
cargo add tokio --features full
cargo add reqwest regex axum

Links in HTML come in many forms: absolute (https://...), root-relative (/about), relative (../index.html), fragments (#top), and other schemes (mailto:). Url::join resolves each one against the page's own URL, exactly as a browser does. Then the crawler keeps only http(s) links on the same host, and drops the #fragment, because /about#team is the same page as /about.

A regular expression is enough to find href attributes in this project (a real crawler would use an HTML parser such as the scraper crate). It's compiled once, in a LazyLock static.

Rustcargo test
use regex::Regex;
use reqwest::Url;
use std::collections::BTreeSet;
use std::sync::LazyLock;

static HREF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"href="([^"]+)""#).unwrap());

/// Same-site http(s) links in `html`, resolved against the page's URL, without fragments.
pub fn extract_links(page: &Url, html: &str) -> BTreeSet<Url> {
    HREF.captures_iter(html)
        .filter_map(|captures| page.join(&captures[1]).ok())
        .filter(|url| matches!(url.scheme(), "http" | "https"))
        .filter(|url| url.host_str() == page.host_str() && url.port_or_known_default() == page.port_or_known_default())
        .map(|mut url| {
            url.set_fragment(None);
            url
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn paths(page: &str, html: &str) -> Vec<String> {
        extract_links(&Url::parse(page).unwrap(), html).iter().map(|u| u.path().to_string()).collect()
    }

    #[test]
    fn resolves_relative_links() {
        let html = r#"<a href="rust">R</a> <a href="../about">A</a> <a href="/shop">S</a>"#;
        assert_eq!(paths("http://site.test/blog/", html), ["/about", "/blog/rust", "/shop"]);
    }

    #[test]
    fn skips_other_sites_and_schemes() {
        let html = r#"<a href="https://other.test/x">x</a> <a href="mailto:me@site.test">mail</a> <a href="http://site.test:8080/">port</a>"#;
        assert!(paths("http://site.test/", html).is_empty());
    }

    #[test]
    fn drops_fragments_and_duplicates() {
        let html = r##"<a href="/about#team">t</a> <a href="/about">a</a> <a href="#top">top</a>"##;
        assert_eq!(paths("http://site.test/", html), ["/", "/about"]);
    }
}
Output
running 3 tests
test tests::drops_fragments_and_duplicates ... ok
test tests::resolves_relative_links ... ok
test tests::skips_other_sites_and_schemes ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Returning a BTreeSet removes duplicate links and keeps them sorted, which makes the crawler's output deterministic later.

Step 2: Fetching a page

Every request goes through one reqwest::Client. A client holds a connection pool, so reusing it keeps connections alive between requests; creating a client per request would throw that away. A Client is cheap to clone (it's an Arc inside) and can be shared by all tasks.

The client gets a timeout, so one slow page can't stall the crawl. Redirects are followed automatically (up to 10), and response.url() is the final URL, which matters for resolving relative links on the redirected page. Only successful HTML responses are read, so large images or PDFs aren't downloaded.

The test site below has a normal page, a redirect, a missing page, a server error, and a page slower than the timeout.

Rust
use axum::Router;
use axum::response::{Html, Redirect};
use axum::routing::get;
use reqwest::{Client, StatusCode, header};
use std::time::Duration;

async fn slow() -> Html<&'static str> {
    tokio::time::sleep(Duration::from_secs(2)).await;
    Html("<p>finally</p>")
}

async fn start_site() -> String {
    let app = Router::new()
        .route("/", get(|| async { Html(r#"<a href="/about">About</a>"#) }))
        .route("/old", get(|| async { Redirect::permanent("/") }))
        .route("/broken", get(|| async { StatusCode::INTERNAL_SERVER_ERROR }))
        .route("/slow", get(slow))
        .route("/logo.png", get(|| async { ([(header::CONTENT_TYPE, "image/png")], vec![0u8; 1024]) }));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://{addr}")
}

/// Status (or error) and, for HTML pages, the body.
async fn fetch(client: &Client, url: &str) -> Result<(StatusCode, String, Option<String>), String> {
    let response = client.get(url).send().await.map_err(|e| if e.is_timeout() { String::from("timed out") } else { e.to_string() })?;
    let status = response.status();
    let final_path = response.url().path().to_string();
    let is_html = response.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|v| v.starts_with("text/html"));
    let body = if status.is_success() && is_html { Some(response.text().await.map_err(|e| e.to_string())?) } else { None };
    Ok((status, final_path, body))
}

#[tokio::main]
async fn main() {
    let site = start_site().await;
    let client = Client::builder().timeout(Duration::from_millis(500)).user_agent("linkcheck/0.1").build().unwrap();

    for path in ["/", "/old", "/missing", "/broken", "/slow", "/logo.png"] {
        match fetch(&client, &format!("{site}{path}")).await {
            Ok((status, final_path, body)) => {
                let body = body.map_or(String::from("(not read)"), |b| format!("{} bytes of HTML", b.len()));
                println!("{path:<10} {status} -> {final_path}, {body}");
            }
            Err(error) => println!("{path:<10} error: {error}"),
        }
    }
}
Output
/          200 OK -> /, 26 bytes of HTML
/old       200 OK -> /, 26 bytes of HTML
/missing   404 Not Found -> /missing, (not read)
/broken    500 Internal Server Error -> /broken, (not read)
/slow      error: timed out
/logo.png  200 OK -> /logo.png, (not read)

A 404 or 500 is not an Err from reqwest: the request succeeded, and the server answered with an error status. Err means no answer at all: a timeout, a refused connection, or a DNS failure.

Step 3: The complete crawler

The crawl is breadth-first by depth: fetch the start page (depth 0), then every new link found on it (depth 1), and so on up to a maximum depth. Each level's pages are fetched concurrently:

  • A JoinSet holds the tasks for the current level; join_next collects results as they finish.
  • A Semaphore with 4 permits limits how many requests are in flight at once. Each task waits for a permit before sending and releases it when the permit is dropped, so a permit can't leak even if the task returns early.
  • A HashSet of visited URLs makes sure each page is fetched once, even when many pages link to it.

Tasks finish in an unpredictable order, so each level's results are sorted by URL before their links are recorded. That keeps "linked from" and the report the same on every run.

Rust
use axum::Router;
use axum::response::{Html, Redirect};
use axum::routing::get;
use regex::Regex;
use reqwest::{Client, StatusCode, Url, header};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;

// ---------- links.rs (step 1) ----------

static HREF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"href="([^"]+)""#).unwrap());

fn extract_links(page: &Url, html: &str) -> BTreeSet<Url> {
    HREF.captures_iter(html)
        .filter_map(|captures| page.join(&captures[1]).ok())
        .filter(|url| matches!(url.scheme(), "http" | "https"))
        .filter(|url| url.host_str() == page.host_str() && url.port_or_known_default() == page.port_or_known_default())
        .map(|mut url| {
            url.set_fragment(None);
            url
        })
        .collect()
}

// ---------- crawl.rs ----------

enum Outcome {
    Ok(StatusCode),
    Broken(String),
}

struct Page {
    url: Url,
    outcome: Outcome,
    links: BTreeSet<Url>,
}

async fn fetch(client: &Client, url: Url) -> Page {
    let response = match client.get(url.clone()).send().await {
        Ok(response) => response,
        Err(error) => {
            let reason = if error.is_timeout() { String::from("timed out") } else { String::from("connection failed") };
            return Page { url, outcome: Outcome::Broken(reason), links: BTreeSet::new() };
        }
    };
    let status = response.status();
    if !status.is_success() {
        return Page { url, outcome: Outcome::Broken(status.to_string()), links: BTreeSet::new() };
    }
    let final_url = response.url().clone(); // after redirects
    let is_html = response.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|v| v.starts_with("text/html"));
    let links = if is_html {
        let html = response.text().await.unwrap_or_default();
        extract_links(&final_url, &html)
    } else {
        BTreeSet::new()
    };
    Page { url, outcome: Outcome::Ok(status), links }
}

struct Report {
    pages: Vec<(usize, Page)>,          // (depth, page)
    linked_from: HashMap<Url, Url>,     // first page that linked to each URL
}

async fn crawl(client: Client, start: Url, max_depth: usize, max_in_flight: usize) -> Report {
    let permits = Arc::new(Semaphore::new(max_in_flight));
    let mut visited: HashSet<Url> = HashSet::from([start.clone()]);
    let mut linked_from = HashMap::new();
    let mut pages = Vec::new();
    let mut frontier = vec![start];

    for depth in 0..=max_depth {
        let mut tasks = JoinSet::new();
        for url in frontier.drain(..) {
            let client = client.clone();
            let permits = Arc::clone(&permits);
            tasks.spawn(async move {
                let _permit = permits.acquire_owned().await.expect("semaphore closed");
                fetch(&client, url).await
            });
        }

        let mut level: Vec<Page> = Vec::new();
        while let Some(result) = tasks.join_next().await {
            level.push(result.expect("a crawl task panicked"));
        }
        level.sort_by(|a, b| a.url.cmp(&b.url)); // completion order is random; make the report stable

        for page in &level {
            if depth == max_depth {
                break;
            }
            for link in &page.links {
                if visited.insert(link.clone()) {
                    linked_from.insert(link.clone(), page.url.clone());
                    frontier.push(link.clone());
                }
            }
        }
        pages.extend(level.into_iter().map(|page| (depth, page)));
    }
    Report { pages, linked_from }
}

// ---------- a local website to crawl ----------

async fn start_site() -> Url {
    async fn page(links: &[&str]) -> Html<String> {
        Html(links.iter().map(|l| format!(r#"<a href="{l}">{l}</a>"#)).collect::<Vec<_>>().join("\n"))
    }
    let app = Router::new()
        .route("/", get(|| page(&["/about#team", "/blog/", "/shop", "https://elsewhere.test/", "mailto:hi@site.test"])))
        .route("/about", get(|| page(&["/", "/team", "/logo.png"])))
        .route("/blog/", get(|| page(&["rust", "go", "slow", "/old-blog"])))
        .route("/blog/rust", get(|| page(&["/blog/", "async"])))
        .route("/blog/go", get(|| page(&["../about", "rust"])))
        .route("/blog/async", get(|| page(&["/unreachable"]))) // depth 3: beyond the limit
        .route("/blog/slow", get(|| async {
            tokio::time::sleep(Duration::from_secs(2)).await;
            Html("<p>too late</p>")
        }))
        .route("/old-blog", get(|| async { Redirect::permanent("/blog/") }))
        .route("/logo.png", get(|| async { ([(header::CONTENT_TYPE, "image/png")], vec![0u8; 512]) }))
        .route("/shop", get(|| async { StatusCode::INTERNAL_SERVER_ERROR }));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    Url::parse(&format!("http://{addr}/")).unwrap()
}

// ---------- main.rs ----------

#[tokio::main]
async fn main() {
    let start = start_site().await;
    let client = Client::builder().timeout(Duration::from_millis(500)).user_agent("linkcheck/0.1").build().unwrap();

    let began = Instant::now();
    let report = crawl(client, start, 2, 4).await;
    let elapsed = began.elapsed();

    println!("crawled {} pages", report.pages.len());
    for (depth, page) in &report.pages {
        let status = match &page.outcome {
            Outcome::Ok(status) => status.as_u16().to_string(),
            Outcome::Broken(_) => String::from("---"),
        };
        println!("  depth {depth}  {status:>3}  {}", page.url.path());
    }

    println!("broken links:");
    for (_, page) in &report.pages {
        if let Outcome::Broken(reason) = &page.outcome {
            let from = report.linked_from.get(&page.url).map_or("", |u| u.path());
            println!("  {:<12} {:<26} (linked from {from})", page.url.path(), reason);
        }
    }
    // The slow page costs one 500 ms timeout; everything else ran alongside it.
    println!("finished in under 1.5 s: {}", elapsed < Duration::from_millis(1500));
}
Output
crawled 10 pages
  depth 0  200  /
  depth 1  200  /about
  depth 1  200  /blog/
  depth 1  ---  /shop
  depth 2  200  /blog/go
  depth 2  200  /blog/rust
  depth 2  ---  /blog/slow
  depth 2  200  /logo.png
  depth 2  200  /old-blog
  depth 2  ---  /team
broken links:
  /shop        500 Internal Server Error  (linked from /)
  /blog/slow   timed out                  (linked from /blog/)
  /team        404 Not Found              (linked from /about)
finished in under 1.5 s: true

The slow page costs the full 500 ms timeout, but it doesn't hold up anything else: the other requests of its level run alongside it, and the semaphore keeps at most four requests in flight at any moment.

Ideas for extending it

  • Respect robots.txt and add a delay between requests to the same host.
  • Check external links too, but only with a HEAD request and without following their links.
  • Retry timeouts once with a longer limit before reporting them.
  • Read --depth, --concurrency, and the start URL with clap, and exit with status 1 when broken links were found, so the tool can run in CI.
  • Stream the results as they arrive instead of per level, with an mpsc channel from the tasks to a printer task.

Summary

  • Url::join resolves every kind of link the way a browser does; filtering by scheme and host keeps the crawl on one site.
  • Share one reqwest::Client (it's a connection pool) and give it a timeout. Error statuses are responses, not Errs.
  • Breadth-first crawling by depth with a JoinSet per level runs each level's requests concurrently; a HashSet of visited URLs prevents fetching a page twice.
  • A Semaphore caps concurrency: each task holds a permit while its request is in flight.
  • Sorting each level's results before using them makes concurrent code produce deterministic reports.

Next: Key-Value Server, the last project, which builds a networked database with its own protocol.