ScreenshotNeo

BlogHow-to

How to generate website thumbnails for a list of URLs using a Cloudflare Worker

Use Cloudflare Browser Run from a Worker to render a bounded URL list into consistent thumbnails, store each result, and handle failures and rate limits.

By the ScreenshotNeo team4 October 20269 min read

To generate website thumbnails for a list of URLs using a Cloudflare Worker, bind Cloudflare Browser Run and call its screenshot Quick Action once per URL. Validate and bound the input list, use the same viewport and image options for every capture, store each successful image, and return a separate status for every URL. For larger batches, put URLs on a Cloudflare Queue and process them asynchronously.

This guide uses the Cloudflare Worker binding and Quick Actions, which are intended for simple, stateless browser tasks. Use a Browser Session through Puppeteer, Playwright, CDP, or Stagehand when a page requires multiple navigation or interaction steps. See Cloudflare’s Browser Run documentation and Quick Actions reference.

1. Configure the Worker

Add a Browser Run binding to wrangler.toml (or the equivalent Wrangler JSON configuration):

name = "url-thumbnails"
main = "src/index.js"
compatibility_date = "2026-03-24"

[browser]
binding = "BROWSER"

[[kv_namespaces]]
binding = "THUMBNAILS"
id = "YOUR_KV_NAMESPACE_ID"

The .quickAction() method requires a compatibility date of 2026-03-24 or later. A Worker binding does not need an API token for Quick Actions. Local development of this browser binding requires remote mode, such as wrangler dev --remote. Cloudflare’s get-started guide documents the binding setup and a screenshot Worker example using KV.

Create the KV namespace and replace the placeholder ID before deploying. KV is a documented option for storing images; choose another storage system if its access patterns, object size, retention, or delivery model better fit your application.

2. Implement bounded batch capture

The endpoint below accepts JSON like {"urls":["https://example.com"]}. It allows up to 20 URLs per request, accepts only HTTP and HTTPS, rejects local and private-network hostnames, captures a 1280 × 800 viewport as WebP, stores successful image bytes in KV, and reports success or failure per input URL.

Input validation is important because a public screenshot endpoint can otherwise be abused as an open proxy or used to probe private services. The hostname checks below are a practical baseline, not a complete network-layer SSRF defense. In production, also restrict destinations to the domains your application needs whenever possible.

const MAX_URLS = 20;
const WIDTH = 1280;
const HEIGHT = 800;

function validateUrl(value) {
  if (typeof value !== "string" || value.length > 2048) {
    throw new Error("Each URL must be a string of at most 2048 characters");
  }
  const url = new URL(value);
  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error("Only http and https URLs are allowed");
  }
  if (url.username || url.password) {
    throw new Error("URLs containing credentials are not allowed");
  }
  const host = url.hostname.toLowerCase();
  if (
    host === "localhost" ||
    host.endsWith(".localhost") ||
    host.endsWith(".local") ||
    host === "::1" ||
    host.startsWith("127.") ||
    host.startsWith("10.") ||
    host.startsWith("192.168.") ||
    /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
    host === "0.0.0.0"
  ) {
    throw new Error("Local and private-network hostnames are not allowed");
  }
  return url.href;
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return json({ error: "Use POST with a JSON body containing a urls array" }, 405);
    }

    let body;
    try {
      body = await request.json();
    } catch {
      return json({ error: "Request body must be valid JSON" }, 400);
    }

    if (!body || !Array.isArray(body.urls) || body.urls.length === 0) {
      return json({ error: "urls must be a non-empty array" }, 400);
    }
    if (body.urls.length > MAX_URLS) {
      return json({ error: `At most ${MAX_URLS} URLs are allowed per request` }, 400);
    }

    const results = [];
    for (const originalUrl of body.urls) {
      let url;
      try {
        url = validateUrl(originalUrl);
      } catch (error) {
        results.push({ url: originalUrl, ok: false, error: error.message });
        continue;
      }

      try {
        const image = await env.BROWSER.quickAction("screenshot", {
          url,
          viewport: { width: WIDTH, height: HEIGHT },
          screenshotOptions: {
            type: "webp",
            fullPage: false
          }
        });
        const key = crypto.randomUUID();
        await env.THUMBNAILS.put(key, image, {
          metadata: { sourceUrl: url, createdAt: new Date().toISOString() }
        });
        results.push({
          url,
          ok: true,
          key,
          imageUrl: `/thumbnail/${key}`
        });
      } catch (error) {
        results.push({ url, ok: false, error: "Screenshot capture failed" });
      }
    }

    return json({ results });
  }
};

