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:
npm i [email protected] @sparticuz/[email protected]
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-coreupgrade without the matching@sparticuz/chromiumupgrade 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
-minpackage that downloads a pack on cold start. - Cold starts. The first invocation in a new environment decompresses Chromium into
/tmpbefore 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.
/tmpgrowth. Long-lived warm environments can fill/tmpwith 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()andconnect(). The loop above falls back tolaunch()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:
- Deploy both handlers above in the same geography (for example, Lambda in us-east-1, and call the Worker from the same client).
- Measure cold and warm separately. On Lambda, force a cold start by updating an environment variable. On Cloudflare, wait past the
keep_alivewindow. - Record total time from the client, plus the time inside the handler around
launch/connect,gotoandscreenshot. - 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.
- 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.