Cloudflare Browser Rendering Alternative: Build or Buy

RenderScreenshot runs on Cloudflare Browser Rendering. Here is what our layer adds, what Browser Run costs on its own, and when to call it directly, with code.

By Simon Chiu · Updated

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.
  • networkidle0 can 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 userAgent you 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.

Frequently asked questions

How much does Cloudflare Browser Rendering cost?
As listed on Cloudflare's pricing page on October 14, 2026: on Workers Paid ($5 a month minimum), 10 browser hours a month are included, then $0.09 per hour. Browser sessions driven by Puppeteer or Playwright also pay $2.00 per concurrent browser above 10 (monthly average). Workers Free allows 10 browser minutes a day.
Does Cloudflare Browser Rendering have a screenshot API?
Yes. The /screenshot Quick Action (REST, or a Workers binding) takes a URL or HTML and returns an image. Caching, public links, retries and storage are up to you.
Is RenderScreenshot built on Cloudflare Browser Rendering?
Yes. A Cloudflare Worker drives Browser Rendering, and finished images go to Cloudflare R2. Rails handles authentication, validation, caching and billing in front of it.
Can either one get past bot protection?
No. Cloudflare's docs say requests from Browser Run are always identified as a bot, and a custom user agent does not change that. Because we render on Browser Run, the same applies to RenderScreenshot.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.