· Tutorials

How to Use Playwright with Cloudflare Browser Rendering

A practical tutorial on running Playwright inside Cloudflare Workers using the Browser Rendering API — installation, locators, auto-waiting, session reuse, and gotchas from running it in production.

Line illustration of the RenderScreenshot mascot with an edge cloud and a theatre-curtain browser snapshot

Cloudflare's Browser Rendering API has shipped with Puppeteer support since launch, but Playwright is the API most modern automation projects reach for first. Better selectors, built-in auto-waiting, a cleaner async model, and a locator system that survives DOM changes — there are real reasons to prefer it. The good news: Cloudflare publishes @cloudflare/playwright, a Playwright build that talks to the same browser binding you'd use for Puppeteer.

This guide walks through running Playwright inside a Worker, end to end. We'll cover installation, the basic screenshot flow, the parts of the Playwright API that work differently against Cloudflare's browser, session reuse, and the rough edges we hit running it in production. If you've read our Cloudflare Browser Rendering API tutorial, the structure will be familiar — but the API surface and the gotchas are different enough to warrant a dedicated post.

Why Playwright on Cloudflare

Playwright and Puppeteer overlap heavily, but Playwright has three advantages that matter on a managed browser platform:

  • Auto-waiting. Locator actions wait for the element to be visible, stable, and actionable before clicking. You write less explicit waitForSelector boilerplate, which means fewer bugs from flaky waits in production.
  • Better selectors. Playwright's locator engine supports getByRole, getByText, getByLabel, and other accessibility-first selectors. They survive class name churn and refactors much better than CSS selectors.
  • Cleaner failure messages. When a locator times out, Playwright tells you whether the element wasn't found, wasn't visible, or wasn't interactable. Puppeteer just says "timeout."

On Cloudflare specifically, that last one matters more than you'd think. You're paying per browser-minute, and a flaky test that retries five times because a selector intermittently fails is expensive. Playwright's stricter waiting model gives you fewer retries.

What You Need

The setup is the same as the Puppeteer flow:

  • A Workers plan — the Free plan (10 minutes of browser time/day, 3 concurrent browsers) is enough to follow this guide; the Paid plan ($5/month minimum) raises the limits substantially
  • wrangler installed and authenticated
  • A Cloudflare account ID and (optional) an R2 bucket for storing output

Then add the Playwright package:

npm install @cloudflare/playwright

This is a Cloudflare-maintained fork of Playwright that knows how to connect to the browser binding. It's API-compatible with upstream Playwright for the parts that work on Cloudflare — which is most of them, with the exceptions covered below.

Your First Screenshot

Configure the browser binding in wrangler.toml:

name = "playwright-screenshot"
main = "src/index.ts"
compatibility_date = "2026-05-01"

browser = { binding = "MYBROWSER" }

Then write the Worker:

import { launch } from '@cloudflare/playwright';

export interface Env {
  MYBROWSER: Fetcher;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url).searchParams.get('url');
    if (!url) {
      return new Response('Missing ?url parameter', { status: 400 });
    }

    const browser = await launch(env.MYBROWSER);
    const page = await browser.newPage();
    await page.setViewportSize({ width: 1280, height: 720 });
    await page.goto(url);
    const screenshot = await page.screenshot({ type: 'png', fullPage: true });
    await browser.close();

    return new Response(screenshot, {
      headers: { 'content-type': 'image/png' },
    });
  },
};

Deploy with wrangler deploy and hit ?url=https://example.com. You'll get a PNG response.

A few details worth noticing:

launch(env.MYBROWSER) is the Cloudflare equivalent of chromium.launch() in normal Playwright. Browser launch options like headless: false don't apply — the browser is always headless and always Chromium on Cloudflare. Don't pass executablePath or channel; they'll be ignored.

