The Rust screenshot API from ScreenshotRun turns any URL into a PNG, JPEG, WebP, AVIF, or PDF with a single reqwest call. No headless Chrome binary to bundle, no chromiumoxide or headless_chrome crate to wrestle with. Your compiled binary stays at 10 MB instead of ballooning to 1-2 GB with a Chromium dependency.
Rust developers who need website screenshots usually reach for headless_chrome or chromiumoxide. Both wrap the Chrome DevTools Protocol and work fine on your machine. Deploy to a Docker container or CI pipeline and the problems start: matching the crate version to the installed Chrome binary, configuring font packages on Alpine, managing shared memory for each browser process. A REST API call through reqwest avoids all of that.
First Rust screenshot in 30 seconds
Open the dashboard, create an account, and copy your API key from the settings panel. The free tier gives you 200 captures per month without a credit card.
Add these dependencies to your Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
Then capture your first screenshot:
use std::fs;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let bytes = client
.get("https://api.screenshotrun.com/v1/screenshots/capture")
.header("Authorization", "Bearer YOUR_API_KEY")
.query(&[
("url", "https://github.com"),
("format", "png"),
("response_type", "image"),
])
.send()
.await?
.error_for_status()?
.bytes()
.await?;
fs::write("github.png", &bytes)?;
println!("Saved: github.png ({} KB)", bytes.len() / 1024);
Ok(())
}
No Chromium download, no DevTools Protocol handshake. Our servers launch a headless Chrome instance, render the page fully, including JavaScript and lazy-loaded images, then stream the bytes back over HTTP.
Reusable reqwest screenshot function
A helper function with configurable parameters that you can call from anywhere in your application:
use reqwest::Client;
use std::collections::HashMap;
async fn capture_screenshot(
client: &Client,
url: &str,
format: &str,
width: u32,
full_page: bool,
) -> Result<Vec<u8>, reqwest::Error> {
let mut params = HashMap::new();
params.insert("url", url.to_string());
params.insert("format", format.to_string());
params.insert("width", width.to_string());
params.insert("full_page", full_page.to_string());
params.insert("response_type", "image".to_string());
let bytes = client
.get("https://api.screenshotrun.com/v1/screenshots/capture")
.query(¶ms)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
Ok(bytes.to_vec())
}
Usage:
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = env::var("SCREENSHOTRUN_KEY")?;
let client = Client::builder()
.default_headers({
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Authorization",
format!("Bearer {}", api_key).parse()?,
);
headers
})
.timeout(std::time::Duration::from_secs(60))
.build()?;
let screenshot = capture_screenshot(
&client, "https://github.com", "webp", 1920, true
).await?;
std::fs::write("github-full.webp", &screenshot)?;
println!("Captured full-page WebP screenshot");
Ok(())
}
The key loads from an environment variable at runtime. reqwest::Client pools connections internally, so sharing one instance across your application is the idiomatic approach and saves you from opening extra sockets.
Screenshot API integration with Axum
If you run an Axum web server, you can expose a proxy endpoint that keeps the API key server-side. The frontend requests screenshots through your backend without ever seeing the credentials.
use axum::{extract::Query, http::StatusCode, response::IntoResponse, routing::get, Router};
use reqwest::Client;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize)]
struct ScreenshotParams {
url: String,
format: Option<String>,
width: Option<u32>,
}
async fn screenshot_handler(
Query(params): Query<ScreenshotParams>,
client: axum::extract::Extension<Arc<Client>>,
) -> impl IntoResponse {
let mut query = vec![
("url", params.url),
("format", params.format.unwrap_or("png".into())),
("response_type", "image".into()),
];
if let Some(w) = params.width {
query.push(("width", w.to_string()));
}
match client.get("https://api.screenshotrun.com/v1/screenshots/capture")
.query(&query)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let content_type = resp.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("image/png")
.to_string();
let bytes = resp.bytes().await.unwrap_or_default();
(StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()
}
Ok(resp) => StatusCode::from_u16(resp.status().as_u16())
.unwrap_or(StatusCode::BAD_GATEWAY)
.into_response(),
Err(_) => StatusCode::BAD_GATEWAY.into_response(),
}
}
Register it in your router with a shared Client:
let client = Arc::new(
Client::builder()
.default_headers({
let mut h = reqwest::header::HeaderMap::new();
h.insert("Authorization", format!("Bearer {}", api_key).parse().unwrap());
h
})
.build()
.unwrap(),
);
let app = Router::new()
.route("/api/screenshot", get(screenshot_handler))
.layer(axum::extract::Extension(client));
Your frontend calls /api/screenshot?url=https://example.com&format=png and gets the image directly. The API key stays in the server process.
Available parameters
Pass each option as a query string pair. Here are the ones you will reach for most often:
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | required | Target URL to screenshot |
format | string | png | Image format: png, jpeg, webp, avif, pdf |
width | u32 | 1280 | Browser viewport width (px) |
height | u32 | 720 | Browser viewport height (px) |
full_page | bool | false | Scroll and capture the full document |
device | string | desktop | Emulate desktop, mobile, or tablet |
retina | bool | false | 2x device pixel ratio |
dark_mode | bool | false | Enable prefers-color-scheme: dark |
selector | string | CSS selector to crop to that element | |
delay | u32 | 0 | Pause N ms before the capture fires |
block_cookies | bool | false | Dismiss cookie consent popups |
block_ads | bool | false | Strip ad placements from the page |
block_chats | bool | false | Hide live-chat overlays |
css | string | Custom stylesheet injected before render | |
js | string | Script executed before the screenshot |
See the API documentation for the complete parameter reference and response schemas.
Capture in dark mode, by CSS selector, or with injected styles
Dark mode
Add dark_mode=true to the query. The API sets prefers-color-scheme: dark before the page loads, so any site supporting the media query renders its dark theme:
let bytes = client
.get("https://api.screenshotrun.com/v1/screenshots/capture")
.query(&[
("url", "https://github.com"),
("dark_mode", "true"),
("response_type", "image"),
])
.send().await?
.error_for_status()?
.bytes().await?;
Element capture
Pass a CSS selector to crop the screenshot to a specific element:
.query(&[
("url", "https://github.com/anthropics"),
("selector", ".js-repo-list"),
("response_type", "image"),
])
CSS injection
Hide banners or adjust styles before the capture fires:
.query(&[
("url", "https://example.com"),
("css", ".promo-banner { display: none !important; }"),
("response_type", "image"),
])
Stack all three in one request. A single reqwest call can crop to an element, flip the color scheme to dark, and inject a custom stylesheet without extra round trips.
Error handling and retries in Rust
The ? operator handles the happy path, but production code needs retry logic for rate limits and transient server errors:
use std::time::Duration;
use tokio::time::sleep;
async fn capture_with_retry(
client: &Client,
url: &str,
max_retries: u32,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let params = [
("url", url),
("format", "png"),
("response_type", "image"),
];
for attempt in 0..=max_retries {
let resp = client
.get("https://api.screenshotrun.com/v1/screenshots/capture")
.query(¶ms)
.send()
.await?;
let status = resp.status().as_u16();
if resp.status().is_success() {
return Ok(resp.bytes().await?.to_vec());
}
if status == 429 {
let wait = resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5);
sleep(Duration::from_secs(wait)).await;
continue;
}
if status >= 500 && attempt < max_retries {
sleep(Duration::from_secs((attempt + 1) as u64)).await;
continue;
}
return Err(format!("HTTP {}", status).into());
}
Err("max retries exceeded".into())
}
A 429 means you hit the rate limit. The code reads the Retry-After header and sleeps that many seconds before trying again. For 5xx errors each retry backs off a little longer. Client errors (wrong credentials, malformed parameters) bail out on the spot because retrying won't fix them.
Screenshot API vs. headless_chrome and chromiumoxide
The headless_chrome and chromiumoxide crates give you full browser control: form filling, multi-step navigation, running JS against the page. When the only goal is capturing a URL as an image, they bring significant overhead.
| Self-hosted (headless_chrome / chromiumoxide) | ScreenshotRun API | |
|---|---|---|
| Binary size | 1-2 GB with bundled Chromium | Your binary stays at its normal size |
| Dependencies | Chrome binary + matching crate version | reqwest + tokio (likely already in your project) |
| Memory per capture | 200-500 MB per browser tab | Zero on your server |
| CI/CD builds | Install Chrome in Docker, slow layer caching | No browser layer needed |
| Cookie banners | Write dismiss logic per site, keep it updated | Single flag: block_cookies=true |
| Font rendering | Ship font packages with every build target | Uniform rendering on every capture |
| Alpine Linux | Complex Chromium setup, frequent breakage | Works anywhere reqwest compiles |
| Cost | Server CPU/RAM plus ongoing maintenance | Free tier covers 200/month, pay-as-you-go after |
When your code needs to log in, fill forms, or evaluate JavaScript across multiple steps, headless_chrome or chromiumoxide earn their weight. When the job is simply turning a URL into an image, a reqwest GET replaces the entire browser stack and keeps your binary lean.
Rust teams typically use this for website change monitoring, generating dashboard reports on a schedule, and running visual regression checks inside CI pipelines.
FAQ#
Send a GET request to the ScreenshotRun API using the reqwest crate. Pass the target URL and desired format as query parameters. The API renders the page on its servers and returns the image bytes — no local browser binary required.
reqwest is the standard choice. It supports async with tokio, handles connection pooling automatically, and the query builder maps cleanly to API parameters. No extra crates needed beyond reqwest and tokio.
Yes. Add full_page=true to the query string. The API scrolls the entire page, waits for lazy-loaded content, and returns a single image covering the full document height.
Create a GET handler that reads query parameters from the frontend, forwards them to the ScreenshotRun API with your key in the Authorization header, and streams the response bytes back. The API key never reaches the client.
PNG, JPEG, WebP, AVIF, and PDF. Set the format parameter in the query string. Each format streams back as raw bytes that you write directly with std::fs::write.
For simple URL-to-image capture, yes. reqwest skips browser startup, DevTools Protocol negotiation, and local rendering. The API server handles all of that. Your code sends one HTTP request and receives bytes back, typically in 2-5 seconds.