Disclosure: I build RenderScreenshot, and our renderer runs on Cloudflare Browser Rendering. That makes me a Cloudflare customer with production experience, and a Browserless outsider. I have tried to keep the two sides equal: product facts come from each vendor's docs and pricing pages (read on 2026-10-15), and the experience notes are labelled as ours.
Short answer: pick Cloudflare Browser Rendering when your capture code can live in a Cloudflare Worker, your output goes to R2 or another Cloudflare product, and you want to pay for browser time by the hour. Pick Browserless when your code runs on your own servers in any language, you want a standard puppeteer.connect() or Playwright endpoint, you need Firefox or WebKit, or you may self-host later. Both offer REST endpoints for one-shot screenshots, so neither requires browser code for the simple case.
Cloudflare's docs now call the product Browser Run, and older URLs redirect there. This page uses the original name because that is what most people search for. Other pairs are in the comparison hub.
Where the browser runs, and where your code runs
This is the decision under all the others.
- Cloudflare: the browser runs in Cloudflare's network. Your automation code runs in a Worker that is bound to the browser (
browser = { binding = "MYBROWSER" }), using@cloudflare/puppeteer, Cloudflare's fork of Puppeteer, or Playwright. The Worker and the browser are close, so each command is a short hop. From outside Cloudflare you use the REST endpoints. - Browserless: the browser runs in a Browserless region (San Francisco, London or Amsterdam on the shared cloud), or in your own Docker deployment. Your code runs anywhere and connects over a WebSocket. Every Puppeteer command crosses the network between your server and that region.
If your app is a Rails, Django or Laravel monolith, Browserless fits without new infrastructure. If you already deploy Workers, Cloudflare removes the network hop and the second vendor.
Side by side
| Browserless (shared cloud) | Cloudflare Browser Rendering | |
|---|---|---|
| Control API | Puppeteer, Playwright, BrowserQL over WebSocket | @cloudflare/puppeteer or Playwright inside a Worker; CDP access |
| One-shot REST | /screenshot, /pdf, /content, /scrape, /function |
/screenshot, /pdf, /content, /markdown, /links, /scrape, /snapshot |
| Auth | Token in the URL query string | Cloudflare API token (REST) or the Worker binding |
| Browsers | Chrome, WebKit, Firefox | Chromium |
| Billing | Units: up to 30 s of browser time per connection | Browser hours; plus concurrent browsers for Puppeteer/Playwright sessions |
| Concurrency (entry paid plan) | 10 (+5) on Prototyping | 10 averaged per month included, up to 200 per account on Workers Paid |
| New-browser rate limit | Not stated on the pricing page | 3 new browsers per second on Workers Paid; 1 every 20 s on Free |
| Idle timeout | Max session time by plan (2 to 60 min) | 60 s of inactivity by default, keep_alive up to 20 min |
| Bot identification | Stealth and CAPTCHA features listed | Cloudflare states requests are always identified as a bot |
| Self-hosting | Docker or licensed self-hosting | No |
Sources: Browserless pricing, Browserless docs, Cloudflare pricing, Cloudflare limits, Cloudflare screenshot endpoint.
The bot identification row matters for screenshots of sites behind bot protection. If your targets block automated traffic, test them on both before you commit.
The same screenshot on each
Cloudflare: a Worker that captures and stores to R2
wrangler.toml:
name = "capture-worker" main = "src/index.ts" compatibility_date = "2026-10-01" browser = { binding = "MYBROWSER" } [[r2_buckets]] binding = "SHOTS" bucket_name = "screenshots"
src/index.ts:
import puppeteer from '@cloudflare/puppeteer'; interface Env { MYBROWSER: Fetcher; SHOTS: R2Bucket; } 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 new Response('Pass ?url=https://...', { status: 400 }); } const browser = await puppeteer.launch(env.MYBROWSER); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto(target, { waitUntil: 'networkidle2', timeout: 30_000 }); const png = await page.screenshot({ type: 'png' }); const key = `shots/${crypto.randomUUID()}.png`; await env.SHOTS.put(key, png, { httpMetadata: { contentType: 'image/png' } }); return Response.json({ key, bytes: png.byteLength }); } finally { await browser.close(); // stops browser-hour billing for this session } }, };
Install with npm i @cloudflare/[email protected] and deploy with wrangler deploy. In production, put an auth check in front of this Worker: as written, anyone who finds the URL can spend your browser hours.
Or the REST endpoint, with no Worker:
curl -X POST \ "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/browser-rendering/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"}}' \ --output cloudflare.png
Cloudflare's docs show this path under both /browser-rendering/ and the newer /browser-run/. Do not send quality with PNG: the endpoint returns a 400.
Browserless: connect from any Node server
import puppeteer from 'puppeteer-core'; import { writeFile } from 'node:fs/promises'; const browser = await puppeteer.connect({ browserWSEndpoint: `wss://production-sfo.browserless.io?token=${process.env.BROWSERLESS_TOKEN}`, }); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 30_000 }); await writeFile('browserless.png', await page.screenshot({ type: 'png' })); } finally { await browser.close(); }
Browserless also lists London and Amsterdam endpoints; use the hostname shown in your account. When navigation dominates the time, pick the region closest to the sites you capture, not to your server. The REST route works the same way as Cloudflare's: POST /screenshot?token=... with {"url": "..."}.
What it costs at three volumes
Assume one capture keeps a browser busy for 3 seconds and uses one connection. In our local tests a viewport capture took 1.5 to 1.9 seconds from launch to close, so 3 seconds leaves room for network round trips. It is an assumption, not a measurement of either service.
| Captures per month | Browser time | Cloudflare (Workers Paid) | Browserless |
|---|---|---|---|
| 1,000 | 0.8 h | Inside the 10 included hours | Free plan: 1k units |
| 10,000 | 8.3 h | Inside the 10 included hours | Prototyping: 20k units, $25/mo billed annually |
| 100,000 | 83 h | 73 extra hours x $0.09 = about $6.57, plus concurrency above 10 | Starter: 180k units, $140/mo billed annually |
Prices as listed on each vendor's pricing page on 2026-10-15. The Cloudflare column excludes the Workers Paid subscription itself and Worker request costs.
The concurrency line is the one to model carefully on Cloudflare. It is the monthly average of your daily peak, and each browser above 10 costs $2.00. A daily burst to 30 browsers, every day, adds 20 x $2.00 = $40 to the month, far more than the browser hours. On Browserless, concurrency is a plan limit, not a meter, so a burst queues or fails instead of adding cost.
What we learned running screenshots on Cloudflare
These notes are from operating RenderScreenshot's renderer. The full write-up, with code, is in our Cloudflare Browser Rendering tutorial.
- Reuse sessions. A fresh launch takes seconds; connecting to a warm session is much faster.
@cloudflare/puppeteerexposessessions()andconnect(), andbrowser.disconnect()leaves the session alive for the next request. This also keeps your concurrency peak down. - The launch-rate limit arrives before the concurrency limit. When traffic bursts, new-browser launches per second are the first thing to fail, with a plain HTTP 429. Log which limit you hit.
- Warm sessions can vanish. A session you cached can close between requests. Treat
connect()as fallible and fall back tolaunch(). - For one-shot captures, the REST endpoint holds up well. We expected the binding to be faster for simple screenshots; in practice the REST path was competitive. The binding wins for multi-step flows.
We do not run Browserless in production, so there is no equivalent list for it here. Its Docker image is the obvious way to try it locally before you buy; our guide to self-hosting Browserless with Docker covers that.
How to benchmark the two fairly
We have not published head-to-head timings for this pair yet. If you run your own:
- Compare like with like: REST against REST, or a Worker using the binding against a Node server using
puppeteer.connect()from a region near the Browserless endpoint. - Measure session setup, navigation and capture separately. Setup is the vendor; navigation is mostly the target site.
- Run at least 5 captures per URL, cold (new session) and warm (reused session), and report median and p95.
- Include a page behind bot protection and a JavaScript-heavy page, not only
example.com. - Record the 429 rate during a burst test. Limits differ more than speed does.
The harness on our Browserbase vs Browserless page times setup, navigation and capture per run and accepts any browserWSEndpoint, so you can point it at Browserless directly. For Cloudflare, put the same timing calls inside a Worker.
Decision table
| Situation | Choose |
|---|---|
| Your stack already runs on Workers and R2 | Cloudflare |
| Your capture code runs on your own servers (any language) | Browserless |
| You need Firefox or WebKit rendering | Browserless |
| Steady, predictable traffic and short captures | Cloudflare (browser hours stay low) |
| Large daily bursts | Browserless, or Cloudflare with session reuse and a queue to cap the peak |
| Data must stay in your own infrastructure | Browserless self-hosted |
| You only need URL-to-image and do not want to write browser code | Either REST endpoint, or a screenshot API |
If you only need the image
Both products still leave caching, signed URLs, cookie-banner handling and retries to you. RenderScreenshot does those per request, on top of the same Cloudflare browser infrastructure we described above:
curl -o example.png \ -H "Authorization: Bearer $RS_API_KEY" \ "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800&block_cookies=true"
Repeat requests are served from cache, and the response includes an X-Cache-URL CDN link you can store (see response headers). Get a free API key to compare it with your Worker or Browserless script on the same URLs.
Last verified: 2026-10-15 (docs, limits and pricing). Benchmark pending.