Quick Action option names and response details can evolve; check the current Quick Actions reference when adapting the example. In particular, pin the viewport and explicitly choose the image type and full-page behavior to keep thumbnail output predictable.

The code processes URLs sequentially. That is a conservative starting point: it limits simultaneous browser work in one request and makes individual failures easy to associate with their inputs. Increase throughput with a bounded queue consumer rather than unbounded parallel promises.

3. Return stored thumbnail images

Add a route to serve images from KV. Append this route check near the start of the fetch handler, before the POST-only check:

const match = new URL(request.url).pathname.match(/^\/thumbnail\/([a-f0-9-]+)$/i);
if (request.method === "GET" && match) {
  const image = await env.THUMBNAILS.get(match[1], "arrayBuffer");
  if (!image) return new Response("Not found", { status: 404 });
  return new Response(image, {
    headers: {
      "content-type": "image/webp",
      "cache-control": "public, max-age=3600"
    }
  });
}

In a deployed application, place this logic inside the same fetch method and make its route behavior explicit. Decide whether image URLs should be public, authenticated, or signed, and set cache headers and KV expiration to match the desired retention. Do not expose a predictable storage key if the images are private.

4. Submit a list and consume partial results

Send JSON to the deployed Worker. Each result corresponds to its input; a failed target page does not discard successful captures.

curl -X POST "https://url-thumbnails.YOUR_SUBDOMAIN.workers.dev" \
  -H "content-type: application/json" \
  --data '{"urls":["https://example.com","https://developers.cloudflare.com/"]}'

A response has this shape:

{
  "results": [
    {
      "url": "https://example.com/",
      "ok": true,
      "key": "generated-key",
      "imageUrl": "/thumbnail/generated-key"
    },
    {
      "url": "https://developers.cloudflare.com/",
      "ok": false,
      "error": "Screenshot capture failed"
    }
  ]
}

For larger or variable workloads, accept the list, enqueue one URL per task, and return a job identifier. A queue consumer can capture and store each URL independently, retry transient failures, and persist terminal statuses. Cloudflare’s Browser Run FAQ specifically describes Cloudflare Queues for asynchronous URL batches. Keep queue concurrency bounded by your account’s current limits.

5. Choose thumbnail capture settings

Decision Use Trade-off
Viewport Set a fixed width and height for every URL. Different dimensions create inconsistent cards and may change responsive layouts.
Visible viewport or full page Use visible viewport for a compact preview; use full-page capture when the whole document is needed. Full-page images can be much taller and use more browser time and storage.
Image type WebP or JPEG can reduce thumbnail bytes; PNG preserves lossless detail. Format support and quality options depend on the current screenshot API options. Cloudflare documents that quality is incompatible with PNG and requires a supported lossy format.
Sharpness Use a device scale factor when a higher-density capture is needed. Higher pixel dimensions increase output size and processing work.
Selector capture Capture a particular element when the thumbnail represents a component rather than a page. The selector must exist and be visible after the page loads.

The screenshot action documents a default viewport of 1920 × 1080, full-page capture, image type, selector capture, and deviceScaleFactor. Set the values you rely on instead of depending on defaults; see the screenshot options.

