Automate a cloud browser with headless_chrome
Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.
Scaffolds a starter project locally. Requires the Steel CLI.
headless_chrome is the Rust equivalent of Puppeteer: a high-level, synchronous wrapper over the Chrome DevTools Protocol. Browser::connect takes a websocket URL and returns a connected browser, which is all a Steel session is. There is no event loop to drive and no async runtime in the browser code itself: new_tab, navigate_to, find_elements, and capture_screenshot block until they return, the way the Node original does.
let browser = Browser::connect(cdp_url)?;let tab = browser.new_tab()?;tab.navigate_to("https://quotes.toscrape.com")?;tab.wait_until_navigated()?;let quotes = tab.find_elements(".quote")?;
Scraping is element handles rather than evaluated JavaScript. find_elements returns a Vec<Element>, and each Element queries its own subtree, so quote.find_element(".text")?.get_inner_text()? reads the text inside one card without touching the rest of the page. The loop in scrape pulls the quote, author, and tags from the first five .quote blocks that way.
Sync library, async SDK
The one seam worth understanding is that the two halves of this program disagree about async. The Steel SDK (steel-rs) is async: sessions().create(...).await and sessions().release(...).await need a runtime, so main is #[tokio::main]. headless_chrome is the opposite, a blocking API built on threads. Calling its blocking methods directly inside the async main would stall a runtime worker for the whole scrape.
The bridge is spawn_blocking, which hands the synchronous work to a thread pool meant for exactly this:
let result = tokio::task::spawn_blocking(move || scrape(&websocket_url, &key)).await?;
That is also why scrape returns Box<dyn Error + Send + Sync> rather than the bare Box<dyn Error> you would reach for first: spawn_blocking moves the closure to another thread, so its return type has to be Send. The session is created before the blocking call and released after it, so the browser work sits between two await points and the async SDK never blocks.
One detail in scrape is shared with the chromiumoxide recipe: the connect URL is normalized to carry a path. Steel's websocket URL is wss://host?token, and the websocket layer underneath headless_chrome expects wss://host/?token, so the match on :// and ? inserts the slash before Browser::connect dials it.
Run it
cd examples/headless-chromecp .env.example .env # set STEEL_API_KEYcargo run
Grab a key at app.steel.dev/settings/api-keys. The first build pulls headless_chrome and tokio and takes a minute or two; later runs are quick. The program prints a session viewer URL as it starts. Open it in a second tab to watch the remote browser load the page, and it writes quotes.png to the working directory on the way out.
Your output varies with the site. Structure looks like this:
Creating Steel session...Session live at https://app.steel.dev/sessions/ab12cd34Connected over CDP, opening page...Found 10 quotes on the page:1. The world as we have created it is a process of our thinking.- Albert Einsteintags: change, deep-thoughts, thinking, world2. It is our choices, Harry, that show what we truly are.- J.K. Rowlingtags: abilities, choicesSaved screenshot to quotes.png (98231 bytes)Releasing session...Session released
A run costs a few cents of browser time. Steel bills per session-minute, so the sessions().release() call after the blocking work is not optional: main captures the scrape result, releases the session, and only then propagates any error, so a failed scrape still tears the session down instead of leaving it to idle until the default 5-minute timeout.
Make it yours
- Swap the target. Change the URL in
navigate_toand the selectors in the loop.quotes.toscrape.compaginates with a.next > alink, so you can follow it and scrape every page; the connect and cleanup code stays the same. - Wait on a specific element.
tab.wait_for_element(selector)blocks until a node appears, which is sturdier thanwait_until_navigatedfor pages that fill in content with JavaScript after first paint. - Capture a single element. Beyond the full-page
tab.capture_screenshot, anElementhas its owncapture_screenshotthat crops to that node, useful for grabbing one card or chart instead of the whole viewport. - Harden for anti-bot.
SessionCreateParamscarriesblock_ads,solve_captcha,use_proxy, anddimensions. Set them on the struct passed tosessions().create()for sites that fingerprint or challenge headless traffic.
Related
- chromiumoxide drives the same kind of Steel session the other way: async, tokio-native, with an explicit handler loop you spawn yourself. Comparing the two
mainfiles is the fastest way to decide whether you want the sync or async model in Rust. - scrape-rs skips the browser entirely and reaches the page through Steel's
scrapeandscreenshotendpoints. Start there if you only need content or an image and never touch the DOM. - playwright-py and playwright-go connect over CDP the same way from other languages.
- The headless_chrome docs cover the full
TabandElementAPI.
Related recipes
Run a Steel browser job with Trigger.dev
Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
Run a durable browser workflow with Temporal
Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
Automate a cloud browser with chromedp
Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot.