Screenshot API Benchmark: Method, Corpus and Scripts
How we benchmark screenshot APIs each quarter: a public URL corpus, cold and warm runs, a visual rubric, raw CSV and a runner you can use on any vendor.
Disclosure: I build RenderScreenshot, one of the APIs this benchmark covers. That is why the method, the URL list, the runner and the raw CSV are public: you can rerun every number without trusting us, and the results tables keep the rows where we lose.
This is the home of our screenshot API benchmark. It defines what we measure, on which pages, with which settings, and publishes the scripts that produce the data. Each quarterly run adds a results section with its raw CSV. For a feature-by-feature comparison of the vendors, see the best screenshot API comparison; for how to run your own evaluation with your URLs, see how to choose a screenshot API.
Method version 1, October 15, 2026. Status of the first run: not started for hosted APIs, see "Run status" below.
What the benchmark answers
- How long does a fresh render take? Cold requests, cache defeated, p50 and p95.
- How long does a repeat request take? Warm requests, the identical call again, p50 and p95.
- How often does it work? HTTP 200 with a real image, and an image a person would accept.
- What comes back? Format, dimensions and bytes, to catch vendors whose defaults differ.
It does not answer throughput under load; that needs a different test, described in load testing a screenshot API. It does not score features or price; those are on the comparison and pricing comparison pages.
Method box
| Item | Value |
|---|---|
| Corpus | 10 public URLs in 7 categories: corpus-2026-q4.txt |
| Request | Viewport 1280×800, device scale factor 1, PNG, viewport capture (not full page), each provider's default wait, no blocking |
| Cold | First request for a URL with a unique rsbench query parameter, so no cache can answer |
| Warm | The identical request sent immediately after the cold one |
| Runs | 3 rounds per provider; within a round, every URL cold then warm |
| Order | Providers run one after another, not interleaved, to stay under each free or entry plan's rate limits |
| Client | A single client per run; location and hardware stated in each run's results |
| Timeout | 60 seconds per request on the client |
| Success | HTTP 200 and a body that starts with PNG, JPEG or WebP magic bytes |
| Visual check | Every image scored by a person with the rubric below |
| Percentiles | Nearest-rank p50 and p95 of successful requests only; failures are counted separately |
| Raw data | One CSV per run, linked from its results section |
Why these choices
- Viewport capture at scale 1. Full-page and retina captures multiply the pixels, and vendors differ in how they handle very long pages. A viewport capture isolates the render pipeline. Full page gets its own section in later runs.
- Default wait per provider. Every vendor picks a different default (
load, network idle, a fixed delay). Forcing one strategy on all of them measures our choice, not theirs. The CSV records the provider, so you can rerun with a forced wait if that is your question. - Cold and warm in separate columns. A warm request on a provider with a CDN cache measures the CDN. Both numbers matter, for different use cases: cold for "render this new page", warm for an
og:imagethat crawlers fetch repeatedly. - Failures excluded from latency. A 429 returns in milliseconds and would make an overloaded provider look fast. Success rate is its own column.
The visual rubric
| Score | Meaning |
|---|---|
| 3 | Matches what a person sees in a desktop browser at the same size |
| 2 | Small defect: a lazy image missing, a font fallback, a sticky element in the wrong place |
| 1 | Usable only after a fix: a consent banner over the content, a half-loaded app |
| 0 | Wrong: blank, error page, bot challenge, wrong page |
A provider's visual score is the mean over all successful images. Each results section says who scored the images. When two people score the same run, disagreements are settled by opening the page in a desktop browser at the same size.
The corpus
The corpus is deliberately small so that anyone can run it inside a free plan, and deliberately mixed so that one kind of page cannot decide the result:
| Category | URLs | What it stresses |
|---|---|---|
| Static, tiny | 1 | Pure overhead: launch, network, capture |
| Reference article with images | 1 | Many images, long DOM |
| Documentation | 2 | Web fonts, code blocks, moderate JavaScript |
| Text-heavy, minimal JavaScript | 1 | Fast server, little to wait for |
| Single-page app | 1 | Content rendered by JavaScript after load |
| Marketing homepages | 2 | Heavy JavaScript, animations, third-party tags |
| News | 1 | Ads, consent prompts, many requests |
| Ecommerce | 1 | Large images, third-party widgets |
Public pages change, so a quarter-to-quarter comparison of the same URL is never exact. Each run records the date and time of every request in the CSV. We will add purpose-built test pages (lazy images, a cookie banner, a delayed single-page app, a 20,000 px page) when they are hosted on a stable URL; until then the corpus stays as published.
The runner
bench.mjs sends every corpus URL to every provider you have credentials for, cold then warm, for the number of rounds you choose, and writes one CSV row per attempt. It has three kinds of provider:
selfhosted: local Chrome through Puppeteer, as a baseline. Cold launches a browser per request; warm reuses one.renderscreenshot: our API, withRS_API_KEY. Warm requests sendcache.available: true, the documented way to get a stored render back.template:<name>: any GET-style API. You give it a URL template with{url}and{key}placeholders in environment variables, so adding a vendor needs no code change.
// bench.mjs: the RenderScreenshot benchmark runner. One CSV row per attempt. // Node 22+. The self-hosted baseline needs puppeteer@24; API providers need only fetch. // // node bench.mjs --corpus corpus.txt --providers selfhosted,renderscreenshot --runs 3 > raw.csv // // Providers: // selfhosted local Chrome via Puppeteer (cold = new browser, warm = reused browser) // renderscreenshot POST /v1/screenshot with RS_API_KEY // template:<NAME> any GET API; set BENCH_<NAME>_URL to a template with {url} and {key}, // and BENCH_<NAME>_KEY to the key. Example for a vendor "acme": // BENCH_ACME_URL='https://api.acme.dev/shot?url={url}&key={key}&width=1280&height=800' import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; const { values: args } = parseArgs({ options: { corpus: { type: 'string', default: 'corpus.txt' }, providers: { type: 'string', default: 'selfhosted' }, runs: { type: 'string', default: '3' }, timeout: { type: 'string', default: '60000' }, }, }); const RUNS = Number(args.runs); const TIMEOUT = Number(args.timeout); const corpus = (await readFile(args.corpus, 'utf8')) .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); // Cache buster: a query parameter the target ignores, unique per cold attempt. const bust = (url, tag) => `${url}${url.includes('?') ? '&' : '?'}rsbench=${tag}`; function sniff(buf) { if (buf.length < 12) return 'none'; if (buf[0] === 0x89 && buf[1] === 0x50) return 'png'; if (buf[0] === 0xff && buf[1] === 0xd8) return 'jpeg'; if (buf.subarray(8, 12).toString() === 'WEBP') return 'webp'; return 'none'; } async function viaFetch(input, init) { const res = await fetch(input, { ...init, signal: AbortSignal.timeout(TIMEOUT) }); const body = Buffer.from(await res.arrayBuffer()); return { status: res.status, body, cacheHit: res.headers.get('x-cache-hit') ?? '' }; } function makeProvider(name) { if (name === 'renderscreenshot') { const key = process.env.RS_API_KEY; if (!key) return null; return { name, capture: (url, phase) => viaFetch(`${process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url, viewport: { width: 1280, height: 800, scale: 1 }, output: { format: 'png' }, // Warm runs ask for the cached copy explicitly; cold runs never get one. cache: phase === 'warm' ? { available: true } : { enabled: true }, }), }), }; } if (name.startsWith('template:')) { const id = name.slice(9).toUpperCase(); const template = process.env[`BENCH_${id}_URL`]; const key = process.env[`BENCH_${id}_KEY`]; if (!template || !key) return null; return { name, capture: (url) => viaFetch(template.replace('{url}', encodeURIComponent(url)).replace('{key}', encodeURIComponent(key))), }; } if (name === 'selfhosted') { let shared; // the warm browser return { name, async capture(url, phase) { const { default: puppeteer } = await import('puppeteer'); const cold = phase === 'cold'; const browser = cold ? await puppeteer.launch({ headless: 'shell' }) : (shared ??= await puppeteer.launch({ headless: 'shell' })); const page = await browser.newPage(); try { await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); const res = await page.goto(url, { waitUntil: 'load', timeout: TIMEOUT }); const body = Buffer.from(await page.screenshot({ type: 'png' })); return { status: res?.status() ?? 0, body, cacheHit: '' }; } finally { await page.close(); if (cold) await browser.close(); } }, close: () => shared?.close(), }; } throw new Error(`unknown provider ${name}`); } const providers = args.providers.split(',').map(makeProvider).filter(Boolean); if (providers.length === 0) { console.error('No provider has credentials. Set RS_API_KEY or BENCH_<NAME>_URL/KEY.'); process.exit(2); } const stamp = Date.now().toString(36); console.log('timestamp,provider,url,phase,run,status,ok,format,bytes,ms,cache_hit,error'); for (const p of providers) { try { for (let run = 1; run <= RUNS; run++) { for (const [i, url] of corpus.entries()) { const coldUrl = bust(url, `${stamp}-${run}-${i}`); // Cold first (fresh render), then warm: the identical request again. for (const phase of ['cold', 'warm']) { const t = performance.now(); let r = { status: 0, body: Buffer.alloc(0), cacheHit: '' }; let error = ''; try { r = await p.capture(coldUrl, phase); } catch (err) { error = err.message.split('\n')[0].replaceAll(',', ';').slice(0, 100); } const ms = Math.round(performance.now() - t); const format = sniff(r.body); const ok = r.status === 200 && format !== 'none'; console.log([new Date().toISOString(), p.name, url, phase, run, r.status, ok, format, r.body.length, ms, r.cacheHit, error].join(',')); } } } } finally { await p.close?.(); } }
And the summary that turns the raw CSV into the results table:
// bench-summary.mjs: per provider and phase, success rate, p50/p95 latency and median bytes. // node bench-summary.mjs raw.csv import { readFile } from 'node:fs/promises'; const [header, ...lines] = (await readFile(process.argv[2] ?? 'raw.csv', 'utf8')).trim().split('\n'); const cols = header.split(','); const rows = lines.map((l) => Object.fromEntries(l.split(',').map((v, i) => [cols[i], v]))); // Nearest-rank percentile: no interpolation, so every reported value was observed. const pct = (sorted, p) => sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)]; const groups = Map.groupBy(rows, (r) => `${r.provider}|${r.phase}`); console.log('| Provider | Phase | Attempts | Success | p50 ms | p95 ms | Median bytes |'); console.log('|---|---|---|---|---|---|---|'); for (const [key, g] of groups) { const [provider, phase] = key.split('|'); const ok = g.filter((r) => r.ok === 'true'); const ms = ok.map((r) => Number(r.ms)).sort((a, b) => a - b); const bytes = ok.map((r) => Number(r.bytes)).sort((a, b) => a - b); const success = ((ok.length / g.length) * 100).toFixed(1); console.log(`| ${provider} | ${phase} | ${g.length} | ${success}% | ${pct(ms, 50) ?? '-'} | ${pct(ms, 95) ?? '-'} | ${pct(bytes, 50) ?? '-'} |`); }
npm install [email protected] # only for the selfhosted baseline node bench.mjs --corpus corpus-2026-q4.txt --providers selfhosted,renderscreenshot,template:acme --runs 3 > raw.csv node bench-summary.mjs raw.csv
Tested on October 15, 2026 with Node.js 22.18.0 and Puppeteer 24.43.1: the selfhosted provider against example.com, and the renderscreenshot and template: adapters against a local mock that records request bodies and returns PNGs, to check the cold and warm bodies, the cache-hit column and the summary table. The adapters have not yet been run against live vendor APIs.
A full run is 10 URLs × 2 phases × 3 rounds = 60 requests per provider. On RenderScreenshot, the 30 cold requests use one credit each and warm hits with cache.available use none, so a run fits in the free plan.
Adding a vendor
Most screenshot APIs are a GET request with the target URL and a key in the query string. Map their parameters to the method box (1280×800, scale 1, PNG, viewport only) and set two variables:
export BENCH_ACME_URL='https://api.acme.example/take?url={url}&access_key={key}&viewport_width=1280&viewport_height=800&device_scale_factor=1&format=png' export BENCH_ACME_KEY='...' node bench.mjs --corpus corpus-2026-q4.txt --providers template:acme --runs 3 >> raw.csv
Check the first image by hand before the full run. Vendors with 2x default scale or full-page default will otherwise produce rows that look slow and heavy for reasons unrelated to speed. Our comparison page found exactly that with one vendor's defaults.
Run status
| Provider group | Status |
|---|---|
| Self-hosted baseline (Puppeteer, Playwright) | Runner tested; published single-URL figures are on the comparison page |
| RenderScreenshot | Not yet run with this method |
| Other hosted APIs | Not yet run; each needs an account on its entry plan |
The first quarterly run will publish, in one section here: the method box filled in with client location and hardware, the results table from bench-summary.mjs with cold and warm rows per provider, the visual scores, the raw CSV, and a vendor disclosure for each account we used. If RenderScreenshot is slower or fails more often than another provider on this corpus, that row stays in the table.
Until that run exists, treat any "fastest screenshot API" claim with no method box as marketing, including ours. The one-URL numbers on our comparison page are a starting point, not a ranking.
What a fair comparison needs from you
If you run this on your own URLs, the same rules apply:
- Same settings everywhere, including scale factor and capture mode.
- Cold and warm reported separately.
- At least three rounds, at different times of day if your traffic is global.
- Look at the images. A 400 ms capture of a consent wall scores 1, not 3. Our headless detection study shows how often top sites challenge automated browsers.
- Phase-level timing when results surprise you. How long a screenshot takes shows how to split a slow request into its parts.
Run it with a free key
The full corpus uses 30 credits on RenderScreenshot, inside the free plan. Create a free account, set RS_API_KEY, and run the command above alongside any vendor you are considering.
Related in this topic
- Best Screenshot API Comparison (2026): 10 Services The best screenshot APIs of 2026 compared: 10 services plus self-hosted Chrome, with a scored checklist, prices checked in October 2026 and a test script.
- Screenshot API Alternatives Alternatives to screenshot APIs, OG image tools, PDF APIs and headless browser libraries, with honest notes on when each option is the better fit.
- Screenshot API Pricing Comparison: 10 Vendors (2026) Screenshot API pricing compared at 1,000, 10,000 and 100,000 shots a month for 10 vendors, with cost per 1,000, billing-unit traps and a calculator.
- ApiFlash Alternative: Parameter Map and Migration An ApiFlash alternative with signed links and presets: full parameter map, ApiFlash pricing at three volumes, and tested Node, Python and Ruby migration code.
- Browserless Alternative for Screenshots A Browserless alternative for screenshots: browser infrastructure vs a screenshot API, cost per screenshot, and tested /screenshot migration code.
- Microlink Alternative for Screenshots: Tested Switch A Microlink alternative for screenshots: live Microlink timings, its 2x pixel-ratio default, a parameter map, pricing and migration code in 3 languages.