Fetch only. No browser in your deploy.

Website screenshots
from Cloudflare Workers

Workers cannot run Chromium themselves. Cloudflare's Browser Rendering product gives you a remote browser, metered by the minute and by concurrent session. A screenshot API is priced per finished image, and the Worker stays a few lines of fetch.

200 renders a month, no card.

Why Chromium does not fit in Cloudflare Workers

A Worker is a V8 isolate with a 128 MB memory ceiling and no process API, so Puppeteer cannot launch a browser inside it. Cloudflare's answer is Browser Rendering: your Worker drives a Chromium session that runs on Cloudflare's side, using @cloudflare/puppeteer or a REST endpoint.

The meter is the catch. The free plan includes 10 browser minutes a day and three concurrent browsers. Paid plans include 10 browser hours a month, then $0.09 per additional hour, plus $2.00 per additional concurrent browser above ten (averaged monthly). A screenshot of a slow marketing page can take 5 to 10 seconds of browser time, so a modest job of a few thousand captures a month moves out of the included hours quickly, and concurrency spikes are billed even when they are short. You also still own the page logic: waiting for network idle, dismissing cookie banners, handling lazy-loaded images.

With a screenshot API the Worker sends one request per image and pays per image, cached hits are free, and banner and ad blocking are on by default. The example below stores results in R2 through a bucket binding so the capture happens once per URL per day.

References: Cloudflare Browser Rendering pricing and limits, R2 Workers API (put and get).

The integration: one GET request

Call GET https://app.snap-render.com/v1/screenshot with the target in the url parameter and your key in the X-API-Key header. The response body is the finished image (or PDF when format=pdf). Ads and cookie banners are blocked by default.

Add the key as a Worker secret
npx wrangler secret put SNAPRENDER_API_KEY
# paste sk_live_... when prompted

# wrangler.toml (R2 binding for the storage example)
[[r2_buckets]]
binding = "SCREENSHOTS"
bucket_name = "screenshots"
src/index.ts
export interface Env {
  SNAPRENDER_API_KEY: string;
}

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

    const params = new URLSearchParams({
      url: target,
      format: 'jpeg',
      quality: '85',
      width: '1280',
      height: '800',
      cache: 'true',
      cache_ttl: '86400',
    });

    const upstream = await fetch(`https://app.snap-render.com/v1/screenshot?${params}`, {
      headers: { 'X-API-Key': env.SNAPRENDER_API_KEY },
    });

    if (upstream.status === 429) {
      const body: { error: { code: string; message: string } } = await upstream.json();
      return Response.json({ error: body.error.code }, { status: 429 });
    }
    if (!upstream.ok) {
      return Response.json({ error: `upstream ${upstream.status}` }, { status: 502 });
    }

    return new Response(upstream.body, {
      headers: {
        'Content-Type': 'image/jpeg',
        'Cache-Control': 'public, max-age=86400',
      },
    });
  },
};

Storing the image

R2 has no egress fees to the public internet, so serving stored screenshots from the Worker is cheap. Add an R2 lifecycle rule to expire objects after a few days if the date-prefixed keys should not accumulate.

Cache captures in R2 for a day
export interface Env {
  SNAPRENDER_API_KEY: string;
  SCREENSHOTS: R2Bucket;
}

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

    const key = `${new Date().toISOString().slice(0, 10)}/${encodeURIComponent(target)}.jpg`;

    const cached = await env.SCREENSHOTS.get(key);
    if (cached) {
      return new Response(cached.body, { headers: { 'Content-Type': 'image/jpeg', 'X-Source': 'r2' } });
    }

    const upstream = await fetch(
      `https://app.snap-render.com/v1/screenshot?url=${encodeURIComponent(target)}&format=jpeg&quality=85`,
      { headers: { 'X-API-Key': env.SNAPRENDER_API_KEY } },
    );
    if (!upstream.ok) return new Response('capture failed', { status: 502 });

    const bytes = await upstream.arrayBuffer();
    await env.SCREENSHOTS.put(key, bytes, { httpMetadata: { contentType: 'image/jpeg' } });

    return new Response(bytes, { headers: { 'Content-Type': 'image/jpeg', 'X-Source': 'api' } });
  },
};

Caching, quota, and errors

Caching

Caching is off by default. Pass cache=true and a cache_ttl in seconds (default 86400, maximum depends on plan). A repeat request for the same URL and parameters inside that window is served from the cache and does not count against your monthly quota. The X-Cache response header reports HIT or MISS, and X-Remaining-Credits shows what is left this month.

Error handling

  • 400 INVALID_URL or BLOCKED_URL: fix the input; private and local addresses are refused.
  • 401 missing or wrong X-API-Key.
  • 408 RENDER_TIMEOUT or TARGET_TIMEOUT: the page took longer than 30 seconds; retry with a simpler URL.
  • 429 RATE_LIMITED (per-minute burst, retry after a minute) or QUOTA_EXCEEDED (monthly allowance; the body names the next plan).
  • 502 RENDER_FAILED or TARGET_UNREACHABLE: the target site is down or refused the request.

Every error body is { "error": { "code", "message", "status" } }.

Full parameter list, POST with raw HTML, signed URLs, and batch jobs are in the API reference. The OpenAPI spec is at /openapi.json.

Cloudflare Workers screenshot FAQ

How much does Cloudflare Browser Rendering cost for screenshots?+

Free plans get 10 browser minutes a day and three concurrent browsers. Paid plans include 10 browser hours a month, then $0.09 per additional hour and $2.00 per additional concurrent browser above ten. Because a single capture can take several seconds of browser time, the effective per-screenshot cost depends heavily on how slow the target pages are.

Can I use the screenshot API from a Cron Trigger?+

Yes. Add a scheduled() handler to the Worker, loop over the URLs you monitor, and write each capture to R2 with the pattern above. Nothing about the API call changes.

Do cached hits count against my SnapRender quota?+

No. Requests served from the API cache (cache=true within cache_ttl) are not billed as renders. Combine that with an R2 or Cache API layer in front of the Worker and the API is called once per URL per window.

What is the difference between RATE_LIMITED and QUOTA_EXCEEDED?+

Both return HTTP 429. RATE_LIMITED is the per-minute burst limit for your plan and clears within a minute. QUOTA_EXCEEDED is the monthly render allowance; the JSON body names the next plan and its limit.

Is there a free tier?+

Yes. The free plan includes 200 renders a month with every feature enabled, and no card is required.

Other serverless runtimes

Language quickstarts: Node.js, Python, cURL. Cost comparison: Puppeteer on Lambda vs an API.

Or skip the setup entirely

Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.

200 screenshots a month free. First render in under a minute.

Keep the browser out of your deploy

Get a free API key, 200 renders a month, no card.