Screenshot API vs Self-Hosted Puppeteer: Real Cost
A build-vs-buy cost model for screenshots: servers, Cloudflare browser time, engineering hours and API plans, with a calculator and a capacity script.
Disclosure: I build RenderScreenshot, a screenshot API. This page compares my product with two ways of not using it, and the calculator lets you change every assumption, including the ones that favour us.
Self-hosted Puppeteer looks free: Chrome is open source and a small server costs a few dollars a month. The real bill has three parts: infrastructure (servers or browser time), people (hours to build and keep it working) and the layer around the browser (caching, storage, retries, auth). This page models all three for three options: your own servers, Cloudflare Browser Run, and a hosted API. It gives you a calculator script and a second script to measure the one number nobody can give you: how many screenshots per hour your server handles on your pages.
Prices checked October 15, 2026. No capacity or memory figures on this page: the defaults in the calculator are placeholders for you to replace with your own measurements.
The three options
flowchart TB subgraph A[Self-hosted] A1[Your API] --> A2[Queue] --> A3[Chrome pool on your servers] --> A4[(Your storage)] end subgraph B[Cloudflare Browser Run] B1[Your API] --> B2[Your Worker] --> B3[Managed Chromium] --> B4[(R2)] end subgraph C[Hosted screenshot API] C1[Your code] --> C2[One HTTPS request] --> C3[Image + CDN link] end
| Cost | Self-hosted | Browser Run | Hosted API |
|---|---|---|---|
| Infrastructure | Servers sized for your peak hour, plus redundancy | Browser time ($0.09 per hour after 10 included on Workers Paid), plus concurrency for binding sessions | Plan price |
| Build | Browser pool, queue, timeouts, fonts, storage, caching, SSRF checks, API for your app | Worker, storage, caching, SSRF checks, retries | Integration only |
| Ongoing | Chrome and OS updates, leaks, zombies, font and rendering bugs, capacity planning | Platform changes, limits, the layer you built | Watching usage |
| Control | Total | High within the platform | What the API exposes |
That last row is why teams self-host at all, and it is a real reason: an exact Chrome version, extensions, Firefox or WebKit, data that must stay on your network. If you need any of those, the cost comparison is secondary.
The cost model
self_hosted = servers × server_price + maintenance_hours × rate + build_hours × rate / amortization_months browser_run = 5 + max(0, shots × seconds_per_shot / 3600 − 10) × 0.09 + (your layer's hours) × rate api = plan price for your monthly volume servers = max(redundancy_floor, ceil(peak_hour_shots / shots_per_server_hour))
Three inputs decide the answer:
shots_per_server_hour, which you measure (next section).maintenance_hours, which you estimate honestly. Look at the last three months of tickets for the thing you already run.peak_factor, the ratio of your busiest hour to an average hour. Screenshot traffic is bursty: a newsletter send, a crawler visiting every page, a nightly batch. You size servers for the peak, and pay for them all month.
Measure your server's capacity
Do not use anybody's "one server handles N screenshots" figure, including any you find on vendor blogs. Capacity depends on your pages (a text page and a 20,000 px page with lazy images differ by an order of magnitude), your wait strategy, your image format and your CPU. This script runs your URL list at increasing concurrency on the machine you plan to use:
// capacity.mjs: how many screenshots per minute one machine sustains at each // concurrency level, and how much memory Chrome uses while it does. // Node 22+, puppeteer@24, macOS or Linux (uses `ps`). Usage: // node capacity.mjs urls.txt 60 1,2,4,8 > capacity.csv import { readFile } from 'node:fs/promises'; import { execFileSync } from 'node:child_process'; import puppeteer from 'puppeteer'; const [, , urlFile = 'urls.txt', secondsArg = '60', levelsArg = '1,2,4,8'] = process.argv; const SECONDS = Number(secondsArg); const LEVELS = levelsArg.split(',').map(Number); const urls = (await readFile(urlFile, 'utf8')).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); // Resident memory of the browser process tree, in MB. function chromeRssMb(rootPid) { const table = execFileSync('ps', ['-axo', 'pid=,ppid=,rss='], { encoding: 'utf8' }) .trim().split('\n').map((l) => l.trim().split(/\s+/).map(Number)); const tree = new Set([rootPid]); let grew = true; while (grew) { grew = false; for (const [pid, ppid] of table) if (tree.has(ppid) && !tree.has(pid)) { tree.add(pid); grew = true; } } return Math.round(table.filter(([pid]) => tree.has(pid)).reduce((sum, [, , rss]) => sum + rss, 0) / 1024); } console.log('concurrency,shots,failures,shots_per_min,p50_ms,p95_ms,peak_rss_mb'); for (const level of LEVELS) { const browser = await puppeteer.launch({ headless: 'shell' }); const pid = browser.process().pid; const times = []; let failures = 0; let peak = 0; let next = 0; const start = performance.now(); const deadline = start + SECONDS * 1000; const sampler = setInterval(() => { peak = Math.max(peak, chromeRssMb(pid)); }, 500); async function worker() { while (performance.now() < deadline) { const url = urls[next++ % urls.length]; const page = await browser.newPage(); const t = performance.now(); try { await page.setViewport({ width: 1280, height: 800 }); await page.goto(url, { waitUntil: 'load', timeout: 30_000 }); await page.screenshot({ type: 'png' }); times.push(performance.now() - t); } catch { failures++; } finally { await page.close().catch(() => {}); } } } try { await Promise.all(Array.from({ length: level }, worker)); } finally { clearInterval(sampler); await browser.close(); } const elapsedMin = (performance.now() - start) / 60_000; // includes shots still finishing after the deadline times.sort((a, b) => a - b); const p = (q) => Math.round(times[Math.max(0, Math.ceil(q * times.length) - 1)] ?? 0); console.log([level, times.length, failures, (times.length / elapsedMin).toFixed(1), p(0.5), p(0.95), peak].join(',')); }
How to use the output:
- Pick the concurrency where p95 is still acceptable, not the one with the most screenshots per minute. Past the knee, extra concurrency mostly adds latency.
- Multiply that level's
shots_per_minby 60 forshots_per_server_hour, then take off a safety margin for retries and slow pages. - Check
peak_rss_mbagainst the server's memory with room for the OS and your app. Memory, not CPU, is what usually kills a Chrome host, and a leak shows up as a peak that keeps growing over a long run. Our timeouts and memory leaks guide covers the causes. - Run it on the target hardware, on an otherwise idle machine, for at least a few minutes per level. A laptop or a shared machine gives you numbers for that laptop.
The script reuses one browser and opens a new page per screenshot, which is the efficient pattern. If your design launches a browser per screenshot (for isolation), change it to match, or you will overestimate capacity. Tested on October 15, 2026 with Node.js 22.18.0 and Puppeteer 24.43.1 using chrome-headless-shell 148 on macOS, for correctness of the output only; we ran it on a shared development machine, so we do not publish those numbers.
The calculator
Every input is a flag. The defaults are placeholders, not measurements: replace --shots-per-server-hour with your result from above, and --eng-hours with your own estimate.
// tco.mjs: monthly cost of screenshots, self-hosted vs Cloudflare Browser Run vs a hosted API. // Node 22+, no dependencies. Every input is a flag; defaults are placeholders, not measurements. // node tco.mjs --shots 50000 --server-price 40 --shots-per-server-hour 600 --eng-hours 6 --eng-rate 100 import { parseArgs } from 'node:util'; const { values: v } = parseArgs({ options: { shots: { type: 'string', default: '50000' }, // screenshots per month 'peak-factor': { type: 'string', default: '3' }, // peak hour rate / average hour rate 'server-price': { type: 'string', default: '40' }, // USD per server per month 'shots-per-server-hour': { type: 'string', default: '600' }, // MEASURE THIS: capacity.mjs 'min-servers': { type: 'string', default: '2' }, // redundancy floor 'eng-hours': { type: 'string', default: '6' }, // maintenance hours per month 'eng-rate': { type: 'string', default: '100' }, // USD per engineering hour 'build-hours': { type: 'string', default: '80' }, // one-off build effort 'amortize-months': { type: 'string', default: '24' }, 'seconds-per-shot': { type: 'string', default: '4' }, // browser time per shot on Browser Run }, }); const n = Object.fromEntries(Object.entries(v).map(([k, x]) => [k, Number(x)])); // Self-hosted: size for the peak hour, never below the redundancy floor. const avgPerHour = n.shots / (30 * 24); const servers = Math.max(n['min-servers'], Math.ceil((avgPerHour * n['peak-factor']) / n['shots-per-server-hour'])); const selfHosted = { infra: servers * n['server-price'], people: n['eng-hours'] * n['eng-rate'] + (n['build-hours'] * n['eng-rate']) / n['amortize-months'], }; // Cloudflare Browser Run, Workers Paid, as listed October 15, 2026 (browser time only). const hours = (n.shots * n['seconds-per-shot']) / 3600; const browserRun = { infra: 5 + Math.max(0, hours - 10) * 0.09, // You still build caching, storage, retries and auth around it: half the self-hosted effort is a guess; change it. people: (n['eng-hours'] / 2) * n['eng-rate'] + (n['build-hours'] / 2 * n['eng-rate']) / n['amortize-months'], }; // RenderScreenshot monthly plans as listed on /pricing, October 15, 2026. const PLANS = [ { name: 'Free', price: 0, credits: 150 }, { name: 'Starter', price: 10, credits: 2_500 }, { name: 'Growth', price: 40, credits: 12_000 }, { name: 'Scale', price: 100, credits: 60_000 }, ]; const plan = PLANS.find((p) => p.credits >= n.shots); const api = plan ? { infra: plan.price, people: 0, note: `${plan.name} plan` } : { infra: NaN, people: 0, note: 'above the largest self-serve plan: ask for a quote' }; const row = (name, c, note = '') => `${name.padEnd(18)} infra ${c.infra.toFixed(2).padStart(9)} people ${c.people.toFixed(2).padStart(9)} total ${(c.infra + c.people).toFixed(2).padStart(9)} ${note}`; console.log(`${n.shots.toLocaleString('en-US')} screenshots/month, USD per month`); console.log(row('Self-hosted', selfHosted, `${servers} server(s)`)); console.log(row('Browser Run', browserRun, `${hours.toFixed(1)} browser hours`)); console.log(row('RenderScreenshot', api, api.note));
Tested on October 15, 2026 with Node.js 22.18.0. With the placeholder defaults it prints:
50,000 screenshots/month, USD per month Self-hosted infra 80.00 people 933.33 total 1013.33 2 server(s) Browser Run infra 9.10 people 466.67 total 475.77 55.6 browser hours RenderScreenshot infra 100.00 people 0.00 total 100.00 Scale plan
Do not read that output as a result. Read it as the shape of the answer, which holds across inputs:
- Infrastructure is the smallest line for the do-it-yourself options. At these placeholder inputs, Browser Run's browser time ($9.10) is cheaper than our plan ($100), and so are two servers ($80).
- People dominate. Six maintenance hours a month at $100 is $600, more than every infrastructure line combined. Run it with
--eng-hours 0 --build-hours 0and the order flips: Browser Run $9.10, self-hosted $80, API $100. That is the honest version of "self-hosting is cheaper": it is, if your time is free. - Above the largest self-serve plan, the API line becomes "ask for a quote". At 500,000 a month the script prints
NaNfor us on purpose. At that volume the build-it-yourself options get much more attractive, and you should run the numbers with a real quote.
The API line has a people cost of zero in the script, which flatters us. Integration takes a few hours, and you still handle failures and retries. Add them with your own figure if you want a strict comparison.
The hours people forget
These are the recurring jobs that make up eng-hours. Each is small; the list is the cost.
| Job | Why it recurs |
|---|---|
| Chrome and Puppeteer upgrades | Rendering and API changes; deprecated calls such as page.waitForTimeout removed in Puppeteer 22 |
| Fonts and emoji on Linux | Missing glyphs, CJK fallbacks, color emoji packages per base image |
| Memory leaks and zombie processes | Pages not closed on error paths, Chrome children that outlive Node |
| Timeouts and wait strategies | networkidle that never settles, single-page apps that load data late |
| Cookie banners and popups | New consent platforms and markup changes; see how our handler fails |
| SSRF protection | A renderer that fetches user URLs can reach your private network |
| Caching and storage | Keys, TTLs, purge, public links |
| Capacity | Bursts, queues, autoscaling, alerting |
Our own list of these, with the bugs we shipped, is in how we built RenderScreenshot. The Puppeteer-side details of pooling and Docker are in scaling Puppeteer screenshots.
When self-hosting wins
Be honest about these cases, because they are common:
- High, steady volume with an existing platform team. If someone already runs a container platform, the marginal hours are low and the volume spreads them thin.
- Requirements an API cannot meet: a pinned browser version, extensions, non-Chromium engines, data that cannot leave your network, or internal pages behind a VPN.
- Screenshots as a side effect of something larger, such as an end-to-end test suite that already runs a browser.
And when an API wins: low or bursty volume, a small team, and features you would otherwise build (caching, signed links for og:image tags, presets, cookie banner handling). If you are comparing APIs with each other rather than with self-hosting, see the screenshot API pricing comparison.
Run your own numbers
- Put 20 to 30 URLs that look like your traffic in
urls.txt. - Run
capacity.mjson the server type you would use. - Run
tco.mjswith your measured capacity, your peak factor and your hours. - For the API line, run the same URLs through a free account and check how many credits they used.
Our Puppeteer alternative page maps Puppeteer options to API parameters if you want to try the switch on a real workload.
Related in this topic
- How We Built a Screenshot API: Rails, Workers and R2 The architecture of RenderScreenshot: a Rails 8 API, a Cloudflare Worker that drives the browser, R2 storage, and the bugs and fixes that shaped it.
- Cloudflare Browser Rendering API: A Tested Tutorial (2026) Screenshots with Cloudflare Browser Rendering (now Browser Run): REST Quick Actions, the Workers binding, session reuse, limits and cost. Tested code.
- Which Websites Block Headless Chrome? A 200-Site Study We loaded the top 200 Tranco domains in three headless Chrome profiles. 15% of sites blocked or challenged default Puppeteer. Raw data and script.
- How Long Does a Screenshot Take? Latency, Phase by Phase Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.
- Blocking Cookie Banners Automatically: How It Fails How our block.cookie_banners option clicks or hides consent banners in headless Chrome, the code behind it, and eight fixtures that show where it breaks.
- Cloudflare Workers as an API Gateway for Screenshots What runs at the edge in our screenshot API today, what stays in Rails, and tested Worker code to verify HMAC-signed URLs with Web Crypto before the origin.