6. Validate, secure, and make the workflow reliable

  • Bound request size and URL count. Enforce a maximum list length and body size. Return a clear client error before starting browser work.
  • Constrain destinations. Allow only expected schemes and, where practical, an explicit hostname allowlist. Reject credentials and internal destinations. DNS can resolve a public-looking hostname to a private address, so application checks alone are not a complete SSRF boundary.
  • Keep results independent. Store the original input or a stable job ID, status, storage key, and sanitized failure category for every URL. Avoid returning raw browser exceptions to untrusted callers.
  • Retry selectively. Retry transient rate limits and temporary navigation failures with exponential backoff and jitter. Do not repeatedly retry invalid URLs, unsupported schemes, or policy rejections.
  • Make storage writes repeatable. Use a deterministic key derived from a normalized URL plus capture settings if the latest thumbnail should replace the previous one; use unique keys if each capture is an immutable snapshot.
  • Set retention deliberately. Expire old thumbnails or remove them when their source record is deleted. Do not assume a cached page image is safe to retain indefinitely.
  • Protect the endpoint. Apply authentication or caller-level quotas if arbitrary users can submit URLs. A screenshot worker can consume browser time and create unwanted outbound requests.

7. Performance, limits, and cost

Browser time depends on the pages being rendered and the capture settings. Script-heavy pages, slow resources, long full-page captures, and high-resolution output can take longer or produce larger files. Measure representative pages in your own workload; this guide does not claim a benchmark.

Cloudflare’s current limits documentation lists Workers Free at 10 minutes of Browser Run time per day and one Quick Actions request every 10 seconds. Workers Paid defaults to 30 Quick Actions requests per second. These request-rate limits are distinct from Browser Sessions concurrency. Pricing documentation lists 10 browser hours per month included on Workers Paid and $0.09 per additional browser hour; Quick Actions are charged for browser hours only, and browser hours are shared across methods. These allowances and prices can change, so verify the current limits and pricing before estimating a production bill.

Track the X-Browser-Ms-Used response header or use the Cloudflare dashboard to understand browser time consumption, as described in the FAQ. On 429 responses, slow queue consumption and retry after backoff. Estimate monthly usage from representative pages and expected capture frequency, then include retries and full-page or high-density captures in the estimate.

8. Troubleshooting

Symptom Likely cause Fix
quickAction is not a function or binding unavailable Compatibility date is too old, the binding name differs, or local development is not remote. Use compatibility date 2026-03-24 or later, confirm the BROWSER binding name, and run local development with remote mode.
Rate-limit response or intermittent 429 Requests exceed the current Quick Actions rate limit. Reduce queue concurrency, add exponential backoff with jitter, and honor the current plan limits.
One URL fails while others succeed The target may be unavailable, slow, protected by bot controls, or dependent on resources that did not load. Keep per-URL status, retry only transient failures, and inspect the target in a browser session if the capture needs interactions or authentication.
Thumbnail is blank or incomplete The page may render content after navigation, require client-side interaction, or defer images until scroll. Use Browser Sessions for multi-step automation, or adjust the capture workflow to wait for page readiness. Check the target’s access and rendering behavior.
Images look inconsistent Viewport, device scale, full-page setting, or format varies. Pin the same viewport and screenshot options for all jobs and include them in any cache key.
KV image route returns 404 The key was not stored, expired, or the route is parsing a different key. Check the capture result before returning an image URL, verify namespace binding and key handling, and set a retention period appropriate to the product.
Unexpected access to internal hosts URL validation is incomplete or a hostname resolves to an internal address. Use a strict destination allowlist and network-level egress controls where available; do not rely only on string checks of hostnames.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request can return a PNG, JPEG, WebP, or PDF. Its screenshot API handles rendering and offers capture options including full-page shots, viewport and device presets, element capture, custom CSS and JavaScript, waits, and request blocking. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners are accepted before capture, and more than 60 known consent platforms, newsletter popups, and chat widgets can be removed; each step can be turned off.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers report the page verdict and billing status.
  • An MCP server gives AI agents tools for screenshots, page information, and PDF capture.
  • 1,000 screenshots per month are free with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan.

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

FAQ

Can the Worker return all images directly in one response?

It can, but large binary responses are awkward to consume and can become large quickly. Returning storage keys or image URLs lets clients fetch individual thumbnails and makes partial failures easier to handle.

Should I capture the full page for a thumbnail?

Usually a fixed visible viewport is a better thumbnail because it produces a consistent, compact preview. Choose full-page capture when the entire page itself is the required artifact.

When should I switch from Quick Actions to a Browser Session?

Use a session when capture requires navigation steps, clicks, login state, or more complex browser automation. A direct URL-to-image task is a Quick Actions fit.