Screenshot API and HTML to PDF in Rust with reqwest
If you've tried running headless Chrome from a Rust binary, you know the friction: chromiumoxide pulls in a large async runtime dependency surface, headless_chrome is fragile against Chrome version bumps, and your final binary now ships with — or implicitly requires — a 300 MB Chrome installation. For many backend services, that's the wrong tradeoff.
A REST screenshot API gives you the same rendered output without any of that. Your Rust binary stays lean. You call an endpoint, get back bytes. This guide covers doing exactly that with PageBolt using reqwest, tokio, and serde_json.
Why Not chromiumoxide or headless_chrome?
Both crates work. The maintenance cost is the problem:
- Chrome binary coupling — Your service breaks whenever the system Chrome version diverges from what the crate expects.
- Docker image bloat — A minimal Rust app image is ~10 MB. Add headless Chrome and you're at 1–2 GB.
- Memory management — You own the browser process lifecycle. Leaked browser instances in long-running services cause gradual OOM.
-
Async complexity —
chromiumoxiderequires a dedicated async task to drive the CDP event loop, adding architectural overhead even for simple captures.
For occasional screenshot or PDF generation — monitoring jobs, invoice endpoints, CI snapshot runs — the REST approach is simpler and cheaper.
Setup
Add these to Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
The Simplest Screenshot
use std::fs;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let api_key = std::env::var("PAGEBOLT_API_KEY")?;
let bytes = client
.post("https://pagebolt.dev/api/v1/screenshot")
.header("x-api-key", &api_key)
.json(&serde_json::json!({
"url": "https://example.com",
"fullPage": true,
"blockBanners": true
}))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
fs::write("screenshot.png", &bytes)?;
println!("Saved {} bytes", bytes.len());
Ok(())
}
No browser. No subprocess. No system dependencies beyond the Rust toolchain.
Use Case 1: Website Monitoring Service
Capture a snapshot on a schedule and persist it to disk with a timestamp. Run this from a tokio::time::interval loop or a cron-triggered binary.
use std::fs;
use chrono::Utc;
async fn capture_snapshot(
client: &reqwest::Client,
api_key: &str,
url: &str,
output_dir: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let bytes = client
.post("https://pagebolt.dev/api/v1/screenshot")
.header("x-api-key", api_key)
.json(&serde_json::json!({
"url": url,
"fullPage": true,
"blockBanners": true,
"blockAds": true
}))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
let filename = format!(
"{}/{}.png",
output_dir,
Utc::now().format("%Y-%m-%dT%H-%M-%SZ")
);
fs::write(&filename, &bytes)?;
println!("Snapshot saved: {filename}");
Ok(())
}
Diff consecutive snapshots with image + imageproc to detect unexpected layout changes or error pages before your users do.
Use Case 2: PDF Invoice Generation in an Axum Service
Render an HTML invoice template and return it as a PDF — directly from an Axum handler.
use axum::{extract::State, response::Response, http::header};
use std::sync::Arc;
struct AppState {
http: reqwest::Client,
pagebolt_key: String,
}
async fn generate_invoice(
State(state): State<Arc<AppState>>,
// In a real handler, extract invoice data from request body
) -> Result<Response<axum::body::Body>, StatusCode> {
let invoice_html = format!(r#"
<!DOCTYPE html>
<html>
<head><style>
body {{ font-family: sans-serif; padding: 40px; }}
h1 {{ color: #1a1a2e; }}
.total {{ font-size: 1.5rem; font-weight: bold; margin-top: 2rem; }}
</style></head>
<body>
<h1>Invoice #1042</h1>
<p>Date: 2026-03-25</p>
<table>
<tr><td>API access — Growth plan</td><td>$79.00</td></tr>
</table>
<div class="total">Total: $79.00</div>
</body>
</html>
"#);
let bytes = state
.http
.post("https://pagebolt.dev/api/v1/pdf")
.header("x-api-key", &state.pagebolt_key)
.json(&serde_json::json!({
"html": invoice_html,
"pdfOptions": { "format": "A4" }
}))
.send()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?
.error_for_status()
.map_err(|_| StatusCode::BAD_GATEWAY)?
.bytes()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Response::builder()
.header(header::CONTENT_TYPE, "application/pdf")
.header(header::CONTENT_DISPOSITION, "attachment; filename=\"invoice-1042.pdf\"")
.body(axum::body::Body::from(bytes))
.unwrap())
}
The handler renders any HTML — Tera templates, Askama, raw strings — and returns a PDF stream directly to the client. No wkhtmltopdf, no LaTeX, no browser process.
Use Case 3: Visual Regression Testing
Capture before/after screenshots in CI and store them as build artifacts. Then diff with a pixel-level library.
async fn capture_for_test(
client: &reqwest::Client,
api_key: &str,
url: &str,
label: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let bytes = client
.post("https://pagebolt.dev/api/v1/screenshot")
.header("x-api-key", api_key)
.json(&serde_json::json!({
"url": url,
"fullPage": true,
"blockBanners": true,
"blockTrackers": true
}))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
fs::write(format!("snapshots/{label}.png"), &bytes)?;
println!("Captured {label}");
Ok(())
}
// In your test runner:
// capture_for_test(&client, &key, "https://staging.yourapp.com", "post-deploy").await?;
// Then diff "baseline.png" vs "post-deploy.png" with the `image` crate
This works cleanly in a cargo test context with #[tokio::test] — no separate browser infrastructure needed in CI.
Getting a Base64 Response Instead of Binary
For cases where you want to process the image in memory without writing to disk first, use response_type: "json":
let resp: serde_json::Value = client
.post("https://pagebolt.dev/api/v1/screenshot")
.header("x-api-key", &api_key)
.json(&serde_json::json!({
"url": "https://example.com",
"response_type": "json"
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let b64 = resp["image"].as_str().unwrap();
// decode with base64::engine::general_purpose::STANDARD.decode(b64)
Free Tier
PageBolt's free tier includes 100 requests per month — no credit card required. It's enough to prototype all three use cases above and validate the integration before committing to a paid plan.
Get started: pagebolt.dev
Top comments (0)