· Updated · Tutorials · By Simon Chiu

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.

Line illustration of the RenderScreenshot mascot beside an edge cloud feeding a browser

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 viewport explicitly if you compare output with another tool.
  • quality with the default PNG type returns HTTP 400. Set screenshotOptions.type to jpeg or webp before you set quality.
  • 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 browser binding 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. Use page.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