references/limits.md
# Browser Rendering Limits, Quotas, and Pricing
Complete guide to Cloudflare Browser Rendering limits, quotas, pricing, and troubleshooting.
## Quick Reference
| Plan | Daily Limit | Concurrent Browsers | New Browsers/Min | REST API Rate | Browser Timeout |
|------|-------------|---------------------|------------------|---------------|-----------------|
| **Free** | 10 min/day | 3 | 3/min | 6/min | 60 seconds |
| **Paid** | 10 hrs/month included | 30 | 30/min | 180/min | 60 seconds (up to 10 min with keep_alive) |
---
## Workers Free Plan Limits
### Usage Limits
- **Daily browser time**: **10 minutes per day**
- Resets at 00:00 UTC
- No overage pricing available - upgrade to Paid plan for more usage
### Concurrency Limits (Workers Bindings only)
- **Concurrent browsers**: **3 per account**
- **New browser instances**: **3 per minute**
- Does not apply to REST API
### REST API Rate Limits
- **Requests**: **6 per minute**
- Enforced with a fixed per-second fill rate: **1 request per 10 seconds**
- Cannot burst all 6 requests at once - must spread evenly over the minute
### Browser Timeout
- **Default timeout**: **60 seconds** of inactivity
- Browser automatically closes after 60 seconds without DevTools commands
- Cannot be extended on Free plan
- `browser.close()` immediately releases the browser instance
### Quota Exhaustion Behavior
When you exceed the 10-minute daily limit:
- New rendering requests return **429 error**: `"Browser time limit exceeded for today"`
- Wait until next UTC day for quota to reset
- Or upgrade to Paid plan for immediate access
---
## Workers Paid Plan Limits
### Usage Limits
- **Included browser time**: **10 hours per month** at no additional charge
- **Overage pricing**: **$0.09 per hour** beyond included 10 hours
- **Included concurrency**: **10 concurrent browsers** (monthly average) at no additional charge
- **Concurrency overage**: **$2.00 per additional browser** beyond included concurrency
### Concurrency Limits (Workers Bindings only)
- **Concurrent browsers**: **30 per account**
- **New browser instances**: **30 per minute**
- Higher limits available on request with clear use case demonstration
### REST API Rate Limits
- **Requests**: **180 per minute**
- Enforced with a fixed per-second fill rate: **3 requests per second**
- Cannot burst all 180 requests at once - must spread evenly over the minute
- Higher limits available on request
### Browser Timeout
- **Default timeout**: **60 seconds** of inactivity
- **Extended timeout**: Up to **10 minutes** using `keep_alive` option
- `browser.close()` immediately releases the browser instance
### Session Lifetime
- **No fixed maximum** as long as session remains active
- Sessions close automatically when:
- Inactivity timeout reached (60 seconds or custom `keep_alive` duration)
- Browser Rendering deploys a new release
- Explicitly closed with `browser.close()`
---
## Pricing Details
### Browser Hours Calculation
Pricing is based on total browser time used:
1. **Daily tracking**: Browser usage is tracked per day
2. **Monthly billing**: Daily totals are summed for the billing cycle
3. **Rounding**: Rounded to nearest hour
4. **Overage charge**: $0.09 per hour beyond included 10 hours/month
**Example calculation:**
```
Day 1: 30 minutes
Day 2: 45 minutes
Day 3: 20 minutes
...
Monthly total: 15 hours
Included: 10 hours (free)
Overage: 5 hours × $0.09 = $0.45
```
### Concurrent Browser Calculation
Pricing is based on monthly average of daily peak concurrent browsers:
1. **Daily peak**: Track the maximum concurrent browsers each day
2. **Monthly average**: Average all daily peaks across the billing cycle
3. **Included**: 10 concurrent browsers at no charge
4. **Overage charge**: $2.00 per browser beyond included average
**Example calculation:**
```
Day 1 peak: 8 concurrent
Day 2 peak: 15 concurrent
Day 3 peak: 12 concurrent
...
30-day average: 11 concurrent browsers
Included: 10 browsers (free)
Overage: 1 browser × $2.00 = $2.00
```
**Note**: Short spikes are averaged in, so occasional bursts won't dramatically increase costs.
### REST API vs Workers Bindings Pricing
| Method | Browser Hours | Concurrency Pricing |
|--------|---------------|---------------------|
| **REST API** | ✓ Charged | ✗ Not applicable |
| **Workers Bindings** | ✓ Charged | ✓ Charged |
REST API usage only counts browser hours. Concurrency pricing only applies to Workers Bindings.
---
## Monitoring Usage
### Dashboard
View aggregate metrics in the Cloudflare dashboard:
- Go to **Browser Rendering** page
- See total REST API requests
- See total browser hours used
- View session history and close reasons
### REST API Response Headers
Every REST API response includes:
- **`X-Browser-Ms-Used`**: Browser time used for that request (in milliseconds)
**Example:**
```
X-Browser-Ms-Used: 1234
```
This request consumed 1.234 seconds of browser time.
### Workers Bindings Session APIs
Check usage programmatically:
```typescript
// View active sessions
const sessions = await playwright.sessions();
console.log(`Active sessions: ${sessions.length}`);
// View recent history
const history = await playwright.history();
for (const session of history) {
console.log(`Session ${session.sessionId}:`);
console.log(` Duration: ${session.endTime - session.startTime}ms`);
console.log(` Close reason: ${session.closeReasonText}`);
}
// Check active limits
const limits = await playwright.limits();
console.log(`Concurrent sessions: ${limits.activeSessions.length}/${limits.maxConcurrentSessions}`);
console.log(`Can launch: ${limits.allowedBrowserAcquisitions}`);
console.log(`Wait time: ${limits.timeUntilNextAllowedBrowserAcquisition}ms`);
```
---
## Rate Limiting
### How Rate Limits Work
Rate limits are enforced with a **fixed per-second fill rate**, not burst allowance.
**Free plan example (6 requests/minute):**
- Translates to: **1 request per 10 seconds**
- ❌ Cannot send 6 requests at once
- ✓ Must spread requests evenly: 1 every 10 seconds
**Paid plan example (180 requests/minute):**
- Translates to: **3 requests per second**
- ❌ Cannot send 180 requests at once
- ✓ Must spread requests evenly: 3 per second
### Handling 429 Errors
When rate limited, the API responds with:
- HTTP status: **429 Too Many Requests**
- Header: **`Retry-After: {seconds}`** - how long to wait before retrying
**REST API example:**
```typescript
const response = await fetch(
"https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/screenshot",
{
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com" }),
}
);
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
console.log(`Rate limited. Waiting ${retryAfter} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
// Retry the request
}
```
**Workers Bindings example:**
```typescript
import puppeteer from "@cloudflare/puppeteer";
try {
const browser = await puppeteer.launch(env.BROWSER);
// ... use browser
} catch (error) {
if (error.status === 429) {
const retryAfter = error.headers.get("Retry-After");
console.log(`Browser instance limit reached. Waiting ${retryAfter} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
// Retry launching browser
}
}
```
---
## Browser Close Reasons
When viewing session history, you'll see different close reasons:
| Close Reason | Description | Recommended Action |
|--------------|-------------|-------------------|
| **NormalClosure** | Explicitly closed with `browser.close()` | ✓ Good - proper cleanup |
| **BrowserIdle** | Closed due to inactivity timeout | ⚠ Check if `browser.close()` is missing |
| **SessionRollout** | Closed due to Browser Rendering deployment | ℹ Normal during updates |
### Why BrowserIdle is a Warning
If you see many `BrowserIdle` closures, it means:
1. `browser.close()` was not called explicitly
2. Browser stayed open until timeout (60 seconds or `keep_alive` duration)
3. You're consuming more browser time than necessary
**Fix:**
```typescript
// ❌ Bad: browser stays open until timeout
const browser = await puppeteer.launch(env.BROWSER);
const page = await browser.newPage();
await page.goto(url);
return Response.json({ data: await page.content() });
// Missing browser.close()!
// ✓ Good: explicit cleanup
const browser = await puppeteer.launch(env.BROWSER);
try {
const page = await browser.newPage();
await page.goto(url);
return Response.json({ data: await page.content() });
} finally {
await browser.close(); // Always called
}
```
---
## Troubleshooting
### Error: 429 Browser time limit exceeded for today
**Cause:** You've hit the 10-minute daily limit on Workers Free plan.
**Solutions:**
1. **Upgrade to Paid plan**: Go to **Workers plans** in the dashboard
2. **Wait until next UTC day**: Quota resets at 00:00 UTC
3. **Optimize browser usage**: Ensure `browser.close()` is called promptly
**If you recently upgraded:**
- Redeploy your Worker to associate usage with the new plan
- Run: `wrangler deploy`
### Error: 429 Too many requests
**Cause:** Exceeding rate limits (requests per minute).
**Solutions:**
1. **Spread requests evenly**: Don't burst all requests at once
2. **Implement exponential backoff**: Read `Retry-After` header and wait
3. **Request higher limits**: Contact Cloudflare with clear use case
**Example rate limiter:**
```typescript
class RateLimiter {
private queue: Array<() => void> = [];
private requestsPerSecond: number;
private lastRequestTime = 0;
constructor(requestsPerMinute: number) {
this.requestsPerSecond = requestsPerMinute / 60;
}
async throttle() {
const now = Date.now();
const minInterval = 1000 / this.requestsPerSecond;
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < minInterval) {
await new Promise((resolve) =>
setTimeout(resolve, minInterval - timeSinceLastRequest)
);
}
this.lastRequestTime = Date.now();
}
}
// Usage
const limiter = new RateLimiter(6); // 6 requests/min for Free plan
for (const url of urls) {
await limiter.throttle();
const response = await fetch(/* browser rendering API */);
}
```
### Higher Browser Usage Than Expected
**Cause:** Browser sessions not closed properly, staying open until timeout.
**Diagnostic steps:**
1. Check session history:
```typescript
const history = await playwright.history();
const idleClosures = history.filter(
(s) => s.closeReasonText === "BrowserIdle"
);
console.log(`${idleClosures.length} sessions closed due to idle timeout`);
```
2. Review session durations:
```typescript
for (const session of history) {
const duration = session.endTime - session.startTime;
console.log(`Session: ${duration}ms, reason: ${session.closeReasonText}`);
}
```
**Solutions:**
1. **Always use try/finally**:
```typescript
const browser = await puppeteer.launch(env.BROWSER);
try {
// ... operations
} finally {
await browser.close(); // Always called, even on error
}
```
2. **Don't use `keep_alive` unnecessarily**: Default 60s timeout is often sufficient
3. **Monitor close reasons**: Check dashboard for `BrowserIdle` vs `NormalClosure` ratio
### Hitting Concurrent Browser Limits
**Cause:** Too many browsers open simultaneously.
**Diagnostic:**
```typescript
const limits = await playwright.limits();
console.log(`Active: ${limits.activeSessions.length}/${limits.maxConcurrentSessions}`);
console.log(`Can launch: ${limits.allowedBrowserAcquisitions}`);
```
**Solutions:**
1. **Optimize with tabs**: Use multiple tabs in one browser instead of multiple browsers
```typescript
const browser = await puppeteer.launch(env.BROWSER);
try {
const page1 = await browser.newPage();
const page2 = await browser.newPage();
await Promise.all([
page1.goto("https://example1.com"),
page2.goto("https://example2.com"),
]);
} finally {
await browser.close();
}
```
2. **Reuse sessions**: Keep browser open across requests
```typescript
import { acquire, connect } from "@cloudflare/playwright";
const { sessionId } = await acquire(env.BROWSER);
// Reuse same session multiple times
for (const url of urls) {
const browser = await connect(env.BROWSER, sessionId);
try {
const page = await browser.newPage();
await page.goto(url);
// ... work
} finally {
await browser.close(); // Disconnects but keeps session alive
}
}
```
3. **Use incognito contexts**: Isolate work without multiple browsers
```typescript
const browser = await puppeteer.launch(env.BROWSER);
try {
const context1 = await browser.createIncognitoBrowserContext();
const context2 = await browser.createIncognitoBrowserContext();
// Each context has isolated cookies/cache
} finally {
await browser.close();
}
```
4. **Request higher limits**: Contact Cloudflare with clear use case
### Can't Extend Browser Timeout Beyond 60 Seconds
**Cause:** Not using `keep_alive` option (Paid plan only).
**Solution:**
```typescript
// Puppeteer
const browser = await puppeteer.launch(env.BROWSER, {
keep_alive: 600000, // 10 minutes (max)
});
// Playwright
const browser = await launch(env.BROWSER, {
keep_alive: 600000, // 10 minutes (max)
});
```
**Note:** `keep_alive` is only available on Paid plan.
### Session Closes Unexpectedly
**Possible causes:**
1. **Inactivity timeout**: No commands sent for 60 seconds (or `keep_alive` duration)
2. **Release deployment**: Browser Rendering rolled out a new release
3. **Explicit close**: `browser.close()` called elsewhere
**Diagnostic:**
```typescript
const history = await playwright.history();
const lastSession = history[0];
console.log(`Close reason: ${lastSession.closeReasonText}`);
console.log(`Duration: ${lastSession.endTime - lastSession.startTime}ms`);
```
**Solutions:**
- If `BrowserIdle`: Send commands more frequently or increase `keep_alive`
- If `SessionRollout`: Normal during updates, implement retry logic
- If `NormalClosure`: Check your code for unexpected `browser.close()` calls
---
## Requesting Higher Limits
Cloudflare grants requests for higher limits on a case-by-case basis.
**How to request:**
1. Go to Cloudflare dashboard
2. Navigate to **Support** → **Request higher limits**
3. Clearly demonstrate your use case and need
**What to include:**
- Current usage patterns and where you're hitting limits
- Expected growth or scaling needs
- Description of your application and its value
- Why the current limits are insufficient
**Limits you can request increases for:**
- Concurrent browsers (currently 30 on Paid)
- New browser instances per minute (currently 30 on Paid)
- REST API requests per minute (currently 180 on Paid)
---
## Cost Optimization Tips
1. **Always close browsers explicitly**: Prevent idle timeouts from consuming time
2. **Use REST API for simple tasks**: No concurrency pricing, just browser hours
3. **Cache aggressively**: Store screenshots/PDFs in R2 or KV
4. **Reuse sessions**: Eliminate cold start time and reduce concurrency
5. **Use multiple tabs**: More efficient than multiple browsers
6. **Set appropriate timeouts**: Don't use `keep_alive` if not needed
7. **Monitor close reasons**: Fix `BrowserIdle` closures
8. **Batch operations**: Process multiple items per browser session
**Example: Cost-efficient screenshot service with caching**
```typescript
interface Env {
BROWSER: Fetcher;
SCREENSHOTS: R2Bucket;
CACHE: KVNamespace;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
// Check KV for metadata
const cached = await env.CACHE.get(`screenshot:${url}`);
if (cached) {
// Return from R2
const screenshot = await env.SCREENSHOTS.get(cached);
return new Response(await screenshot.arrayBuffer(), {
headers: { "Content-Type": "image/png", "X-Cache": "HIT" },
});
}
// Generate new screenshot
const browser = await puppeteer.launch(env.BROWSER);
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "load" }); // Don't wait for networkidle
const screenshot = await page.screenshot({ type: "png" });
// Cache in R2 and KV
const key = crypto.randomUUID();
await env.SCREENSHOTS.put(key, screenshot);
await env.CACHE.put(`screenshot:${url}`, key, { expirationTtl: 86400 });
return new Response(screenshot, {
headers: { "Content-Type": "image/png", "X-Cache": "MISS" },
});
} finally {
await browser.close(); // Always close!
}
},
} satisfies ExportedHandler<Env>;
```
---
## Summary
| Metric | Free | Paid |
|--------|------|------|
| Browser time | 10 min/day | 10 hrs/month + $0.09/hr |
| Concurrent browsers | 3 | 30 (10 included + $2/browser) |
| REST API rate | 6/min | 180/min |
| Browser timeout | 60s | 60s (up to 10 min with keep_alive) |
| Overage behavior | 429 error | Pay per use |
| Upgrade path | Go to Workers plans | Request higher limits |
**Key takeaways:**
- Always close browsers explicitly
- Monitor session history for idle closures
- Spread REST API requests evenly
- Cache aggressively
- Reuse sessions when possible
- Use try/finally for cleanup
references/patterns.md
# Browser Rendering Patterns
Advanced patterns and techniques for Cloudflare Browser Rendering.
> **Note:** Examples in this guide primarily use Puppeteer, but the patterns also apply to Playwright and the REST API. Where patterns differ between methods, specific examples are provided.
## REST API Patterns
The REST API provides simple endpoints for common tasks without requiring Workers Bindings setup.
### Screenshot Endpoint
```bash
curl -X POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/screenshot \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"fullPage": true,
"type": "png",
"viewport": {
"width": 1920,
"height": 1080
}
}' \
--output screenshot.png
```
### PDF Generation Endpoint
```bash
curl -X POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/pdf \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"format": "A4",
"printBackground": true
}' \
--output document.pdf
```
### Markdown Extraction Endpoint
AI-powered extraction of clean markdown from web pages:
```bash
curl -X POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/markdown \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
```
### Structured Data Extraction (AI)
Extract structured data using AI:
```bash
curl -X POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/json \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/products",
"schema": {
"products": {
"name": "string",
"price": "number",
"inStock": "boolean"
}
}
}'
```
### REST API with Worker Integration
Call REST API from a Worker to add caching or custom logic:
```typescript
interface Env {
BROWSER_RENDERING_TOKEN: string;
ACCOUNT_ID: string;
SCREENSHOTS: R2Bucket;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
// Check cache
const cacheKey = `screenshot-${btoa(url)}.png`;
const cached = await env.SCREENSHOTS.get(cacheKey);
if (cached) {
return new Response(await cached.arrayBuffer(), {
headers: { "Content-Type": "image/png", "X-Cache": "HIT" },
});
}
// Call REST API
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/browser-rendering/screenshot`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${env.BROWSER_RENDERING_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage: true,
type: "png",
}),
}
);
if (!response.ok) {
return new Response("Screenshot failed", { status: 500 });
}
const screenshot = await response.arrayBuffer();
// Cache for future requests
await env.SCREENSHOTS.put(cacheKey, screenshot);
return new Response(screenshot, {
headers: {
"Content-Type": "image/png",
"X-Cache": "MISS",
"X-Browser-Ms-Used": response.headers.get("X-Browser-Ms-Used") || "unknown",
},
});
},
} satisfies ExportedHandler<Env>;
```
---
## Caching Strategies
### Cache Screenshots in R2
Store generated screenshots in R2 to avoid regenerating them:
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER_RENDERING: Fetcher;
SCREENSHOTS: R2Bucket;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
// Create cache key
const encoder = new TextEncoder();
const data = encoder.encode(url);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
const cacheKey = `screenshot-${hash}.png`;
// Check cache first
const cached = await env.SCREENSHOTS.get(cacheKey);
if (cached) {
return new Response(await cached.arrayBuffer(), {
headers: {
"Content-Type": "image/png",
"X-Cache": "HIT",
},
});
}
// Generate screenshot
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(url, { waitUntil: "networkidle0" });
const screenshot = await page.screenshot({
type: "png",
fullPage: true,
});
// Cache for future requests
await env.SCREENSHOTS.put(cacheKey, screenshot, {
httpMetadata: { contentType: "image/png" },
});
return new Response(screenshot, {
headers: {
"Content-Type": "image/png",
"X-Cache": "MISS",
},
});
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
### Cache with KV for Quick Lookups
Use KV for metadata and small results:
```typescript
interface Env {
BROWSER_RENDERING: Fetcher;
CACHE: KVNamespace;
}
// Cache page metadata
const cacheKey = `metadata:${url}`;
const cached = await env.CACHE.get(cacheKey, "json");
if (cached) {
return Response.json(cached);
}
// Scrape and cache
const metadata = await scrapePage(url, env);
await env.CACHE.put(cacheKey, JSON.stringify(metadata), {
expirationTtl: 3600, // 1 hour
});
return Response.json(metadata);
```
---
## Rate Limiting
### Per-Domain Rate Limiting with Durable Objects
```typescript
import puppeteer from "@cloudflare/puppeteer";
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject {
async fetch(request: Request) {
const url = new URL(request.url);
const domain = url.searchParams.get("domain");
if (!domain) {
return Response.json({ allowed: false }, { status: 400 });
}
const count = await this.ctx.storage.get<number>(`count:${domain}`) || 0;
if (count >= 10) {
return Response.json({
allowed: false,
message: "Rate limit exceeded",
});
}
await this.ctx.storage.put(`count:${domain}`, count + 1, {
expirationTtl: 60, // Reset after 1 minute
});
return Response.json({ allowed: true, remaining: 10 - count - 1 });
}
}
interface Env {
BROWSER_RENDERING: Fetcher;
RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const targetUrl = searchParams.get("url");
if (!targetUrl) {
return new Response("Missing ?url parameter", { status: 400 });
}
const domain = new URL(targetUrl).hostname;
// Check rate limit
const limiterId = env.RATE_LIMITER.idFromName(domain);
const limiter = env.RATE_LIMITER.get(limiterId);
const rateLimitResponse = await limiter.fetch(
`https://internal/?domain=${domain}`
);
const { allowed } = await rateLimitResponse.json();
if (!allowed) {
return new Response("Rate limit exceeded", { status: 429 });
}
// Proceed with scraping
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(targetUrl);
const text = await page.$eval("body", el => el.textContent);
return Response.json({ text });
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
---
## Authentication Patterns
### Bearer Token Authentication
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
"Authorization": `Bearer ${env.API_TOKEN}`,
});
await page.goto(url);
const content = await page.content();
return Response.json({ content });
} finally {
await browser.close();
}
```
### Cookie-Based Authentication
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// Set authentication cookies
await page.setCookie({
name: "session_id",
value: sessionToken,
domain: new URL(url).hostname,
httpOnly: true,
secure: true,
});
await page.goto(url);
const content = await page.content();
return Response.json({ content });
} finally {
await browser.close();
}
```
### Form-Based Login
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// Navigate to login page
await page.goto("https://example.com/login");
// Fill in credentials
await page.type("#username", env.USERNAME);
await page.type("#password", env.PASSWORD);
// Submit form
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle0" }),
page.click("button[type=submit]"),
]);
// Now navigate to target page
await page.goto(targetUrl);
const content = await page.content();
return Response.json({ content });
} finally {
await browser.close();
}
```
---
## Multi-Page Scraping
### Sequential Page Navigation
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
const results = [];
for (const url of urls) {
await page.goto(url);
const data = await page.evaluate(() => ({
title: document.title,
content: document.body.textContent,
}));
results.push(data);
}
return Response.json({ results });
} finally {
await browser.close();
}
```
### Pagination Handling
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(startUrl);
const allItems = [];
let hasNextPage = true;
while (hasNextPage) {
// Extract items from current page
const items = await page.$$eval(".item", elements =>
elements.map(el => ({
title: el.querySelector("h3")?.textContent,
link: el.querySelector("a")?.href,
}))
);
allItems.push(...items);
// Check for next page button
const nextButton = await page.$(".next-page");
if (nextButton) {
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle0" }),
nextButton.click(),
]);
} else {
hasNextPage = false;
}
}
return Response.json({ items: allItems });
} finally {
await browser.close();
}
```
---
## SEO and Metadata Extraction
### Complete SEO Analysis
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle0" });
const seoData = await page.evaluate(() => {
const getMeta = (name: string) => {
const meta = document.querySelector(
`meta[name="${name}"], meta[property="${name}"]`
);
return meta?.getAttribute("content") || null;
};
return {
title: document.title,
description: getMeta("description"),
keywords: getMeta("keywords"),
ogTitle: getMeta("og:title"),
ogDescription: getMeta("og:description"),
ogImage: getMeta("og:image"),
ogUrl: getMeta("og:url"),
twitterCard: getMeta("twitter:card"),
canonical: document.querySelector("link[rel=canonical]")?.href,
h1: Array.from(document.querySelectorAll("h1")).map(h => h.textContent),
images: Array.from(document.querySelectorAll("img")).map(img => ({
src: img.src,
alt: img.alt,
})),
links: {
internal: [],
external: [],
},
};
});
return Response.json(seoData);
} finally {
await browser.close();
}
```
### Structured Data Extraction
```typescript
const structuredData = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll('script[type="application/ld+json"]')
);
return scripts.map(script => {
try {
return JSON.parse(script.textContent || "");
} catch {
return null;
}
}).filter(Boolean);
});
return Response.json({ structuredData });
```
---
## Performance Monitoring
### Track Page Load Metrics
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
const startTime = Date.now();
await page.goto(url, { waitUntil: "networkidle0" });
const loadTime = Date.now() - startTime;
const metrics = await page.evaluate(() => {
const perf = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
return {
domContentLoaded: perf.domContentLoadedEventEnd - perf.fetchStart,
loadComplete: perf.loadEventEnd - perf.fetchStart,
firstPaint: performance.getEntriesByType("paint")
.find(p => p.name === "first-paint")?.startTime,
firstContentfulPaint: performance.getEntriesByType("paint")
.find(p => p.name === "first-contentful-paint")?.startTime,
};
});
return Response.json({
totalTime: loadTime,
...metrics,
});
} finally {
await browser.close();
}
```
---
## Error Handling Patterns
### Comprehensive Error Handling
```typescript
interface Env {
BROWSER_RENDERING: Fetcher;
}
async function safeScrape(url: string, env: Env) {
let browser;
try {
// Validate URL
let normalizedUrl;
try {
normalizedUrl = new URL(url).toString();
} catch {
return {
success: false,
error: "Invalid URL format",
};
}
// Launch browser
browser = await puppeteer.launch(env.BROWSER_RENDERING);
const page = await browser.newPage();
// Set timeout
page.setDefaultTimeout(30000);
// Navigate with error handling
try {
await page.goto(normalizedUrl, {
waitUntil: "networkidle0",
timeout: 30000,
});
} catch (error) {
if (error.name === "TimeoutError") {
return {
success: false,
error: "Page load timeout",
};
}
if (error.message.includes("net::ERR")) {
return {
success: false,
error: "Network error",
};
}
throw error;
}
// Extract content
const content = await page.evaluate(() => ({
title: document.title,
text: document.body.textContent,
}));
return {
success: true,
data: content,
};
} catch (error) {
console.error("Scraping error:", error);
return {
success: false,
error: "Internal scraping error",
};
} finally {
if (browser) {
await browser.close();
}
}
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return Response.json(
{ error: "Missing ?url parameter" },
{ status: 400 }
);
}
const result = await safeScrape(url, env);
if (!result.success) {
return Response.json(
{ error: result.error },
{ status: 500 }
);
}
return Response.json(result.data);
},
} satisfies ExportedHandler<Env>;
```
---
## Integration Patterns
### Screenshot Service with R2 and KV
Complete service combining KV metadata and R2 storage:
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER_RENDERING: Fetcher;
SCREENSHOTS: R2Bucket;
METADATA: KVNamespace;
}
interface ScreenshotMetadata {
url: string;
timestamp: number;
size: number;
width: number;
height: number;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
const refresh = searchParams.has("refresh");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
// Generate cache key
const hash = await generateHash(url);
const metadataKey = `meta:${hash}`;
const screenshotKey = `screenshot-${hash}.png`;
// Check cache unless refresh requested
if (!refresh) {
const metadata = await env.METADATA.get<ScreenshotMetadata>(
metadataKey,
"json"
);
if (metadata) {
const screenshot = await env.SCREENSHOTS.get(screenshotKey);
if (screenshot) {
return new Response(await screenshot.arrayBuffer(), {
headers: {
"Content-Type": "image/png",
"X-Cache": "HIT",
"X-Timestamp": metadata.timestamp.toString(),
},
});
}
}
}
// Generate new screenshot
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(url, { waitUntil: "networkidle0" });
const screenshot = await page.screenshot({
type: "png",
fullPage: true,
});
// Store in R2
await env.SCREENSHOTS.put(screenshotKey, screenshot, {
httpMetadata: { contentType: "image/png" },
});
// Store metadata in KV
const metadata: ScreenshotMetadata = {
url,
timestamp: Date.now(),
size: screenshot.byteLength,
width: 1920,
height: 1080,
};
await env.METADATA.put(metadataKey, JSON.stringify(metadata), {
expirationTtl: 86400, // 24 hours
});
return new Response(screenshot, {
headers: {
"Content-Type": "image/png",
"X-Cache": "MISS",
"X-Timestamp": metadata.timestamp.toString(),
},
});
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
async function generateHash(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
```
### Queue-Based Scraping
Use Queues to handle large scraping jobs:
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface ScrapeMessage {
url: string;
jobId: string;
}
interface Env {
BROWSER_RENDERING: Fetcher;
SCRAPE_QUEUE: Queue<ScrapeMessage>;
RESULTS: KVNamespace;
}
// Producer: Add URLs to queue
export default {
async fetch(request, env): Promise<Response> {
const { urls } = await request.json() as { urls: string[] };
const jobId = crypto.randomUUID();
// Send all URLs to queue
await env.SCRAPE_QUEUE.sendBatch(
urls.map(url => ({ body: { url, jobId } }))
);
return Response.json({
jobId,
queued: urls.length,
});
},
// Consumer: Process queue messages
async queue(batch, env): Promise<void> {
for (const message of batch.messages) {
const { url, jobId } = message.body;
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(url, { timeout: 30000 });
const data = await page.evaluate(() => ({
title: document.title,
text: document.body.textContent,
}));
// Store result
await env.RESULTS.put(`${jobId}:${url}`, JSON.stringify(data));
message.ack();
} catch (error) {
console.error(`Failed to scrape ${url}:`, error);
message.retry();
} finally {
await browser.close();
}
}
},
} satisfies ExportedHandler<Env>;
```
---
## Best Practices Summary
1. **Always close browsers** - Use try/finally to ensure cleanup
2. **Cache aggressively** - Store results in R2 or KV
3. **Set timeouts** - Prevent hanging requests
4. **Validate inputs** - Normalize URLs before navigation
5. **Handle errors gracefully** - Catch and classify different error types
6. **Rate limit by domain** - Use Durable Objects for distributed limiting
7. **Monitor performance** - Track metrics and costs
8. **Respect robots.txt** - Check site policies
9. **Use appropriate waitUntil** - Balance speed vs completeness
10. **Test locally** - Use wrangler dev before deploying
references/puppeteer.md
# Puppeteer API Reference for Cloudflare
Complete reference for using `@cloudflare/puppeteer` in Cloudflare Workers.
> **Note:** Cloudflare also supports [@cloudflare/playwright](https://developers.cloudflare.com/browser-rendering/playwright/) as an alternative to Puppeteer. Playwright offers a modern API with built-in test assertions, trace files, and storage state management. This guide focuses on Puppeteer, but most concepts apply to Playwright as well.
## Browser Class
### puppeteer.launch()
Launch a new browser instance.
```typescript
import puppeteer from "@cloudflare/puppeteer";
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
```
**Parameters:**
- `binding: Fetcher` - The browser binding from your Env interface
**Returns:** `Promise<Browser>`
### browser.newPage()
Create a new page in the browser.
```typescript
const page = await browser.newPage();
```
**Returns:** `Promise<Page>`
### browser.close()
Close the browser and free resources.
```typescript
await browser.close();
```
**CRITICAL:** Always call this to prevent memory leaks.
---
## Page Class
### Navigation
#### page.goto()
Navigate to a URL.
```typescript
await page.goto(url, options);
```
**Options:**
```typescript
{
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
timeout?: number; // milliseconds, default 30000
referer?: string;
}
```
**waitUntil values:**
- `load` - Wait for load event (default)
- `domcontentloaded` - Wait for DOMContentLoaded event
- `networkidle0` - Wait until no network connections for 500ms
- `networkidle2` - Wait until no more than 2 network connections for 500ms
**Example:**
```typescript
await page.goto("https://example.com", {
waitUntil: "networkidle0",
timeout: 30000,
});
```
#### page.reload()
Reload the current page.
```typescript
await page.reload(options);
```
---
### Content Extraction
#### page.content()
Get the full HTML content of the page.
```typescript
const html = await page.content();
```
**Returns:** `Promise<string>` - Full HTML document
#### page.$eval()
Run a function on the first element matching a selector.
```typescript
const text = await page.$eval("h1", el => el.textContent);
```
**Parameters:**
- `selector: string` - CSS selector
- `pageFunction: (element) => T` - Function to run on the element
**Returns:** `Promise<T>` - Result of the function
**Example:**
```typescript
const links = await page.$eval("a", el => ({
text: el.textContent,
href: el.href,
}));
```
#### page.$$eval()
Run a function on all elements matching a selector.
```typescript
const allLinks = await page.$$eval("a", elements => {
return elements.map(el => ({
text: el.textContent,
href: el.href,
}));
});
```
**Parameters:**
- `selector: string` - CSS selector
- `pageFunction: (elements) => T` - Function to run on array of elements
**Returns:** `Promise<T>` - Result of the function
#### page.evaluate()
Execute JavaScript in the page context.
```typescript
const data = await page.evaluate(() => {
return {
title: document.title,
url: window.location.href,
userAgent: navigator.userAgent,
};
});
```
**Parameters:**
- `pageFunction: (...args) => T` - Function to execute in page context
- `...args` - Arguments to pass to the function (must be serializable)
**Returns:** `Promise<T>` - Result of the function (must be serializable)
**Complex extraction example:**
```typescript
const articles = await page.evaluate(() => {
return Array.from(document.querySelectorAll("article")).map(article => ({
title: article.querySelector("h2")?.textContent,
date: article.querySelector("time")?.getAttribute("datetime"),
excerpt: article.querySelector("p")?.textContent,
link: article.querySelector("a")?.href,
}));
});
```
---
### Viewport and Display
#### page.setViewport()
Set the viewport size and device scale factor.
```typescript
await page.setViewport({
width: 1920,
height: 1080,
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
});
```
**Options:**
```typescript
{
width: number;
height: number;
deviceScaleFactor?: number; // default 1
isMobile?: boolean; // default false
hasTouch?: boolean; // default false
isLandscape?: boolean; // default false
}
```
**Common presets:**
```typescript
// Desktop
await page.setViewport({ width: 1920, height: 1080 });
// Mobile (iPhone 12)
await page.setViewport({
width: 390,
height: 844,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
});
// Tablet (iPad)
await page.setViewport({
width: 768,
height: 1024,
isMobile: true,
hasTouch: true,
});
```
---
### Screenshots
#### page.screenshot()
Take a screenshot of the page.
```typescript
const screenshot = await page.screenshot(options);
```
**Options:**
```typescript
{
type?: "png" | "jpeg" | "webp"; // default "png"
quality?: number; // 0-100, only for jpeg/webp
fullPage?: boolean; // default false
clip?: {
x: number;
y: number;
width: number;
height: number;
};
omitBackground?: boolean; // default false (transparent background)
encoding?: "binary" | "base64"; // default "binary"
}
```
**Examples:**
Full page PNG:
```typescript
const screenshot = await page.screenshot({
type: "png",
fullPage: true,
});
return new Response(screenshot, {
headers: { "Content-Type": "image/png" },
});
```
JPEG with quality:
```typescript
const screenshot = await page.screenshot({
type: "jpeg",
quality: 80,
fullPage: false,
});
```
Specific region:
```typescript
const screenshot = await page.screenshot({
clip: {
x: 0,
y: 0,
width: 800,
height: 600,
},
});
```
Base64 encoding:
```typescript
const screenshot = await page.screenshot({
type: "png",
encoding: "base64",
});
return Response.json({ image: screenshot });
```
---
### PDF Generation
#### page.pdf()
Generate a PDF of the page.
```typescript
const pdf = await page.pdf(options);
```
**Options:**
```typescript
{
format?: "Letter" | "Legal" | "A4" | "A3"; // default "Letter"
width?: string | number; // e.g. "210mm", 8.5
height?: string | number; // e.g. "297mm", 11
margin?: {
top?: string | number;
right?: string | number;
bottom?: string | number;
left?: string | number;
};
printBackground?: boolean; // default false
landscape?: boolean; // default false
pageRanges?: string; // e.g. "1-5, 8, 11-13"
scale?: number; // default 1
preferCSSPageSize?: boolean; // default false
}
```
**Examples:**
Basic A4 PDF:
```typescript
const pdf = await page.pdf({
format: "A4",
printBackground: true,
});
return new Response(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=page.pdf",
},
});
```
Custom size with margins:
```typescript
const pdf = await page.pdf({
width: "8.5in",
height: "11in",
margin: {
top: "1in",
right: "0.5in",
bottom: "1in",
left: "0.5in",
},
printBackground: true,
});
```
Landscape orientation:
```typescript
const pdf = await page.pdf({
format: "A4",
landscape: true,
printBackground: true,
});
```
---
### Waiting Strategies
#### page.waitForSelector()
Wait for a selector to appear in the DOM.
```typescript
await page.waitForSelector(selector, options);
```
**Options:**
```typescript
{
visible?: boolean; // wait for element to be visible
hidden?: boolean; // wait for element to be hidden
timeout?: number; // milliseconds, default 30000
}
```
**Examples:**
```typescript
// Wait for element to appear
await page.waitForSelector(".loaded");
// Wait for element to be visible
await page.waitForSelector(".modal", { visible: true });
// Wait for element to disappear
await page.waitForSelector(".spinner", { hidden: true });
```
#### page.waitForFunction()
Wait for a function to return a truthy value.
```typescript
await page.waitForFunction(pageFunction, options, ...args);
```
**Examples:**
```typescript
// Wait for custom condition
await page.waitForFunction(() => {
return document.querySelector(".content")?.textContent?.length > 0;
});
// With timeout
await page.waitForFunction(
() => window.dataLoaded === true,
{ timeout: 10000 }
);
// With arguments
await page.waitForFunction(
(minCount) => document.querySelectorAll(".item").length >= minCount,
{},
10 // minCount argument
);
```
#### page.waitForTimeout()
Wait for a specific amount of time.
```typescript
await page.waitForTimeout(milliseconds);
```
**Example:**
```typescript
await page.waitForTimeout(2000); // Wait 2 seconds
```
**Note:** Prefer `waitForSelector` or `waitForFunction` over hard-coded timeouts when possible.
---
### HTTP Headers and Authentication
#### page.setExtraHTTPHeaders()
Set extra HTTP headers for all requests.
```typescript
await page.setExtraHTTPHeaders({
"Authorization": `Bearer ${token}`,
"X-Custom-Header": "value",
});
```
**Example with API token:**
```typescript
await page.setExtraHTTPHeaders({
"Authorization": `Bearer ${env.API_TOKEN}`,
"Accept-Language": "en-US",
});
await page.goto(url);
```
#### page.authenticate()
Provide credentials for HTTP authentication.
```typescript
await page.authenticate({
username: "user",
password: "pass",
});
```
---
### Cookies
#### page.setCookie()
Set cookies for the page.
```typescript
await page.setCookie(...cookies);
```
**Cookie format:**
```typescript
{
name: string;
value: string;
domain?: string;
path?: string;
expires?: number; // Unix timestamp
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}
```
**Example:**
```typescript
await page.setCookie({
name: "session",
value: "abc123",
domain: "example.com",
httpOnly: true,
secure: true,
});
```
#### page.cookies()
Get all cookies for the page.
```typescript
const cookies = await page.cookies();
```
---
## Element Handles
### page.$()
Get the first element matching a selector.
```typescript
const element = await page.$("selector");
if (element) {
// Element found
}
```
**Returns:** `Promise<ElementHandle | null>`
### page.$$()
Get all elements matching a selector.
```typescript
const elements = await page.$$("selector");
```
**Returns:** `Promise<ElementHandle[]>`
### ElementHandle methods
```typescript
const element = await page.$("button");
// Click
await element.click();
// Type text
await element.type("text to type");
// Get property
const value = await element.getProperty("value");
const href = await element.getProperty("href");
```
---
## Keyboard and Mouse
### page.type()
Type text into a focused element.
```typescript
await page.type("input[name=email]", "user@example.com");
```
### page.click()
Click an element.
```typescript
await page.click("button#submit");
```
**Options:**
```typescript
{
button?: "left" | "right" | "middle";
clickCount?: number; // for double-click
delay?: number; // milliseconds between mousedown and mouseup
}
```
### page.keyboard
```typescript
await page.keyboard.press("Enter");
await page.keyboard.type("text to type");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
```
---
## Best Practices
1. **Always close browsers** - Use try/finally blocks
2. **Set reasonable timeouts** - Prevent hanging requests
3. **Use appropriate waitUntil** - Balance speed vs content readiness
4. **Validate URLs** - Normalize and validate before navigation
5. **Handle errors** - Catch TimeoutError and navigation errors
6. **Limit concurrent browsers** - Each browser consumes memory
7. **Cache results** - Store screenshots/PDFs in R2 or KV
8. **Monitor performance** - Track browser launch and page load times
9. **Test locally first** - Use `wrangler dev` to iterate quickly
10. **Respect rate limits** - Don't overwhelm target sites
---
## Common Issues
### Timeout Errors
```typescript
try {
await page.goto(url, { timeout: 30000 });
} catch (error) {
if (error.name === "TimeoutError") {
// Handle timeout
return new Response("Page load timeout", { status: 504 });
}
throw error;
}
```
### Memory Management
```typescript
// Good: Close browser in finally block
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// ... operations
} finally {
await browser.close(); // Always called
}
```
### Navigation Failed
```typescript
try {
await page.goto(url);
} catch (error) {
if (error.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
return new Response("Invalid URL", { status: 400 });
}
throw error;
}
```
references/testing.md
# Testing Browser Rendering
Browser Rendering bindings require **mocking** for local testing since headless browsers don't run in test environments.
## Testing Approach
The Browser Rendering API (`@cloudflare/puppeteer`) connects to remote headless Chromium instances. In tests:
1. **Mock Puppeteer** for unit tests
2. **Test Worker logic** separately from browser interactions
3. **Use integration tests** with mocked browser responses
## Setup
### Install Dependencies
```bash
npm i -D vitest@~3.2.0 @cloudflare/vitest-pool-workers
```
### vitest.config.ts
```typescript
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.jsonc" },
},
},
},
});
```
## Mocking Puppeteer
Create comprehensive mocks for `@cloudflare/puppeteer`:
```typescript
// test/mocks/puppeteer.ts
import { vi } from "vitest";
export function createMockBrowser() {
const mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
screenshot: vi.fn().mockResolvedValue(Buffer.from("fake-image-data")),
pdf: vi.fn().mockResolvedValue(Buffer.from("fake-pdf-data")),
content: vi.fn().mockResolvedValue("<html><body>Mock Content</body></html>"),
evaluate: vi.fn().mockResolvedValue(undefined),
setViewport: vi.fn().mockResolvedValue(undefined),
setUserAgent: vi.fn().mockResolvedValue(undefined),
waitForSelector: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
metrics: vi.fn().mockResolvedValue({
Timestamp: Date.now(),
Documents: 1,
Frames: 1,
JSEventListeners: 10,
}),
};
const mockBrowser = {
newPage: vi.fn().mockResolvedValue(mockPage),
close: vi.fn().mockResolvedValue(undefined),
version: vi.fn().mockResolvedValue("HeadlessChrome/120.0.0.0"),
};
return { mockBrowser, mockPage };
}
```
## Unit Tests with Mocks
```typescript
// test/screenshot.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createMockBrowser } from "./mocks/puppeteer";
// Mock the module
vi.mock("@cloudflare/puppeteer", () => ({
default: {
launch: vi.fn(),
},
}));
import puppeteer from "@cloudflare/puppeteer";
import { takeScreenshot } from "../src/screenshot";
describe("Screenshot service", () => {
const { mockBrowser, mockPage } = createMockBrowser();
beforeEach(() => {
vi.mocked(puppeteer.launch).mockResolvedValue(mockBrowser as any);
});
it("takes screenshot of URL", async () => {
const mockEnv = { BROWSER: {} };
const result = await takeScreenshot(mockEnv, "https://example.com");
expect(puppeteer.launch).toHaveBeenCalledWith(mockEnv.BROWSER);
expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", expect.any(Object));
expect(mockPage.screenshot).toHaveBeenCalled();
expect(result).toBeInstanceOf(Buffer);
});
it("closes browser after screenshot", async () => {
const mockEnv = { BROWSER: {} };
await takeScreenshot(mockEnv, "https://example.com");
expect(mockBrowser.close).toHaveBeenCalled();
});
it("handles navigation errors", async () => {
mockPage.goto.mockRejectedValueOnce(new Error("Navigation failed"));
const mockEnv = { BROWSER: {} };
await expect(takeScreenshot(mockEnv, "https://invalid.url")).rejects.toThrow("Navigation failed");
expect(mockBrowser.close).toHaveBeenCalled(); // Cleanup still happens
});
});
```
## Testing Worker HTTP Handler
```typescript
// test/worker.spec.ts
import { SELF, env } from "cloudflare:test";
import { describe, it, expect, vi, beforeAll } from "vitest";
// Mock puppeteer before imports
vi.mock("@cloudflare/puppeteer", () => ({
default: {
launch: vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({
goto: vi.fn(),
screenshot: vi.fn().mockResolvedValue(Buffer.from("fake-png")),
close: vi.fn(),
}),
close: vi.fn(),
}),
},
}));
describe("Screenshot API", () => {
it("returns screenshot for valid URL", async () => {
const response = await SELF.fetch("http://example.com/screenshot?url=https://example.com");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
});
it("returns 400 for missing URL", async () => {
const response = await SELF.fetch("http://example.com/screenshot");
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("URL required");
});
it("returns 400 for invalid URL", async () => {
const response = await SELF.fetch("http://example.com/screenshot?url=not-a-url");
expect(response.status).toBe(400);
});
});
```
## Testing Page Evaluation Logic
```typescript
describe("Page evaluation", () => {
const { mockBrowser, mockPage } = createMockBrowser();
beforeEach(() => {
vi.mocked(puppeteer.launch).mockResolvedValue(mockBrowser as any);
});
it("extracts data from page", async () => {
mockPage.evaluate.mockResolvedValue({
title: "Example Page",
links: 5,
images: 3,
});
const mockEnv = { BROWSER: {} };
const result = await extractPageData(mockEnv, "https://example.com");
expect(result).toEqual({
title: "Example Page",
links: 5,
images: 3,
});
});
it("handles evaluation errors", async () => {
mockPage.evaluate.mockRejectedValue(new Error("Script error"));
const mockEnv = { BROWSER: {} };
await expect(extractPageData(mockEnv, "https://example.com")).rejects.toThrow("Script error");
});
});
```
## Testing Screenshot Options
```typescript
describe("Screenshot options", () => {
it("supports full page screenshot", async () => {
const { mockBrowser, mockPage } = createMockBrowser();
vi.mocked(puppeteer.launch).mockResolvedValue(mockBrowser as any);
await takeScreenshot(env, "https://example.com", { fullPage: true });
expect(mockPage.screenshot).toHaveBeenCalledWith(
expect.objectContaining({ fullPage: true })
);
});
it("supports custom viewport", async () => {
const { mockBrowser, mockPage } = createMockBrowser();
vi.mocked(puppeteer.launch).mockResolvedValue(mockBrowser as any);
await takeScreenshot(env, "https://example.com", {
viewport: { width: 1920, height: 1080 },
});
expect(mockPage.setViewport).toHaveBeenCalledWith({
width: 1920,
height: 1080,
});
});
});
```
## Testing PDF Generation
```typescript
describe("PDF generation", () => {
it("generates PDF from URL", async () => {
const { mockBrowser, mockPage } = createMockBrowser();
vi.mocked(puppeteer.launch).mockResolvedValue(mockBrowser as any);
mockPage.pdf.mockResolvedValue(Buffer.from("%PDF-1.4 fake pdf content"));
const result = await generatePDF(env, "https://example.com");
expect(mockPage.pdf).toHaveBeenCalledWith(
expect.objectContaining({ format: "A4" })
);
expect(result.toString()).toContain("%PDF");
});
});
```
## Example Implementation
```typescript
// src/screenshot.ts
import puppeteer from "@cloudflare/puppeteer";
interface ScreenshotOptions {
fullPage?: boolean;
viewport?: { width: number; height: number };
}
export async function takeScreenshot(
env: { BROWSER: unknown },
url: string,
options: ScreenshotOptions = {}
): Promise<Buffer> {
const browser = await puppeteer.launch(env.BROWSER);
try {
const page = await browser.newPage();
if (options.viewport) {
await page.setViewport(options.viewport);
}
await page.goto(url, { waitUntil: "networkidle0" });
const screenshot = await page.screenshot({
fullPage: options.fullPage ?? false,
type: "png",
});
return screenshot as Buffer;
} finally {
await browser.close();
}
}
```
## Running Tests
```bash
npx vitest # Watch mode
npx vitest run # Single run
```
## Known Limitations
- **No local browser execution** - Real browsers don't run in tests
- **XPath not supported** in Cloudflare's Puppeteer fork
- **Some Puppeteer features** may be restricted
## Best Practices
1. **Always mock Puppeteer** for unit tests
2. **Test browser logic separately** from HTTP handler
3. **Clean up browsers** in finally blocks
4. **Test error scenarios** (navigation failures, timeouts)
5. **Mock different responses** for different test cases
6. **Test option handling** (viewport, fullPage, etc.)
7. **Use integration tests in staging** for real browser behavior
SKILL.md
---
name: browser-rendering
description: Headless Chrome automation for web scraping, screenshots, PDFs, and testing at the edge. Load when capturing page screenshots, generating PDFs, scraping dynamic content, extracting structured data, or automating browser interactions. Supports REST API, Puppeteer, Playwright, and Stagehand.
---
# Browser Rendering
Cloudflare Browser Rendering provides headless Chrome instances on the global edge network for web scraping, screenshots, PDF generation, and automated testing. Choose from **REST API** for simple tasks or **Workers Bindings** (Puppeteer, Playwright, Stagehand) for advanced automation.
## Choosing an Integration Method
| Method | Use Case | Complexity |
|--------|----------|------------|
| **REST API** | Simple screenshots, PDFs, markdown extraction, structured data | Low - just HTTP requests |
| **Puppeteer** | Industry-standard Chrome automation, porting existing scripts | Medium - familiar API |
| **Playwright** | Modern cross-browser automation, AI agent integration (MCP) | Medium - developer-friendly |
| **Stagehand** | AI-native automation with natural language selectors | Low - resilient to site changes |
## REST API Method
For simple, stateless tasks like capturing screenshots or generating PDFs without writing complex scripts.
### Setup
Create an API token with `Browser Rendering - Edit` permission in the Cloudflare dashboard.
### Available Endpoints
| Endpoint | Purpose |
|----------|---------|
| `/content` | Fetch fully-rendered HTML |
| `/screenshot` | Capture page screenshot |
| `/pdf` | Generate PDF document |
| `/snapshot` | Take webpage snapshot |
| `/scrape` | Extract HTML elements |
| `/json` | Capture structured data using AI |
| `/links` | Retrieve all links from page |
| `/markdown` | Extract markdown content |
### REST API Example
```bash
curl -X POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/browser-rendering/screenshot \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"fullPage": true,
"type": "png"
}' \
--output screenshot.png
```
**Monitoring usage:** Check the `X-Browser-Ms-Used` header in responses to see browser time consumed (in milliseconds).
## Workers Bindings Method
For complex workflows, persistent sessions, and custom automation. Requires deploying a Cloudflare Worker.
### Configuration
Add browser binding to `wrangler.jsonc`:
```jsonc
{
"name": "browser-automation",
"main": "src/index.ts",
"compatibility_date": "2025-09-17",
"compatibility_flags": ["nodejs_compat"],
"browser": {
"binding": "BROWSER"
}
}
```
### Option A: Puppeteer
Industry-standard Chrome automation API, ideal for porting existing scripts.
**Installation:**
```bash
npm install @cloudflare/puppeteer --save-dev
```
**Basic example:**
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER: Fetcher;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
const browser = await puppeteer.launch(env.BROWSER);
try {
const page = await browser.newPage();
await page.goto(url);
const text = await page.$eval("body", (el) => el.textContent);
return Response.json({ bodyText: text });
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
### Option B: Playwright (Recommended for New Projects)
Modern browser automation with developer-friendly API and AI agent integration.
**Installation:**
```bash
npm install @cloudflare/playwright --save-dev
```
**Current version:** v1.57.0
**Basic example:**
```typescript
import { launch } from "@cloudflare/playwright";
interface Env {
BROWSER: Fetcher;
}
export default {
async fetch(request, env): Promise<Response> {
const browser = await launch(env.BROWSER);
try {
const page = await browser.newPage();
await page.goto("https://example.com");
const title = await page.title();
const screenshot = await page.screenshot({ type: "png" });
return new Response(screenshot, {
headers: { "Content-Type": "image/png" },
});
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
**Playwright advantages:**
- Built-in test assertions with `expect()` from `@cloudflare/playwright/test`
- Trace files for debugging (downloadable `trace.zip`)
- Storage state for persisting cookies/localStorage
- MCP integration for AI agents
### Option C: Stagehand (AI-Native)
Uses natural language selectors instead of brittle CSS selectors, making automation resilient to website changes.
**Example:**
```typescript
// Instead of: await page.click("#submit-button")
// Use natural language: await page.act("click the submit button")
```
Stagehand is ideal for AI agents that need to autonomously navigate websites without breaking when the DOM structure changes.
## FIRST: Installation (Workers Bindings)
Choose your library and install:
```bash
# Puppeteer (industry standard)
npm install @cloudflare/puppeteer --save-dev
# OR Playwright (modern, recommended)
npm install @cloudflare/playwright --save-dev
# OR Stagehand (AI-native)
npm install @cloudflare/stagehand --save-dev
```
## When to Use Browser Rendering
| Use Case | Recommended Method | Why |
|----------|-------------------|-----|
| Simple screenshots | REST API | No code required, just HTTP request |
| PDF generation | REST API or Puppeteer | REST for simple, Puppeteer for custom headers/auth |
| Web scraping | Puppeteer or Playwright | Full DOM access, complex navigation |
| Automated testing | Playwright | Built-in assertions, trace files |
| SEO analysis | REST API `/markdown` or `/json` | AI-powered structured extraction |
| AI agents | Playwright (MCP) or Stagehand | Natural language automation, resilient selectors |
| Page monitoring | REST API or Puppeteer | REST for periodic checks, Puppeteer for complex flows |
## Quick Reference
| Operation | API |
|-----------|-----|
| Launch browser | `await puppeteer.launch(env.BROWSER_RENDERING)` |
| Create new page | `await browser.newPage()` |
| Navigate to URL | `await page.goto(url)` |
| Get HTML content | `await page.content()` |
| Extract text | `await page.$eval("selector", el => el.textContent)` |
| Take screenshot | `await page.screenshot({ type: "png" })` |
| Generate PDF | `await page.pdf({ format: "A4" })` |
| Close browser | `await browser.close()` |
## Basic Page Scraping
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER_RENDERING: Fetcher;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
let url = searchParams.get("url");
if (url) {
url = new URL(url).toString(); // normalize
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
const page = await browser.newPage();
await page.goto(url);
// Parse the page content
const content = await page.content();
// Find text within the page content
const text = await page.$eval("body", (el) => el.textContent);
// Do something with the text
// e.g. log it to the console, write it to KV, or store it in a database.
console.log(text);
// Ensure we close the browser session
await browser.close();
return Response.json({
bodyText: text,
});
} else {
return Response.json({
error: "Please add an ?url=https://example.com/ parameter"
}, { status: 400 });
}
},
} satisfies ExportedHandler<Env>;
```
## Screenshot Capture
Capture screenshots of web pages as PNG or JPEG:
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER_RENDERING: Fetcher;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// Set viewport size
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(url, { waitUntil: "networkidle0" });
// Capture screenshot
const screenshot = await page.screenshot({
type: "png",
fullPage: true, // Capture entire page
});
return new Response(screenshot, {
headers: { "Content-Type": "image/png" },
});
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
## PDF Generation
Convert web pages to PDF documents:
```typescript
import puppeteer from "@cloudflare/puppeteer";
interface Env {
BROWSER_RENDERING: Fetcher;
}
export default {
async fetch(request, env): Promise<Response> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
if (!url) {
return new Response("Missing ?url parameter", { status: 400 });
}
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle0" });
// Generate PDF
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: {
top: "20px",
right: "20px",
bottom: "20px",
left: "20px",
},
});
return new Response(pdf, {
headers: { "Content-Type": "application/pdf" },
});
} finally {
await browser.close();
}
},
} satisfies ExportedHandler<Env>;
```
## Browser Lifecycle Management
**CRITICAL: Always close the browser after use** to prevent memory leaks and ensure resources are freed:
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// ... perform operations
} finally {
// Always close, even if an error occurs
await browser.close();
}
```
**Best practices:**
1. Use try/finally to ensure cleanup
2. Close browser even if operations fail
3. Each request should launch and close its own browser
4. Don't reuse browser instances across requests
5. Set reasonable timeouts to prevent hanging
## DOM Manipulation and Extraction
Extract structured data from pages:
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
await page.goto(url);
// Extract multiple elements
const data = await page.evaluate(() => {
return {
title: document.querySelector("h1")?.textContent,
links: Array.from(document.querySelectorAll("a")).map(a => ({
text: a.textContent,
href: a.href,
})),
images: Array.from(document.querySelectorAll("img")).map(img => img.src),
};
});
return Response.json(data);
} finally {
await browser.close();
}
```
## Wait Strategies
Wait for content to load before scraping:
```typescript
const page = await browser.newPage();
await page.goto(url);
// Wait for specific selector
await page.waitForSelector(".content-loaded");
// Wait for navigation with options
await page.goto(url, {
waitUntil: "networkidle0", // Wait until network is idle
timeout: 30000, // 30 second timeout
});
// Wait for custom condition
await page.waitForFunction(() => {
return document.querySelector(".dynamic-content") !== null;
});
```
## Error Handling
Handle common browser automation errors:
```typescript
const browser = await puppeteer.launch(env.BROWSER_RENDERING);
try {
const page = await browser.newPage();
// Set timeout for navigation
await page.goto(url, { timeout: 30000 });
} catch (error) {
if (error.name === "TimeoutError") {
return new Response("Page load timeout", { status: 504 });
}
console.error("Browser error:", error);
return new Response("Browser automation failed", { status: 500 });
} finally {
await browser.close();
}
```
## Puppeteer vs Playwright
| Feature | Puppeteer | Playwright |
|---------|-----------|------------|
| API Style | Chrome DevTools Protocol | Higher-level abstractions |
| Test Assertions | Manual | Built-in `expect()` |
| Debugging | Console logs | Trace files with GUI |
| Storage Persistence | Manual | Built-in storage state API |
| MCP Integration | No | Yes (for AI agents) |
**Choose Puppeteer if:** You have existing scripts or prefer Chrome DevTools Protocol.
**Choose Playwright if:** Starting new, need test assertions, want better debugging, or building AI agents.
See [references/puppeteer.md](references/puppeteer.md) for complete Playwright-specific features including test assertions, trace files, and storage state patterns
## Detailed References
- **[references/puppeteer.md](references/puppeteer.md)** - Complete Puppeteer API reference
- **[references/patterns.md](references/patterns.md)** - Advanced patterns (caching, rate limiting, authentication)
- **[references/limits.md](references/limits.md)** - Limits, quotas, pricing, and troubleshooting
- **[references/testing.md](references/testing.md)** - Mocking Puppeteer, testing browser logic
## Best Practices
### General
1. **Choose the right method**: REST API for simple tasks, Workers Bindings for complex automation
2. **Always close browsers**: Use try/finally to ensure `browser.close()` is called (Workers Bindings only)
3. **Monitor usage**: Check `X-Browser-Ms-Used` header (REST) or dashboard for browser time consumed
4. **Set timeouts**: Prevent hanging requests with reasonable timeout values
5. **Handle errors gracefully**: Catch TimeoutError and 429 rate limit errors
6. **Test locally first**: Use `wrangler dev` to iterate before deploying
### Workers Bindings Specific
7. **Reuse sessions**: Keep browsers open with `keep_alive` option (up to 10 minutes) for better performance
8. **Use waitUntil options**: Wait for appropriate page state (networkidle0, load, domcontentloaded)
9. **Consider caching**: Cache static screenshots/PDFs in R2 or KV
10. **Respect robots.txt**: Check site policies before automated scraping
11. **Check session history**: Use `playwright.history()` or `puppeteer.history()` to debug idle timeouts
### REST API Specific
12. **Spread requests evenly**: Rate limits are per-second, not burst (e.g., 6/min = 1 per 10 seconds)
13. **Handle 429 responses**: Read `Retry-After` header and retry appropriately
14. **Use AI endpoints**: `/json` and `/markdown` endpoints use AI for structured extraction
## Common Patterns
See [references/patterns.md](references/patterns.md) for complete examples including:
- Session reuse and keep-alive for performance
- Authentication with custom headers
- Custom user agents
- Caching screenshots in R2
- Rate limiting with Durable Objects