How to Use the Cloudflare Browser Rendering API: A Practical Tutorial
Learn how to capture screenshots, render PDFs, and scrape content with the Cloudflare Browser Rendering API — Worker bindings, REST endpoints, session reuse, and what we learned running it in production.
The Cloudflare Browser Rendering API gives you a headless Chromium browser that runs inside Cloudflare's edge network. You get the Puppeteer API you already know, but without provisioning EC2 boxes, building Docker images with the right Chromium dependencies, or babysitting a browser pool. For screenshot workloads, PDF generation, JavaScript-rendered scraping, and link previews, it's one of the lowest-effort ways to get a real browser into production.
We run RenderScreenshot on top of this exact infrastructure, so this guide covers both the official API and the things you only learn after a few months of real traffic. We'll walk through the two ways to call the service — the Workers binding and the REST API — and then get into session reuse, concurrency limits, cost, and the gotchas that bit us.
What You Need Before Starting
Browser Rendering works on both the Workers Free and Workers Paid plans. The Free plan gives you 10 minutes of browser usage per day and up to 3 concurrent browsers — enough to follow this entire tutorial and prototype a feature. The Paid plan ($5/month minimum) includes 10 browser-hours per month and up to 120 concurrent browsers, with usage-based pricing beyond that (covered in the pricing section below). Install Wrangler (npm install -g wrangler) and authenticate with wrangler login.
You'll also want an R2 bucket if you plan to store screenshots, since pulling binary data back through a Worker and into your own origin defeats most of the point. R2 has no egress fees, which makes it the obvious destination.
Two Ways to Call the API
There are two ways to drive browser rendering on Cloudflare, and the choice matters for both performance and cost:
- Workers binding — your Worker imports
@cloudflare/puppeteerand callspuppeteer.launch(env.MYBROWSER). You get the full Puppeteer API:page.goto,page.evaluate,page.screenshot,page.pdf, everything. - REST API — you POST JSON to endpoints like
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/screenshotand get a binary response back. No Worker required.
The Workers binding is more flexible but more work. The REST API is simpler and works from any HTTP client, including curl. For most "I just need a screenshot" use cases, start with REST. Reach for the binding when you need multi-step automation (click, scroll, wait, then screenshot) or when you want to share browser sessions across requests.
Tutorial 1: Your First Screenshot via the REST API
The REST API is the fastest path to a working screenshot. There's no Worker to write — just an HTTP call.
Create a Cloudflare API token with the Browser Rendering: Edit permission at https://dash.cloudflare.com/profile/api-tokens. Note your account ID from the dashboard sidebar.
Then make a POST request:
curl -X POST \ https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/browser-rendering/screenshot \ -H "Authorization: Bearer ${API_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "screenshotOptions": { "type": "png", "fullPage": false }, "viewport": { "width": 1280, "height": 720 } }' \ --output screenshot.png
You'll get a PNG back. That's the whole flow.
The endpoint accepts most of the options you'd pass to Puppeteer's page.screenshot() method. The two payload fields you'll touch most often:
viewport— width and height, with optionaldeviceScaleFactorfor retina outputscreenshotOptions—type(pngorjpeg),fullPage,quality(for JPEG),omitBackground
There are sibling endpoints for the other things a browser can do: /pdf for PDF generation, /content for the rendered HTML after JavaScript has run, /markdown for content extraction, /links for link discovery, /scrape for selector-based extraction, and /snapshot for a combined screenshot + DOM dump. They all share the same auth, the same JSON shape, and the same per-request budget.
Tutorial 2: The Workers Binding
When you need more than a one-shot screenshot — when you need to log in, fill a form, wait for a specific element, then capture — the Workers binding gives you the full Puppeteer surface.
Scaffold a new Worker:
npm create cloudflare@latest my-screenshot-worker cd my-screenshot-worker npm install @cloudflare/puppeteer
Add a browser binding to wrangler.toml:
name = "my-screenshot-worker" main = "src/index.ts" compatibility_date = "2026-05-01" browser = { binding = "MYBROWSER" } [[r2_buckets]] binding = "SCREENSHOTS" bucket_name = "my-screenshots"
Now write the Worker:
import puppeteer from '@cloudflare/puppeteer'; export interface Env { MYBROWSER: Fetcher; SCREENSHOTS: R2Bucket; } export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url).searchParams.get('url'); if (!url) { return new Response('Missing ?url parameter', { status: 400 }); } const browser = await puppeteer.launch(env.MYBROWSER); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 720 }); await page.goto(url, { waitUntil: 'networkidle0' }); const screenshot = await page.screenshot({ type: 'png' }); await browser.close(); const key = `screenshots/${Date.now()}.png`; await env.SCREENSHOTS.put(key, screenshot, { httpMetadata: { contentType: 'image/png' }, }); return new Response(JSON.stringify({ key, size: screenshot.length }), { headers: { 'content-type': 'application/json' }, }); }, };
Deploy with wrangler deploy. Hit https://my-screenshot-worker.your-subdomain.workers.dev/?url=https://example.com and you'll get back a JSON response with the R2 key. The PNG is sitting in your bucket.
A few things worth flagging in that code:
puppeteer.launch(env.MYBROWSER) is doing more than it looks. Under the hood it's reserving a browser session from Cloudflare's pool. Sessions are billed per browser-minute, so the browser.close() call at the end is not just hygiene — it's how you stop the meter.
waitUntil: 'networkidle0' waits for the page to go quiet (no network requests for 500ms). For most sites this is the right wait condition. For sites with long-polling, websockets, or analytics beacons that never stop firing, it'll time out — switch to networkidle2 (≤2 pending requests) or domcontentloaded.
Tutorial 3: Session Reuse
The most important optimization on Cloudflare Browser Rendering is session reuse. Launching a fresh browser session takes 1–3 seconds. Connecting to an existing one takes ~100ms. For workloads that do more than a screenshot every few minutes, you want to reuse.
The @cloudflare/puppeteer package supports this through sessions() and connect():
import puppeteer from '@cloudflare/puppeteer'; async function getBrowser(env: Env) { const sessions = await puppeteer.sessions(env.MYBROWSER); const free = sessions.find((s) => !s.connectionId); if (free) { try { return await puppeteer.connect(env.MYBROWSER, free.sessionId); } catch { // Session ended between listing and connecting — fall through to launch. } } return await puppeteer.launch(env.MYBROWSER); } export default { async fetch(request: Request, env: Env) { const browser = await getBrowser(env); const page = await browser.newPage(); try { await page.goto(request.url); const png = await page.screenshot(); return new Response(png, { headers: { 'content-type': 'image/png' } }); } finally { // Disconnect, don't close — leave the session warm for the next request. await page.close(); browser.disconnect(); } }, };
The key distinction is browser.close() versus browser.disconnect(). Close terminates the session. Disconnect just hangs up your end of the WebSocket — the session stays alive in Cloudflare's pool, available for the next request to connect to.
Sessions close after 60 seconds of inactivity by default. You can extend that up to 10 minutes by passing keep_alive (in milliseconds) when you launch — puppeteer.launch(env.MYBROWSER, { keep_alive: 600000 }). Either way, build your reuse logic to expect that the session you tried to connect to might already be gone, and fall back to launching a fresh one. The try/catch around connect() above handles this.
What This Costs (Roughly)
Cloudflare bills Browser Rendering primarily on browser time, measured in browser-hours (tracked in seconds and rounded up at month end). On the Workers Paid plan you get 10 browser-hours per month included, then $0.09 per additional hour. Concurrent Browser Sessions are billed separately: 10 concurrent browsers included (calculated as the monthly average of your daily peak), then $2.00 per additional browser. Requests that fail on a waitForTimeout error are not charged. The numbers shift over time — check developers.cloudflare.com/browser-run/pricing/ for current rates — but the structure has been stable.
In our experience running production traffic, the dominant cost component is how long each session lives, not how many requests you serve. Because billing is browser-time, a request that takes 4 seconds on a fresh launch costs more than 10 sequential requests served from a warm session. Two practical takeaways:
- If you can batch — render multiple URLs from the same Worker invocation, keep the session open — do it.
- If your traffic is bursty and unpredictable, the warm-session optimization is hard to capture. You'll be launching fresh sessions a lot. Budget accordingly.
For low-volume workloads (a few hundred screenshots a day), the Free plan's 10 minutes/day or the Paid plan's included 10 browser-hours are usually enough. For anything significant, total browser time becomes the line item to watch — which is the whole reason session reuse matters so much.
Experience Report: What Bit Us in Production
Here's what we wish we'd known earlier.
Concurrent sessions and launch rate are both capped per account. The Free plan allows 3 concurrent browsers; the Paid plan goes up to 120. Separately, there's a new-instance rate limit — one new browser every 20 seconds on Free, one per second on Paid — and it bites before the concurrency cap if you launch in bursts. When you exceed any of these, the API returns plain HTTP 429 (Too many requests). There's no special JSON error code to match on, so key your handling off the 429 status and back off. We hit the launch-rate limit on the first day of a launch and ate 429s for an hour before realizing it was the instance rate, not the concurrency ceiling — log the status code and the limit type from day one.
The "session quietly disappeared" failure mode is real. Sessions you've been reusing can go away between requests with no notification. If you've cached a session ID in memory and try to connect() to it, you'll get a hang or an error depending on the failure mode. Always treat connect as fallible and fall back to launch.
Some sites detect headless Chromium and serve different content. Cloudflare's browser is real Chrome with the standard headless fingerprint. For sites that care (a lot of ecommerce, some news sites, anything that uses Cloudflare's own bot detection), you may need to set a custom user agent, run page.evaluateOnNewDocument to patch navigator.webdriver, or accept that some targets will refuse you. The Browser Rendering REST endpoint accepts a userAgent field; the binding API gives you full control through page.setUserAgent.
waitUntil: 'networkidle0' can hang on certain sites forever. Analytics scripts that long-poll, websocket-heavy single-page apps, and pages with requestAnimationFrame loops never go idle. Wrap navigation in a hard timeout, or switch to domcontentloaded plus an explicit waitForSelector for the element you care about. We default to a 25-second outer timeout with an early fallback if networkidle0 hasn't fired in 10.
Cold-start variance is wider than the median suggests. A clean session usually launches in a second or two, but there's a real long tail — a small fraction of launches take several times that. If you're serving user-facing screenshots with a tight SLA, that tail shows up as your p99 latency. Reconnecting to a warm session is far faster than a cold launch, so session reuse compresses the tail dramatically — which is the strongest reason to invest in the reuse pattern even if your traffic isn't huge.
The REST API is more reliable than it looks from the outside. Early on we assumed the Workers binding would be lower-latency because there's no extra HTTP hop. In practice, for one-shot screenshots, the REST API is competitive and sometimes faster — Cloudflare's edge handles the request-to-session routing more efficiently than we did from inside a Worker we cold-started ourselves. For multi-step workflows the binding still wins because you avoid the round-trip per action, but don't default to it for simple cases.
When to Use Cloudflare Browser Rendering — and When Not To
It's a great fit when:
- You want a managed browser without operating one
- Your traffic fits the browser-time pricing model (steady or batchable, so sessions stay productive rather than idle)
- You're already on Cloudflare for Workers/R2/KV and want everything in one stack
- You need edge-proximity rendering for low latency to end users
It's a worse fit when:
- You need to render hundreds of pages per minute sustained — at that volume, the metered browser-time cost can exceed running your own browser fleet
- You need a non-Chromium engine (Firefox, WebKit) — Cloudflare is Chromium-only
- You need to install browser extensions, run with custom Chrome flags Cloudflare doesn't expose, or use a specific Chrome version
- Your targets aggressively block Cloudflare egress IPs — your screenshots will be "Access Denied" pages
For most teams building screenshot or PDF features into a SaaS product, it's the right starting point. The math gets harder at scale, but the engineering you avoid in the early months is real.
What's Next
Two natural follow-ups from here. If your team prefers Playwright's API (broader selector engine, built-in auto-waiting), check out our guide on running Playwright on Cloudflare Browser Rendering. And if you want to expose this capability to an AI agent, we wrote up building an MCP server on top of Cloudflare Browser Rendering — it's a surprisingly small amount of code once the rendering layer is in place.
If you'd rather skip building this yourself, that's exactly what RenderScreenshot does: we run the browser layer on Cloudflare, add caching, signed URLs, presets, and a stable API, and charge a flat per-screenshot rate instead of metered browser time. Worth comparing if your math is on the edge.