Cloudflare Browser Rendering vs Lambda Chromium

Cloudflare Browser Rendering vs Chromium on AWS Lambda for screenshots: packaging, fonts, cold starts, limits, cost at 1k to 100k captures, code for both.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot. Our renderer runs on Cloudflare Browser Rendering, and before that we evaluated Chromium on Lambda. The Cloudflare notes below come from running it in production. The Lambda handler was tested locally against real Chrome but not deployed for this page; prices come from Cloudflare's and AWS's pricing pages as read on 2026-10-15.

Short answer: Cloudflare Browser Rendering gives you a managed Chromium with fonts, emoji and CJK already installed, called from a Worker or a REST endpoint, billed by browser hours plus concurrency. Lambda gives you a Chromium you package yourself with @sparticuz/chromium, running in your AWS account and VPC, billed by GB-seconds. Lambda wins on raw compute cost and on access to private networks. Cloudflare wins on setup time, font coverage and not having to track a Chromium binary against your Puppeteer version.

Cloudflare's docs now call the product Browser Run. If you are comparing Cloudflare against hosted browser vendors instead of Lambda, read Browserless vs Cloudflare Browser Rendering. More pairs are in the comparison hub.

The core difference

On Lambda, you own the browser. You ship a compressed Chromium (the @sparticuz/chromium package or a layer), it decompresses into /tmp on a cold start, and your function launches it. You pick the memory size, the timeout, the fonts, the region and the VPC. You also own the version matching, the package size limits and the cold starts.

On Cloudflare, you rent the browser. A Worker gets a browser binding and calls puppeteer.launch(env.MYBROWSER), or you POST to a REST endpoint. Cloudflare runs Chromium, its fonts and its updates. You live with its limits: the font list, the launch rate, and the fact that the browser is outside your network.

Side by side

Cloudflare Browser Rendering AWS Lambda + @sparticuz/chromium
Where your code runs A Worker (or any HTTP client for REST) A Lambda function, Node.js 22 runtime
Browser Managed Chromium Chromium built for Lambda, shipped by you
Packaging None chromium.br is over 50 MB; use a layer or @sparticuz/chromium-min with a remote pack
CPU architecture Managed The npm package is x86_64 only; arm64 needs the -min package plus a layer or pack
Fonts Common system fonts, Open Sans, Roboto, DejaVu, Liberation, Noto CJK, Noto Color Emoji, Arabic, Indic, Thai Open Sans only (Latin, Greek, Cyrillic); add others in a layer
Memory Managed You choose; the package README asks for 512 MB minimum and recommends 1,600 MB or more
Session lifetime 60 s idle timeout by default, keep_alive up to 20 min Up to the function timeout (max 15 min)
Reuse between requests sessions() + connect() + disconnect() Keep the browser in a module variable while the environment is warm
Concurrency Up to 200 browsers per account on Workers Paid; 3 new browsers per second Your Lambda concurrency quota; one browser per execution environment
Private network access No Yes, with a VPC-attached function
Bot identification Cloudflare states requests are always identified as a bot Looks like any headless Chromium from an AWS IP
Output storage R2 (no egress fees) S3

Sources: Cloudflare limits, Cloudflare pricing, Cloudflare supported fonts, the @sparticuz/chromium README, AWS Lambda pricing.

Code: the same capture on each platform

AWS Lambda

Pin the pair first. @sparticuz/chromium follows Chromium's version numbers, and Puppeteer expects a specific Chromium. On 2026-10-15 the newest @sparticuz/chromium was 153.0.0, and the Puppeteer release that targets Chrome 153 is 25.11.0:

handler.mjs:

// AWS Lambda (Node.js 22, x86_64): screenshot a URL with puppeteer-core + @sparticuz/chromium.
// Pin puppeteer-core to the release that targets the same Chromium major as @sparticuz/chromium.
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});
let browserPromise; // reused while the execution environment stays warm

async function getBrowser() {
  // A crashed browser from an earlier invocation must not be reused.
  if (browserPromise && !(await browserPromise.catch(() => null))?.connected) browserPromise = undefined;
  if (!browserPromise) {
    const local = process.env.IS_LOCAL === '1';
    browserPromise = puppeteer.launch({
      args: local ? [] : await puppeteer.defaultArgs({ args: chromium.args, headless: 'shell' }),
      executablePath: local ? process.env.CHROME_PATH : await chromium.executablePath(),
      headless: local ? true : 'shell',
      defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
    }).catch((err) => { browserPromise = undefined; throw err; });
  }
  return browserPromise;
}

