Cloudflare Browser Rendering API: A Tested Tutorial (2026)
Screenshots with Cloudflare Browser Rendering (now Browser Run): REST Quick Actions, the Workers binding, session reuse, limits and cost. Tested code.
Cloudflare Browser Rendering (renamed Browser Run in April 2026) gives you a headless Chromium browser on Cloudflare's network. You can call it two ways: as a REST API that returns a screenshot from one HTTP request, or from a Worker through a browser binding with Cloudflare's fork of Puppeteer. This tutorial covers both, with code we ran on October 15, 2026, then session reuse, current limits and pricing, a cost formula, and a troubleshooting table.
We run RenderScreenshot's renderer on this service, so the production notes come from our own Worker. Where we have not measured something, this page says so. The full architecture around the browser is in how we built RenderScreenshot.
Tested with
| Item | Version |
|---|---|
| Wrangler | 4.151.0 |
@cloudflare/puppeteer |
1.4.0 |
@cloudflare/workers-types |
latest on October 15, 2026 |
| TypeScript | 5.x, tsc --strict clean |
| Node.js (for Wrangler) | 22.18.0 |
| Run mode | wrangler dev local mode on macOS (local Chromium) |
| Not tested by us | REST Quick Actions (needs an account token), quickAction() on a deployed Worker |
Limits and prices below were read from Cloudflare's docs on October 15, 2026 (the limits page was last updated October 14, 2026).
Two ways to call it
| REST Quick Actions | Workers binding | |
|---|---|---|
| Call | POST https://api.cloudflare.com/client/v4/accounts/<id>/browser-run/screenshot |
puppeteer.launch(env.BROWSER) in a Worker, or env.BROWSER.quickAction('screenshot', …) |
| Auth | API token with Browser Rendering - Edit | The binding; no token |
| Control | One request: URL or HTML in, image out | Full Puppeteer: click, type, wait for selectors, multiple pages |
| Billing | Browser time | Browser time, plus concurrent browsers above the included 10 |
| Rate limit (Workers Paid) | 30 requests per second | 200 concurrent browsers, 3 new browsers per second |
| Best for | One-shot screenshots and PDFs from any backend | Multi-step flows, session reuse, writing straight to R2 |
Start with REST if all you need is "URL in, PNG out". Use the binding when you need to act on the page before capturing it, or when the capture should land in R2 without a round trip through your server.
Tutorial 1: a screenshot from the REST API
Create an API token with the Browser Rendering - Edit permission and note your account ID. Then:
curl -X POST \ "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/browser-run/screenshot" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "viewport": { "width": 1280, "height": 800 }, "screenshotOptions": { "type": "png" }, "gotoOptions": { "waitUntil": "load", "timeout": 30000 } }' \ --output screenshot.png
Three details from Cloudflare's endpoint docs trip people up:
- The default viewport is 1920x1080. Set
viewportexplicitly if you compare output with another tool. qualitywith the default PNG type returns HTTP 400. SetscreenshotOptions.typetojpegorwebpbefore you setquality.- Older examples use
/browser-rendering/screenshot. Cloudflare's current docs show both paths; new code should use/browser-run/.
Other Quick Actions share the same request shape: /pdf, /content (rendered HTML), /markdown, /snapshot (several formats in one call), /scrape, /links, /json and, REST only, /crawl.
We have not run this request ourselves: it needs a Cloudflare API token on a live account. The body uses only fields from Cloudflare's screenshot endpoint reference.
Tutorial 2: the Workers binding with Puppeteer
Scaffold a Worker and install the Puppeteer fork:
npm create cloudflare@latest screenshot-worker cd screenshot-worker npm install -D @cloudflare/[email protected]
wrangler.jsonc, with a browser binding and an R2 bucket:
{ "name": "screenshot-worker", "main": "src/index.ts", "compatibility_date": "2026-10-01", "browser": { "binding": "BROWSER" }, "r2_buckets": [{ "binding": "SCREENSHOTS", "bucket_name": "screenshots" }] }
Compatibility dates from 2026-08-04 enable Node.js compatibility by default, which @cloudflare/puppeteer needs. For an older date, add "compatibility_flags": ["nodejs_compat"].
The Worker: it reuses an idle browser session when one exists, launches one when none does, captures a PNG and writes it to R2.
import puppeteer, { type Browser } from '@cloudflare/puppeteer'; interface Env { BROWSER: Fetcher; SCREENSHOTS: R2Bucket; } // Reuse an idle session when one exists; launch only when none is free. async function getBrowser(env: Env): Promise<{ browser: Browser; reused: boolean }> { const sessions = await puppeteer.sessions(env.BROWSER); const idle = sessions.find((s) => !s.connectionId); if (idle) { try { return { browser: await puppeteer.connect(env.BROWSER, idle.sessionId), reused: true }; } catch { // The session closed between list and connect. Launch a new one. } } // keep_alive (ms) keeps an idle browser for the next request; the maximum is 20 minutes. return { browser: await puppeteer.launch(env.BROWSER, { keep_alive: 120_000 }), reused: false }; } export default { async fetch(request: Request, env: Env): Promise<Response> { const target = new URL(request.url).searchParams.get('url'); if (!target || !/^https?:\/\//.test(target)) { return Response.json({ error: 'pass ?url=https://...' }, { status: 400 }); } const started = Date.now(); const { browser, reused } = await getBrowser(env); const page = await browser.newPage(); try { await page.setViewport({ width: 1280, height: 800 }); const response = await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); if (!response || !response.ok()) { return Response.json({ error: `target returned ${response?.status()}` }, { status: 502 }); } const png = await page.screenshot({ type: 'png' }); const key = `screenshots/${crypto.randomUUID()}.png`; await env.SCREENSHOTS.put(key, png, { httpMetadata: { contentType: 'image/png' } }); return Response.json({ key, bytes: png.length, reused, ms: Date.now() - started }); } finally { await page.close(); browser.disconnect(); // keep the session for the next request; close() would end it } }, };
Run it locally and call it:
npx wrangler dev curl "http://localhost:8787/?url=https://example.com" # {"key":"screenshots/1b53d37c-....png","bytes":95269,"reused":false,"ms":...}
In our local run, three requests each returned a 95,269-byte PNG of example.com and reused: false, and a request with an ftp:// URL got the 400. Local mode does not exercise reuse: puppeteer.sessions() returned an empty list, so every request launched. Session reuse only shows up against the real service, through a deployed Worker or a binding with "remote": true.
Why disconnect() and not close()
browser.close() ends the session. browser.disconnect() drops your connection and leaves the browser running for the next request to connect() to. Combined with keep_alive, that is how you avoid paying the launch cost on every request. The inactivity timeout defaults to 60 seconds and can be raised to 20 minutes.
The trade-off is isolation. A reused browser keeps cookies, storage and HTTP cache from earlier pages. If one request's page must never see another's state (different customers, logged-in captures), open each page in its own context with browser.createBrowserContext() and close the context afterwards, or launch a fresh browser per request.
That second option is what our production renderer does today: one puppeteer.launch(env.BROWSER) per capture and browser.close() in a finally block. We chose isolation over launch time on purpose; the reasons are in the architecture post.
Validate the URL before goto
The Worker above checks only the scheme. If the URL comes from users, also reject private, loopback and link-local addresses, and check the peer address of every response after navigation to catch DNS rebinding and redirects. Our renderer does both; the checks and the reasoning are in the architecture post, step 5. A browser that fetches arbitrary URLs is an SSRF endpoint until you prove otherwise.
Tutorial 3: Quick Actions from a Worker
Since May 2026 the binding also exposes Quick Actions directly, with no API token. It needs a compatibility date of 2026-03-24 or later:
interface Env { BROWSER: BrowserRun; } export default { async fetch(request: Request, env: Env): Promise<Response> { const target = new URL(request.url).searchParams.get('url'); if (!target) return new Response('pass ?url=', { status: 400 }); // One call: Browser Run opens a browser, captures and closes it. return env.BROWSER.quickAction('screenshot', { url: target, viewport: { width: 1280, height: 800 }, screenshotOptions: { type: 'png' }, gotoOptions: { waitUntil: 'load', timeout: 30000 }, }); }, } satisfies ExportedHandler<Env>;
In local mode with Wrangler 4.151.0 this failed with TypeError: The RPC receiver does not implement the method "quickAction". Local simulation does not provide the method. Set "remote": true on the browser binding or deploy the Worker to use it. We have not yet run it remotely, so treat the snippet as untested.
Limits on October 15, 2026
| Limit | Workers Free | Workers Paid |
|---|---|---|
| Browser time | 10 minutes per day | No cap, billed |
| Concurrent browsers (binding sessions) | 3 | 200 (default; can be raised on request) |
| New browser instances | 1 every 20 seconds | 3 per second |
| Quick Actions requests | 1 every 10 seconds | 30 per second |
| Inactivity timeout | 60 seconds, keep_alive up to 20 minutes |
Same |
These have changed three times in 2026 (higher concurrency in April and again in August), so check the limits page before you size a system. When you exceed a limit you get HTTP 429. On the Free plan, running out of daily browser time returns 429 Browser time limit exceeded for today until the next UTC day.
What it costs
Browser Run bills browser time, not screenshots. As listed on Cloudflare's pricing page on October 15, 2026:
| Plan | Included | Beyond included |
|---|---|---|
| Workers Free | 10 minutes per day, 3 concurrent browsers | Not available |
| Workers Paid ($5 per month minimum) | 10 browser hours per month, 10 concurrent browsers (monthly average) | $0.09 per browser hour; binding sessions also $2.00 per concurrent browser above 10 |
A cost formula for screenshots:
browser_hours = screenshots × seconds_per_screenshot / 3600 monthly_cost = 5 + max(0, browser_hours − 10) × 0.09 (+ concurrency charge for binding sessions)
| Screenshots per month | At 3 s each | At 10 s each |
|---|---|---|
| 10,000 | 8.3 h, inside the included 10 h: $5.00 | 27.8 h: $6.60 |
| 100,000 | 83.3 h: $11.60 | 277.8 h: $29.10 |
The seconds-per-screenshot input is the one to measure, because it includes the whole browser lifetime: launch, navigation, waits and capture. A page that waits for networkidle0 costs more than one captured at load. Session reuse lowers it by removing launches; an idle kept-alive browser raises it, because idle time counts. Our screenshot API vs self-hosted cost post puts this formula next to self-hosted servers and hosted APIs, and how long a screenshot takes shows how to measure each phase.
The line items that do not appear in that formula are the ones you build: storage, a cache so repeat requests do not re-render, signed public links, retries, per-customer limits. The Cloudflare Browser Rendering alternative page lists that layer piece by piece. For other hosted browsers that you drive with your own Puppeteer or Playwright code, see the best headless browser APIs.
What we learned running it
From our own renderer and its commit history:
- Named Wrangler environments do not inherit bindings. Our dev environment rendered nothing until we repeated the
browserbinding under[env.dev]. We now keep a comment next to it. - Requests are always identified as automated. Cloudflare documents that Browser Run adds non-configurable headers and signs requests with Web Bot Auth, and that a custom user agent does not bypass bot protection. Sites behind bot products will challenge you whatever you set. Our headless detection study shows how often plain headless Chrome is challenged even without those headers.
- Branch on the output type. Our first PDF support called
page.screenshot()for every format and produced PNG files named.pdf. Usepage.pdf()for PDFs and set the content type explicitly when you store the result. - Put SSRF checks in the Worker, next to the browser. Validating the URL in your API is not enough when DNS can change between the check and the browser's connection.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
429 on launch in bursts |
New-instance rate limit (Free: 1 per 20 s; Paid: 3 per second) | Queue launches, reuse sessions |
429 Browser time limit exceeded for today |
Free plan daily browser time used up | Wait for the next UTC day or move to Workers Paid |
| Still on the 10-minute daily limit after upgrading | Usage still tied to the old plan | Redeploy the Worker, as Cloudflare's FAQ advises |
400 from /screenshot when setting quality |
quality with the default PNG type |
Set screenshotOptions.type to jpeg or webp |
| Screenshot is 1920 px wide when you expected 1280 | REST default viewport is 1920x1080 | Send viewport explicitly |
TypeError: … does not implement the method "quickAction" in wrangler dev |
Local mode does not provide Quick Actions | "remote": true on the binding, or deploy |
reused is always false locally |
puppeteer.sessions() returns an empty list in local mode |
Test reuse against a deployed Worker |
| Dev environment renders fail, production works | Binding missing from [env.<name>] |
Repeat the browser binding in each environment |
Navigation timeout of 30000 ms exceeded |
networkidle0 never reached: analytics, long polling, websockets |
Use load plus page.waitForSelector() for the element you need |
connect() throws or hangs |
The idle session closed between sessions() and connect() |
Catch it and launch, as in Tutorial 2 |
| Captures show "Just a moment" or "Access Denied" | The target's bot protection | Allow-list the renderer on sites you own; see the bot protection guide |
PNG saved with a .pdf name |
page.screenshot() called for a PDF request |
page.pdf() with application/pdf |
When to use it, and when not to
Good fit: you already run Workers and R2; your volume is steady or batchable so browser time is mostly productive; you want a managed Chromium without patching servers; you need multi-step automation close to your data.
Poor fit: you need Firefox or WebKit; you need specific Chrome flags, extensions or an exact Chrome version; your targets block automated traffic and you cannot get allow-listed; or you want a finished screenshot product (caching, signed links, presets, per-customer billing) rather than a browser.
If you prefer Playwright's API, see Playwright on Cloudflare Browser Rendering. For the layer we built on top of this service, and why the signature check still runs in our origin, see edge auth and caching with Workers.
The same capture with one request
If you would rather not build the layer around the browser, RenderScreenshot runs on this platform and adds caching, signed URLs and presets. The capture from Tutorial 2 is one request:
curl -H "Authorization: Bearer $RS_API_KEY" \ "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800" \ -o screenshot.png
The response includes an X-Cache-URL header with a public CDN link to the stored image. A free account is enough to compare the output with your own Worker.
Related in this topic
- How We Built a Screenshot API: Rails, Workers and R2 The architecture of RenderScreenshot: a Rails 8 API, a Cloudflare Worker that drives the browser, R2 storage, and the bugs and fixes that shaped it.
- Screenshot API vs Self-Hosted Puppeteer: Real Cost A build-vs-buy cost model for screenshots: servers, Cloudflare browser time, engineering hours and API plans, with a calculator and a capacity script.
- Which Websites Block Headless Chrome? A 200-Site Study We loaded the top 200 Tranco domains in three headless Chrome profiles. 15% of sites blocked or challenged default Puppeteer. Raw data and script.
- How Long Does a Screenshot Take? Latency, Phase by Phase Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.
- Blocking Cookie Banners Automatically: How It Fails How our block.cookie_banners option clicks or hides consent banners in headless Chrome, the code behind it, and eight fixtures that show where it breaks.
- Cloudflare Workers as an API Gateway for Screenshots What runs at the edge in our screenshot API today, what stays in Rails, and tested Worker code to verify HMAC-signed URLs with Web Crypto before the origin.