page.goto(url) defaults to waitUntil: 'load' in Playwright (versus Puppeteer's 'load' default and common override to 'networkidle0'). For most screenshot use cases you can leave the default — Playwright's auto-wait covers a lot of cases that Puppeteer needed manual help with. For SPAs that need to settle, switch to waitUntil: 'networkidle'.

Using Locators and Auto-Waiting

The reason to use Playwright over Puppeteer is the locator API. Here's a more realistic example: log in to a page, wait for the dashboard, then screenshot it.

import { launch } from '@cloudflare/playwright';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const browser = await launch(env.MYBROWSER);
    const page = await browser.newPage();

    await page.goto('https://app.example.com/login');

    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('hunter2');
    await page.getByRole('button', { name: 'Sign in' }).click();

    // Wait for the dashboard to load by waiting for a known element
    await page.getByRole('heading', { name: 'Dashboard' }).waitFor();

    const screenshot = await page.screenshot({ fullPage: true });
    await browser.close();

    return new Response(screenshot, {
      headers: { 'content-type': 'image/png' },
    });
  },
};

Notice how little waiting code there is. getByLabel('Email').fill(...) waits for the input to exist, become visible, and be ready to accept input — all automatically. The click() on the submit button waits for the button to be visible, enabled, and stable (not animating) before clicking. The getByRole('heading', { name: 'Dashboard' }).waitFor() blocks until the dashboard heading is in the DOM.

Compare that to the equivalent Puppeteer code, which would need explicit waitForSelector calls before each action, plus careful handling of the navigation that the login submit triggers. Playwright's auto-waiting eliminates an entire category of flake.

Session Reuse with Playwright

Just like with Puppeteer, the dominant Browser Rendering cost is how long sessions stay alive. Session reuse is the single biggest optimization you can make.

@cloudflare/playwright extends the Playwright API with session helpers: acquire() reserves a session, connect() attaches to one by ID, and sessions() / history() / limits() let you inspect what's running. The idiomatic reuse pattern is to look for a free session and connect() to it, falling back to acquire() + connect() for a fresh one:

import { launch, acquire, connect, sessions } from '@cloudflare/playwright';

async function getBrowser(env: Env) {
  // Reuse a session that has no active worker connection
  const list = await sessions(env.MYBROWSER);
  const free = list.find((s) => !s.connectionId);
  if (free) {
    try {
      return await connect(env.MYBROWSER, free.sessionId);
    } catch {
      // Session went away between listing and connecting — fall through.
    }
  }
  // No free session — acquire a fresh one and connect to it
  const { sessionId } = await acquire(env.MYBROWSER);
  return await connect(env.MYBROWSER, sessionId);
}

export default {
  async fetch(request: Request, env: Env) {
    const browser = await getBrowser(env);
    const context = await browser.newContext();
    const page = await context.newPage();

    try {
      await page.goto(request.url);
      const png = await page.screenshot();
      return new Response(png, { headers: { 'content-type': 'image/png' } });
    } finally {
      // Close the context for isolation, but do NOT call browser.close():
      // leaving the browser open keeps the session warm for the next
      // request (until the keep_alive / idle window elapses).
      await context.close();
    }
  },
};

One Playwright-specific note: prefer context.close() over closing individual pages. Playwright contexts are isolation boundaries — separate cookies, storage, cache. If you don't close the context, the next request that reuses the session will inherit whatever cookies the previous user left behind. Either close the context explicitly, or always create a fresh one per request.

By default a warm session closes after 60 seconds of inactivity. Extend that up to 10 minutes by passing keep_alive (milliseconds) when you launch: launch(env.MYBROWSER, { keep_alive: 600000 }). Match the value to how often you actually get traffic — a longer keep-alive means more reuse but also more billed browser time if the session sits idle.

Capturing PDFs

PDF rendering works just like in Puppeteer — call page.pdf():

const browser = await launch(env.MYBROWSER);
const page = await browser.newPage();
await page.goto('https://example.com');

const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
});

await browser.close();

return new Response(pdf, {
  headers: { 'content-type': 'application/pdf' },
});

PDF rendering counts the same as any other page operation against your browser-minute budget. There's no separate PDF endpoint or pricing on Cloudflare — it's all just "browser time."

What Doesn't Work

