Disclosure: I build RenderScreenshot, and RenderScreenshot renders on Cloudflare Browser Rendering. This is not a comparison of two browser engines. It is a comparison of calling the platform directly versus calling a product built on it.
Cloudflare Browser Rendering (its docs now call it Browser Run) gives you headless Chromium on Cloudflare's network, billed by browser time. It is the cheapest way on our pricing grid to produce a screenshot. We know because our own renders run on it. What you do not get is everything around the browser: authentication for public links, a cache with a CDN URL, presets, per-customer billing, retries and storage. This page lists that layer piece by piece, shows the same capture both ways, and says when you should call Cloudflare directly. It is part of our best screenshot API comparison.
Last verified: October 15, 2026, against Cloudflare's screenshot endpoint docs, limits and pricing as published on October 14, 2026.
The short answer
| Call Cloudflare Browser Run directly if | Use RenderScreenshot if |
|---|---|
| You already deploy Workers and want the lowest cost per screenshot | You do not want to build caching, public links and storage |
| You need full Puppeteer or Playwright control in a Worker (multi-step flows) | The job is URL or HTML in, image or PDF out |
| You render for your own product only, with no per-customer billing | You need signed URLs for <img> and og:image tags |
| Your volume is high and steady, so browser time stays low per shot | You want presets, typed errors and SDKs in six languages |
| You want everything in one Cloudflare account and bill | You want one credit per screenshot regardless of how long a page takes |
How a RenderScreenshot request uses Cloudflare
flowchart LR A[Your app] -->|Bearer key or signed URL| B[RenderScreenshot API<br/>auth, validation, presets,<br/>cache lookup, credits] B -->|cache hit| E[CDN URL<br/>X-Cache-URL] B -->|cache miss| C[Cloudflare Worker] C --> D[Cloudflare Browser Rendering<br/>headless Chromium] C -->|image bytes| F[(Cloudflare R2)] F --> E B -->|image + headers| A
The Rails API does the business logic: it checks the key or signature, validates parameters, expands presets, looks up the cache and counts credits. The Worker only drives the browser. Image bytes go from the Worker to R2 directly, not through the API servers, and the CDN serves them from there. If you call Cloudflare yourself, you write the boxes on the left and the R2 and CDN steps.
What our layer adds
| Piece | Calling Browser Run directly | RenderScreenshot |
|---|---|---|
| Auth for public links | Build a proxy that keeps your API token server-side | Signed URLs: rs_pub_ key ID, expiry up to 30 days, HMAC signature |
| Cache | Build it (KV, R2, Cache API), choose keys and TTLs | Automatic. Key from URL + options, TTL 1 hour to 30 days, zero-credit hits with cache.available |
| Public CDN URL | Put the file in R2 and serve it | X-Cache-URL header on every response |
| Cache invalidation | Build it | DELETE /v1/cache/:key, POST /v1/cache/purge by keys, URL pattern or date |
| Presets | Write sizes yourself | og_card, twitter_card, twitter_card_large, link_preview, full_page, pdf_document, mobile, mobile_landscape, desktop_hd, desktop_4k |
| Blocking | Write request rules and banner scripts | block.ads, block.trackers, block.cookie_banners, block.chat_widgets |
| Per-customer keys and billing | Build accounts, keys and metering | Keys per account, credits, usage endpoint, request logs |
| Errors | HTTP 429 with no specific code for limit type; your retry logic | Typed errors with code and retryable |
| Batch and webhooks | Build a queue | Batch endpoint and webhooks |
| Storage in your bucket | Write the upload | storage options with path templates |
| SDKs | Cloudflare's SDK (account API) | Node, Python, Ruby, PHP, Java, Go |
What running on it taught us
Short versions of what we hit in production; the longer write-up is in our Cloudflare Browser Rendering tutorial:
- The new-browser rate limit bites before the concurrency limit. Bursts of launches return plain HTTP 429 with no code that says which limit you hit. Log the status and the limit type from day one.
- Reused sessions disappear. A session you cached can be gone on the next request. Treat
connect()as fallible and fall back to a fresh launch. networkidle0can wait forever on pages with long polling, websockets or analytics beacons. Use a hard outer timeout and a selector wait.- Cold starts have a long tail. Most launches are quick; a few take several times longer, and they set your p99. Session reuse narrows it.
- Bot protection still applies. Cloudflare's docs say Browser Run requests are always identified as a bot, whatever
userAgentyou send. That is true for our renders too, so neither path gets past sites that block bots.
Cost: platform vs product
Cloudflare bills browser time. On Workers Paid ($5 a month minimum), 10 browser hours are included, then $0.09 per hour. Quick Actions pay for browser hours only; browser sessions driven by Puppeteer or Playwright also pay $2.00 per concurrent browser above 10 (monthly average of daily peaks). A Quick Action that fails with a waitForTimeout error is not charged.
hours = round(shots × seconds_per_shot / 3600) monthly_cost = 5 + max(0, hours − 10) × 0.09
| Monthly volume | Browser Run at 2 s per shot | Browser Run at 10 s per shot | RenderScreenshot |
|---|---|---|---|
| 1,000 | $5.00 | $5.00 | $10 (Starter, 2,500 credits) |
| 10,000 | $5.00 | $6.62 | $40 (Growth, 12,000 credits) |
| 100,000 | $9.14 | $29.12 | No self-serve plan; Scale is $100 for 60,000 |
Seconds per shot is the input that matters: measure it with the X-Browser-Ms-Used header that Cloudflare returns on every Quick Action. The Workers minimum is shared with your other Workers usage, so if you already pay it, the marginal cost is lower still.
Read the gap honestly. The platform is roughly an order of magnitude cheaper per shot. The difference pays for the left column of the table above. If you would build that layer anyway, or do not need it, call Cloudflare.
The same capture, both ways
From a Worker
This Worker serves /og?path=/blog/hello as a 1200×630 image for pages on your own site. With OG_BACKEND=renderscreenshot it calls our API with the og_card preset; otherwise it calls the Browser Run binding. Both paths reject any path that resolves to another host, so the endpoint cannot be used to screenshot arbitrary sites.
// worker.mjs: an /og?path=/blog/hello endpoint on Cloudflare Workers with two interchangeable backends. // Backend "browser-run" uses your own Browser Run binding (wrangler.toml: [browser] binding = "BROWSER"). // Backend "renderscreenshot" calls our API with a secret set by `wrangler secret put RS_API_KEY`. const SITE = 'https://example.com'; // only render pages from your own site export default { async fetch(request, env) { const path = new URL(request.url).searchParams.get('path') ?? '/'; const target = new URL(path, SITE); // "//other.host/x" and absolute URLs resolve off-site, so compare origins after parsing. if (target.origin !== new URL(SITE).origin) return new Response('not our site', { status: 400 }); if (env.OG_BACKEND === 'renderscreenshot') { const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: target.toString(), preset: 'og_card' }), }); if (!res.ok) return new Response(`render failed: ${res.status}`, { status: 502 }); return new Response(res.body, { headers: { 'Content-Type': res.headers.get('Content-Type') ?? 'image/png', 'Cache-Control': 'public, max-age=86400' }, }); } // Browser Run quick action through the binding: billed as browser time on your Cloudflare account. return env.BROWSER.quickAction('screenshot', { url: target.toString(), viewport: { width: 1200, height: 630 }, screenshotOptions: { type: 'png' }, }); }, };
Tested on October 15, 2026 by calling the Worker's fetch handler in Node.js 22.18.0 with a fake BROWSER binding and a local mock of our API: both backends returned 200 image/png, and the paths https://evil.test/, //evil.test/x and /\evil.test/ each returned 400. Not tested inside wrangler dev or on Cloudflare itself. The quickAction call follows the binding example in Cloudflare's screenshot docs.
From any backend: Python
# screenshot.py: the same capture through the Browser Run REST API or RenderScreenshot. # Python 3.9+. pip install httpx import os import sys import httpx USE_RS = os.environ.get('RS_ROLLOUT_PERCENT') == '100' def via_browser_run(url, width=1280, height=800): account = os.environ['CF_ACCOUNT_ID'] res = httpx.post( f'https://api.cloudflare.com/client/v4/accounts/{account}/browser-run/screenshot', headers={'Authorization': f"Bearer {os.environ['CF_API_TOKEN']}"}, json={'url': url, 'viewport': {'width': width, 'height': height}, 'screenshotOptions': {'type': 'png'}}, timeout=60, ) res.raise_for_status() return res.content def via_renderscreenshot(url, width=1280, height=800): res = httpx.post( 'https://api.renderscreenshot.com/v1/screenshot', headers={'Authorization': f"Bearer {os.environ['RS_API_KEY']}"}, json={'url': url, 'viewport': {'width': width, 'height': height}, 'output': {'format': 'png'}}, timeout=60, ) res.raise_for_status() return res.content if __name__ == '__main__': target = sys.argv[1] if len(sys.argv) > 1 else 'https://example.com/' image = (via_renderscreenshot if USE_RS else via_browser_run)(target) with open('out.png', 'wb') as f: f.write(image) print(f'out.png: {len(image)} bytes')
From any backend: Ruby
# screenshot.rb: the same capture through the Browser Run REST API or RenderScreenshot. # Ruby 3.0+, standard library only. require 'json' require 'net/http' USE_RS = ENV['RS_ROLLOUT_PERCENT'] == '100' def post_json(url, token, payload) uri = URI(url) req = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json') req.body = payload.to_json res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 60) { |http| http.request(req) } raise "#{uri.host} #{res.code}: #{res.body[0, 200]}" unless res.is_a?(Net::HTTPSuccess) res.body end def via_browser_run(url, width: 1280, height: 800) post_json("https://api.cloudflare.com/client/v4/accounts/#{ENV.fetch('CF_ACCOUNT_ID')}/browser-run/screenshot", ENV.fetch('CF_API_TOKEN'), { url: url, viewport: { width: width, height: height }, screenshotOptions: { type: 'png' } }) end def via_renderscreenshot(url, width: 1280, height: 800) post_json('https://api.renderscreenshot.com/v1/screenshot', ENV.fetch('RS_API_KEY'), { url: url, viewport: { width: width, height: height }, output: { format: 'png' } }) end if $PROGRAM_NAME == __FILE__ target = ARGV[0] || 'https://example.com/' image = USE_RS ? via_renderscreenshot(target) : via_browser_run(target) File.binwrite('out.png', image) puts "out.png: #{image.bytesize} bytes" end
Tested on October 15, 2026 against a local mock of both APIs (path, Bearer auth and JSON body): Python 3.14 with httpx 0.28.1, Ruby 4.0.7 with the standard library. Cloudflare's REST path is /client/v4/accounts/{account_id}/browser-run/screenshot; some of its examples still show the older /browser-rendering/screenshot path.
Parameter map
Browser Run /screenshot |
RenderScreenshot (POST body) |
Notes |
|---|---|---|
| API token with Browser Run Edit permission | Authorization: Bearer rs_live_... |
|
url, html |
url, html |
|
viewport.width, viewport.height (default 1920×1080) |
viewport.width, viewport.height (default 1280×800) |
Set them explicitly |
viewport.deviceScaleFactor (default 1) |
viewport.scale (default 1, max 3) |
|
screenshotOptions.type, .quality |
output.format, output.quality |
Cloudflare returns 400 for quality with PNG |
screenshotOptions.fullPage, .clip, .captureBeyondViewport |
capture.mode: "full_page", capture.clip, capture.beyond_viewport |
|
screenshotOptions.omitBackground |
output.transparent |
|
selector |
capture.mode: "element", capture.selector |
|
gotoOptions.waitUntil, .timeout |
wait.until, wait.timeout (seconds, max 60) |
|
waitForSelector |
wait.for_selector |
|
addScriptTag, addStyleTag |
page.scripts, page.styles |
|
cookies |
network.cookies |
|
authenticate |
network.auth (type: "basic") |
|
setExtraHTTPHeaders |
network.headers |
|
userAgent |
browser.user_agent |
Does not bypass bot detection on either side |
When to call Cloudflare directly
- You already run Workers and screenshots are a feature of your own product, not something you resell per customer.
- You need a live browser session in a Worker: logins, clicks, multi-page flows. Our API is one capture per request. See Playwright on Cloudflare Browser Rendering.
- Volume is high and the layer is already built. At 100,000 shots a month the platform cost is a small fraction of any screenshot API.
- You want one vendor. Browser, storage, cache and CDN on one Cloudflare bill.
Compare on your own pages
Create a free RenderScreenshot key (150 credits a month, no credit card), deploy the Worker above with both backends, and flip OG_BACKEND on a staging route. Then compare images, response times and the work you would still have to do on the direct path.
Related
- Cloudflare Browser Rendering API tutorial: bindings, REST, session reuse and production notes.
- Playwright on Cloudflare Browser Rendering: the Playwright fork in a Worker.
- Browserless alternative: another browser-as-infrastructure option.
- Caching: cache keys, TTL and
X-Cache-URL.