export const handler = async (event) => {
  const target = event.queryStringParameters?.url;
  if (!target || !/^https?:\/\//.test(target)) {
    return { statusCode: 400, body: 'Pass ?url=https://...' };
  }

  const browser = await getBrowser();
  const page = await browser.newPage();
  try {
    await page.goto(target, { waitUntil: 'networkidle2', timeout: 25_000 });
    const png = await page.screenshot({ type: 'png' });

    if (!process.env.BUCKET) {
      return { statusCode: 200, isBase64Encoded: true, headers: { 'content-type': 'image/png' }, body: Buffer.from(png).toString('base64') };
    }
    const key = `shots/${Date.now()}-${Math.random().toString(36).slice(2)}.png`;
    await s3.send(new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, Body: png, ContentType: 'image/png' }));
    return { statusCode: 200, body: JSON.stringify({ key, bytes: png.length }) };
  } finally {
    await page.close(); // keep the browser for the next warm invocation
  }
};

Deploy it with 1,600 MB of memory and a 30-second timeout behind a function URL or API Gateway. Set IS_LOCAL=1 and CHROME_PATH to a local Chrome to run the same handler on your laptop; that is how we tested it (three invocations of example.com returned 200 with an 81 KB PNG; the warm invocations took about 1 second each). For the deployment steps, layers and the arm64 route, see Puppeteer on AWS Lambda.

Cloudflare Workers

wrangler.toml needs a browser binding and, for storage, an R2 bucket:

name = "capture"
main = "src/index.ts"
compatibility_date = "2026-10-01"

browser = { binding = "MYBROWSER" }

[[r2_buckets]]
binding = "SHOTS"
bucket_name = "screenshots"

src/index.ts, with session reuse, which is the Cloudflare equivalent of keeping the browser in a module variable on Lambda:

// Cloudflare Worker: screenshot a URL, reusing an idle browser session when one exists.
import puppeteer, { type Browser } from '@cloudflare/puppeteer';

interface Env {
  MYBROWSER: Fetcher;
  SHOTS: R2Bucket;
}

async function getBrowser(env: Env): Promise<Browser> {
  const idle = (await puppeteer.sessions(env.MYBROWSER)).filter((s) => !s.connectionId);
  for (const s of idle) {
    try {
      return await puppeteer.connect(env.MYBROWSER, s.sessionId);
    } catch {
      // The session closed between listing and connecting; try the next one.
    }
  }
  return puppeteer.launch(env.MYBROWSER, { keep_alive: 120_000 }); // idle sessions live 2 min
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const target = new URL(request.url).searchParams.get('url');
    if (!target || !/^https?:\/\//.test(target)) {
      return new Response('Pass ?url=https://...', { status: 400 });
    }

    const browser = await getBrowser(env);
    const page = await browser.newPage();
    try {
      await page.setViewport({ width: 1280, height: 800 });
      await page.goto(target, { waitUntil: 'networkidle2', timeout: 25_000 });
      const png = await page.screenshot({ type: 'png' });
      const key = `shots/${crypto.randomUUID()}.png`;
      await env.SHOTS.put(key, png, { httpMetadata: { contentType: 'image/png' } });
      return Response.json({ key, bytes: png.byteLength });
    } finally {
      await page.close();
      await browser.disconnect(); // leave the session warm for the next request
    }
  },
};

This compiles against @cloudflare/[email protected] and the current Workers types; we did not deploy this exact file for the page. A longer keep_alive keeps sessions warm through quiet periods but bills idle browser time, so match it to your traffic gaps. Both handlers above accept any URL: put authentication in front before you expose either one.

The problems you will hit on each

Lambda:

  • Version drift. A puppeteer-core upgrade without the matching @sparticuz/chromium upgrade can break at launch. Upgrade them together and pin both.
  • Package size. The compressed Chromium is too large for a direct zip upload, so you need a layer, a container image, or the -min package that downloads a pack on cold start.
  • Cold starts. The first invocation in a new environment decompresses Chromium into /tmp before it can launch. Our local test cannot show this cost; measure it in your region with your memory size.
  • Missing glyphs. With only Open Sans, emoji render as boxes and Chinese, Japanese or Korean text disappears. Add a fonts layer. Our guide to emoji and fonts in headless Chrome on Linux shows the files to ship.
  • /tmp growth. Long-lived warm environments can fill /tmp with browser profiles; the README documents this for Playwright and recommends a unique user data directory per run.

Cloudflare:

  • Launch rate before concurrency. In our production traffic, bursts hit the new-browser limit and returned HTTP 429 before we reached the concurrency cap. Reuse sessions and queue bursts.
  • Sessions disappear. A session you were about to reuse can close between sessions() and connect(). The loop above falls back to launch() for that reason.
  • Bot detection. Cloudflare marks its own browser traffic as a bot. Sites with aggressive bot protection may block it where a Lambda IP is not blocked, or the reverse. Test your real targets.
  • No private network. Staging sites and internal dashboards behind a VPN or VPC are out of reach.

