DEV Community

I built a pure-Rust browser automation library, no Node.js, no wrappers, just CDP over Tokio

I got tired of every Rust browser automation library either being a thin wrapper around Node.js (slow, heavy) or completely unmaintained and archived. So I built ferrous-browser a pure-Rust, async-first Chrome DevTools Protocol client that ships as a single binary.
The core ideas behind it:

Zero Node.js. It talks directly to Chrome over CDP using Tokio WebSockets. No npm, no subprocess bridges, nothing.
Correct multi-page isolation. CDP session IDs are tracked per page so concurrent pages don't leak events into each other — something a lot of existing libraries quietly get wrong.

Race-condition-free event handling. Event handlers are registered before the commands that trigger them, not after. Sounds obvious, but most implementations don't do this.

A Playwright-inspired API. locator(), evaluate(), WaitUntil — familiar if you've used Playwright or Puppeteer, but idiomatic Rust.
Here's the basic setup in Cargo.toml:

ferrous-browser = "0.1"
tokio = { version = "1", features = ["full"] }
Enter fullscreen mode Exit fullscreen mode

Requires Chrome or Chromium installed locally. That's it.
A minimal example, navigate, read a heading, take a screenshot:

use ferrous_browser::{Browser, WaitUntil};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let browser = Browser::launch_chrome(None).await?;
let page = browser.new_page().await?;
page.goto("https://example.com", WaitUntil::Load).await?;
let heading = page.locator("h1").inner_text().await?;
println!("Heading: {heading}");

let png = page.screenshot().await?;
std::fs::write("screenshot.png", png)?;

Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The locator API covers click, type_text, wait_for, inner_text, and get_attribute. evaluate() is generic — you pass a JS expression and it deserializes into whatever Rust type you specify.
For navigation, there are three wait modes: DomContentLoaded for speed, Load for full resource loading, and NetworkIdle which waits until no network activity for 500ms — useful for SPAs.
On errors, every failure carries structured context. No more opaque strings:

match page.goto("https://bad-url", WaitUntil::Load).await {
Err(BrowserError::NavigationFailed { url, reason }) =>
eprintln!("Navigation to {url} failed: {reason}"),
Err(BrowserError::Timeout { operation, secs }) =>
eprintln!("{operation} timed out after {secs}s"),
Err(e) => eprintln!("{e}"),
Ok(_) => {}
}
Enter fullscreen mode Exit fullscreen mode

You can also chain context onto any Result with .context("loading homepage")?.
Compared to the existing Rust options, chromiumoxide (stale), headless_chrome (archived), ferrous-browser is the only one with an active locator API, NetworkIdle support, and structured errors out of the box.

The benchmarks are honest: raw page creation is slower than Puppeteer right now (Chrome's session routing is heavily optimized on their end), but the gap closes fast when the workload is real scraping or E2E testing rather than micro-benchmarks.

What's on the roadmap: cookie management, PDF export, evaluate_handle for remote object references, HAR/trace capture, and full Windows support.

Would love feedback, especially from anyone who's hit the multi-page session isolation bugs in other libraries, that was the main itch I was scratching.

GitHub: https://github.com/theoxfaber/ferrous-browser
Crates.io: https://crates.io/crates/ferrous-browser

Top comments (0)