A handful of upstream Playwright features don't apply or behave differently on Cloudflare:

  • firefox and webkit launchers. Cloudflare's browser is Chromium-only. There's no Firefox or WebKit engine available, so any test that relies on cross-browser rendering won't translate.
  • Persistent contexts. chromium.launchPersistentContext(userDataDir, ...) writes to a local user data directory. On a Worker there's no persistent filesystem — use browser.newContext() and persist state yourself via R2 or KV if you need it across requests.
  • Tracing and video recording. Playwright's tracing.start() and video recording features are tied to the local filesystem. They don't currently work through the Cloudflare browser binding.
  • page.expose* callbacks. Functions exposed from the Node side into the browser (page.exposeFunction) work, but with the latency of a round-trip through the binding. Use them sparingly.
  • Browser launch options. Headless mode, executable path, channel, downloads path, and similar options are no-ops. The browser is what it is.

The locator API, navigation, screenshot, PDF, evaluation, network interception (page.route), and the bulk of the test-runner-adjacent functionality all work as documented.

Experience Report: What We Learned Running This

A few months of production traffic on Playwright + Cloudflare have surfaced a handful of patterns and pitfalls that don't show up in the docs.

The auto-wait timeout is your friend and your enemy. Playwright's default action timeout is 30 seconds. On Cloudflare you're billed for browser time, so a flaky selector that waits the full 30 seconds before failing is 30 seconds of billed browser time you got nothing for. We dropped the default action timeout to 8 seconds globally and override it upward for actions we know are slow. The locator API supports this: page.getByRole('button').click({ timeout: 15000 }).

Don't reuse contexts across users. If you're building a multi-tenant feature where different users trigger Browser Rendering jobs, do not reuse a browser context. Cookies, localStorage, and IndexedDB all live in the context. The browser session can be reused — the context cannot. We learned this when a customer reported seeing another customer's session in a screenshot. Painful afternoon.

page.route is great, but be careful with the matcher. Network interception works well, but matching with a regex over hundreds of requests adds up. Use string prefix matches when you can, and only intercept what you actually need to block or modify. We had a job that intercepted all requests to log them — the per-request callback overhead added up to noticeable extra latency on every page.

Locators don't auto-retry navigation. Playwright will auto-wait for an element to appear, but it won't retry a failed navigation. If page.goto() returns a 5xx or hits a TLS error, you get an exception. Wrap navigation in your own retry loop with backoff if you need resilience.

networkidle is still risky. Same problem as with Puppeteer: pages with continuous network activity (analytics beacons, websockets, polling) never go idle. Playwright's waitUntil: 'load' plus an explicit waitFor on the element you actually care about is more reliable than networkidle. Save networkidle for static-ish pages where you know it'll settle.

Selector debugging from a Worker is genuinely hard. When a locator fails in local Playwright, you can open the Playwright Inspector and step through. On Cloudflare, you can't. Our workflow: develop selectors against the same target page using local Playwright, then deploy. When something does fail in production, capture page.content() (the rendered HTML) alongside the error log so you can reproduce locally. Add this to your error handler from day one.

The first request after a deploy is slow. Each Worker deploy invalidates warm sessions associated with old isolates. Your first few requests after a deploy will pay full cold-start cost. If you deploy frequently and care about p99, consider a synthetic warm-up hit immediately after each deploy.

When to Pick Playwright Over Puppeteer

We use Puppeteer for one-shot screenshot generation where there's no DOM interaction — goto, screenshot, done. Puppeteer's smaller surface area means less to go wrong.

We reach for Playwright when:

  • The flow involves any form filling, clicking, or waiting for dynamic content
  • We need accessibility-aware selectors that don't break when designers refactor CSS
  • We want network interception with the cleaner page.route API
  • The team already uses Playwright for tests and wants one mental model

For pure screenshot workloads against simple pages, Puppeteer is fine and slightly lighter. For anything that resembles a workflow, Playwright is the better default.

What's Next

If you're new to running browsers on Cloudflare, the Cloudflare Browser Rendering API tutorial covers the platform fundamentals — pricing model, session lifecycle, concurrency limits — that apply equally to Puppeteer and Playwright. And if you want to expose your rendering setup to AI agents, we wrote up building an MCP server on top of Cloudflare Browser Rendering, which adds a small layer on top of either driver.

RenderScreenshot wraps the same infrastructure with caching, signed URLs, presets, and a flat per-screenshot price, so you can skip the browser layer entirely if that math works for your team.