Our Cloudflare Browser Rendering tutorial has the longer production write-up.

Cost at three volumes

Assumptions, stated so you can change them: 3 seconds of browser time per capture, one capture per invocation, 1,600 MB of Lambda memory on x86_64 in US East (N. Virginia). These are planning numbers, not measurements of either platform.

Captures per month Lambda GB-seconds Lambda cost after free tier Cloudflare browser hours Cloudflare cost (Workers Paid)
1,000 4,688 $0 0.8 $0 (inside 10 included hours)
10,000 46,875 $0 8.3 $0 (inside 10 included hours)
100,000 468,750 about $1.15 83 about $6.57, plus concurrency above 10

How the numbers are built, using list prices read on 2026-10-15:

  • Lambda: 1.5625 GB x 3 s = 4.69 GB-seconds per capture. The free tier includes 400,000 GB-seconds and 1 million requests per month. Beyond that, x86 duration is $0.0000166667 per GB-second (arm64 is $0.0000133334) and requests are $0.20 per million. At 100,000 captures: (468,750 - 400,000) x $0.0000166667 = about $1.15. Cold starts, S3 storage, data transfer and, in a VPC, a NAT gateway add to this.
  • Cloudflare: 10 browser hours per month are included on Workers Paid, then $0.09 per hour, so 73 extra hours cost about $6.57. Puppeteer sessions also bill concurrent browsers: 10 (averaged over the month's daily peaks) are included, then $2.00 each. This line excludes the Workers Paid subscription itself.

On compute alone, Lambda is cheaper at every volume here. The difference is a few dollars a month at 100,000 captures. The engineering time for packaging, fonts, version pinning and cold-start tuning is usually the larger cost, so decide on the operational fit first.

How to benchmark the two

We have not published a timed head-to-head for this pair. To run one that means something:

  1. Deploy both handlers above in the same geography (for example, Lambda in us-east-1, and call the Worker from the same client).
  2. Measure cold and warm separately. On Lambda, force a cold start by updating an environment variable. On Cloudflare, wait past the keep_alive window.
  3. Record total time from the client, plus the time inside the handler around launch/connect, goto and screenshot.
  4. Run at least 5 captures per URL for each state, on a static page, a JavaScript-heavy page, and a page with emoji and CJK text, and compare the images, not just the times.
  5. Report median and p95, the date, the region and the memory size.

Decision table

Situation Choose
Your app and data are already in AWS, or you must capture private hosts Lambda
You want the lowest compute bill at high volume and can own the packaging Lambda
You need CJK, emoji and common fonts with no extra work Cloudflare
You already deploy Workers and store files in R2 Cloudflare
You do not want to track Chromium versions Cloudflare
Traffic is very bursty Lambda scales out per request; Cloudflare needs session reuse and queuing to stay under launch limits

Or skip the browser

If your goal is a screenshot and not a browser platform, RenderScreenshot runs the Cloudflare side of this page for you, with caching, retries, cookie-banner blocking and CDN links on top:

curl -o example.png \
  -H "Authorization: Bearer $RS_API_KEY" \
  "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800"

From a Lambda function or a Worker, that is one fetch instead of a browser. See the Node SDK, or get a free API key and compare the output with your own handler.

Last verified: 2026-10-15 (versions, limits and prices). Lambda handler tested locally with Chrome 154; not deployed for this page.

Frequently asked questions

Is AWS Lambda or Cloudflare Browser Rendering cheaper for screenshots?
On list prices, Lambda compute for a few seconds of Chromium costs a fraction of a cent per capture and has a monthly free tier. Cloudflare bills browser hours plus concurrent browsers. Lambda is usually cheaper on compute alone; Cloudflare removes the packaging and font work.
Which version of @sparticuz/chromium do I need?
Match the Chromium major version that your puppeteer-core release targets. For example, puppeteer-core 25.11.0 targets Chrome 153, which pairs with @sparticuz/chromium 153.0.0.
Why are emoji and Chinese characters missing in my Lambda screenshots?
The Lambda runtime has no fonts, and @sparticuz/chromium ships only Open Sans. Add font files, for example Noto Color Emoji and Noto CJK, in a Lambda layer under /opt/fonts.
Can Cloudflare Browser Rendering reach pages inside my VPC?
Not directly; it browses from Cloudflare's network. A Lambda function attached to your VPC can reach private hosts, which matters for internal dashboards and staging sites.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.