Screenshots & PDFs in Cloudflare Workers

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().

Your options on Cloudflare

OptionGood 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 box
Costs 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.

The Worker

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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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);
    }
  },
};

wrangler.toml

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"

Secrets and deploy

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.

Try it

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"]]}'

Gotchas

What we tested

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.

What it costs

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.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: No-code and CI integrations

All guides: guides · integrations · comparisons