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.
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.
cargo new linkcheck
cd linkcheck
cargo add tokio --features full
cargo add reqwest regex axumStep 1: Finding and resolving links
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.
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"]);
}
}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.00sReturning 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.
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}"),
}
}
}/ 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
JoinSetholds the tasks for the current level;join_nextcollects results as they finish. - A
Semaphorewith 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
HashSetof 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.
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));
}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: trueThe 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.txtand add a delay between requests to the same host. - Check external links too, but only with a
HEADrequest and without following their links. - Retry timeouts once with a longer limit before reporting them.
- Read
--depth,--concurrency, and the start URL withclap, 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
mpscchannel from the tasks to a printer task.
Summary
Url::joinresolves 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, notErrs. - Breadth-first crawling by depth with a
JoinSetper level runs each level's requests concurrently; aHashSetof visited URLs prevents fetching a page twice. - A
Semaphorecaps 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.
