Puppeteer and Playwright can't run inside a normal Cloudflare Worker: there is no Chromium binary and no Node child processes. This guide gives one tested Worker that returns website screenshots (cached at the edge), turns JSON into a PDF, and saves a dated screenshot to R2 every day from a Cron Trigger, by calling a render API with fetch().
| Option | Good for |
|---|---|
| Cloudflare Browser Run (formerly Browser Rendering) | Keeping everything inside Cloudflare: a Puppeteer/Playwright browser bound to your Worker Workers Free: 10 browser minutes a day. Workers Paid: 10 hours a month, then $0.09 per hour (pricing page, checked 20 Oct 2026) |
| A hosted render API (Shotmint, …) | One fetch(), any Workers plan, and the same code on Lambda, Vercel or a cron boxCosts per render (€5 for 2,000, no subscription); pages are rendered off Cloudflare |
If you're already on Workers Paid and happy writing Puppeteer code, Browser Run is a good fit. If you'd rather not manage browser sessions, or you're on the Free plan, the Worker below needs no bindings except an R2 bucket for the cron job.
Three routes in one file: GET /shot?url= returns a PNG (only for hosts you allow, cached for a day with the Cache API so repeat views cost nothing), POST /pdf builds an HTML report from JSON and returns an A4 PDF, and scheduled() saves a full-page WebP of each page to R2.
// src/index.js: screenshot + PDF Worker backed by the Shotmint API
const API = "https://shotmint.creasework.com";
const ALLOWED = ["example.com", "www.example.com"]; // hosts this Worker may capture
export default {
async fetch(request, env, ctx) {
const { pathname, searchParams } = new URL(request.url);
// GET /shot?url=https://example.com -> PNG, cached at the edge for a day
if (pathname === "/shot" && request.method === "GET") {
let target;
try { target = new URL(searchParams.get("url")); } catch { return new Response("bad url", { status: 400 }); }
if (!ALLOWED.includes(target.hostname)) return new Response("host not allowed", { status: 403 });
const cache = caches.default;
const cacheKey = new Request(new URL("/shot?url=" + encodeURIComponent(target.href), request.url));
const cached = await cache.match(cacheKey);
if (cached) return cached;
const q = new URLSearchParams({ url: target.href, width: "1280", height: "800", format: "png" });
const r = await fetch(API + "/v1/screenshot?" + q, { headers: { "X-API-Key": env.SHOTMINT_KEY } });
if (!r.ok) return new Response(await r.text(), { status: r.status });
const res = new Response(r.body, {
headers: { "Content-Type": r.headers.get("Content-Type"), "Cache-Control": "public, max-age=86400" },
});
ctx.waitUntil(cache.put(cacheKey, res.clone()));
return res;
}
// POST /pdf with {"title": "...", "rows": [["Item", "Price"], ...]} -> A4 PDF
if (pathname === "/pdf" && request.method === "POST") {
if (request.headers.get("Authorization") !== "Bearer " + env.APP_TOKEN) return new Response("unauthorized", { status: 401 });
const { title = "Report", rows = [] } = await request.json();
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
const html = `<!doctype html><meta charset="utf-8"><style>
body { font: 14px system-ui, sans-serif; margin: 0; } h1 { font-size: 22px; }
table { border-collapse: collapse; width: 100%; } td { border-bottom: 1px solid #ddd; padding: 6px 4px; }
</style><h1>${esc(title)}</h1><table>${rows.map(r => `<tr>${r.map(c => `<td>${esc(c)}</td>`).join("")}</tr>`).join("")}</table>`;
const r = await fetch(API + "/v1/pdf", {
method: "POST",
headers: { "X-API-Key": env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html, format: "A4" }),
});
if (!r.ok) return new Response(await r.text(), { status: r.status });
return new Response(r.body, {
headers: { "Content-Type": "application/pdf", "Content-Disposition": 'attachment; filename="report.pdf"' },
});
}
return new Response("not found", { status: 404 });
},
// Cron Trigger: save a dated full-page screenshot of each page to R2
async scheduled(event, env, ctx) {
const day = new Date(event.scheduledTime).toISOString().slice(0, 10);
for (const url of ["https://example.com/"]) {
const q = new URLSearchParams({ url, full_page: "true", format: "webp" });
const r = await fetch(API + "/v1/screenshot?" + q, { headers: { "X-API-Key": env.SHOTMINT_KEY } });
if (!r.ok) { console.log("failed", url, r.status, await r.text()); continue; }
const name = new URL(url).hostname + "/" + day + ".webp";
await env.SHOTS.put(name, r.body, { httpMetadata: { contentType: "image/webp" } });
console.log("saved", name);
}
},
};
Remove the [triggers] and [[r2_buckets]] blocks if you only need the HTTP routes. Create the bucket first with npx wrangler r2 bucket create screenshots.
name = "screenshots"
main = "src/index.js"
compatibility_date = "2026-10-01"
[triggers]
crons = ["0 6 * * *"] # every day at 06:00 UTC
[[r2_buckets]]
binding = "SHOTS"
bucket_name = "screenshots"
Keep the API key out of the code and out of wrangler.toml. APP_TOKEN is a password of your choice that callers of /pdf must send.
npx wrangler secret put SHOTMINT_KEY
npx wrangler secret put APP_TOKEN
npx wrangler deploy
For local development, put the same two values in a .dev.vars file (one NAME=value per line, and add it to .gitignore), then run npx wrangler dev --test-scheduled.
curl -o shot.png "https://screenshots.example.workers.dev/shot?url=https://example.com"
curl -o report.pdf -X POST https://screenshots.example.workers.dev/pdf \
-H "Authorization: Bearer $APP_TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Weekly sales","rows":[["Item","Price"],["Widget","€12"]]}'
ALLOWED list, anyone could screenshot any site on your credits. For user-supplied URLs, require auth or use signed image URLs instead.caches.default is per data center and only works on a custom domain or route, not on *.workers.dev previews. Our API's own cache_ttl parameter (up to 2592000 s, 30 days) also makes identical requests free, wherever they come from.fetch() doesn't count toward a Worker's CPU limit, so a several-second render is fine on the Free plan.error field (for example 400 for a bad URL) and isn't charged. The Worker passes the status through, so check it on the client.r.body straight to put() works because the API sends a Content-Length. If you transform the stream, buffer it with await r.arrayBuffer() first.We ran this exact Worker locally with Wrangler 4.151 (wrangler dev, the same workerd runtime Cloudflare uses) against the live API: /shot returned a 1280×800 PNG and the second request came from the cache in 7 ms; a host outside the list got a 403; /pdf returned a one-page A4 PDF with the escaped title; a request without the token got a 401; and the scheduled handler saved example.com/2026-10-20.webp to the local R2 bucket. We did not deploy it to a Cloudflare account, so the custom-domain cache behaviour above is from Cloudflare's docs.
Related: AWS Lambda and Vercel, GitHub Actions, website change monitoring. Try the output first, no key needed: website screenshot, URL to PDF.
One screenshot or PDF = one credit. €5 buys 2,000 renders, credits never expire and there's no subscription. Edge cache hits never reach the API, so they're free.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons