Switching screenshot APIs is rarely about one missing feature. It is about whether the new service renders your pages at least as well, at a price that fits your volume, without a risky cut-over. This page gives you the three tools for that decision: a capability map from CaptureKit-style options to RenderScreenshot parameters, a script that captures every URL on both services and diffs the pixels, and a rollout pattern that moves traffic one percentage point at a time.
Disclosure: I build RenderScreenshot. I have not yet run CaptureKit and RenderScreenshot side by side on a shared URL list with an account on each, so this page contains no head-to-head numbers. It gives you the method and the code to produce them on your own URLs, which is the comparison that matters for your decision. For CaptureKit's current features and prices, use CaptureKit's own site.
This page belongs to our screenshot API comparison series. If you are still choosing between several vendors, start with the weighted checklist for choosing a screenshot API.
Verdict
| Stay with CaptureKit if | Move to RenderScreenshot if |
|---|---|
| The shadow comparison shows CaptureKit renders your pages better | Renders match and you want presets, signed URLs and SDKs in six languages |
| You use CaptureKit features that we do not have (check the map below) | You want a public CDN link (X-Cache-URL) with every capture |
| Its price at your volume is lower than ours after you count retries | You want typed errors with a retryable flag to drive retries |
| Switching cost is higher than the gain | You need batch jobs, webhooks or uploads to your own bucket |
Capability map
The right-hand column is what to look up in CaptureKit's documentation for your current integration. The middle column is the RenderScreenshot parameter, as documented in our parameter reference.
| Capability | RenderScreenshot parameter | Check in CaptureKit's docs |
|---|---|---|
| Viewport size | viewport.width, viewport.height (GET: width, height) |
Viewport parameter names and limits |
| Retina / high-DPI | viewport.scale (1 to 3) |
Device scale factor support |
| Device emulation | viewport.device (for example iphone_14_pro, pixel_7) |
Device list |
| Full page | capture.mode: "full_page" (GET: full_page=true) |
Full-page flag and maximum height |
| Lazy-loaded images | capture.scroll.simulate, scroll.delay, scroll.max_height (default 16,000 px) |
Scroll or lazy-load option |
| One element | capture.mode: "element", capture.selector |
Selector capture |
| Wait for network | wait.until: "networkidle" |
Wait conditions |
| Wait for element | wait.for_selector |
Selector wait |
| Fixed delay | wait.delay (ms) |
Delay units (seconds or ms) |
| Timeout | wait.timeout (seconds, max 60) |
Timeout limits |
| Cookie banners | block.cookie_banners |
Consent handling |
| Ads, trackers, chat widgets | block.ads, block.trackers, block.chat_widgets |
Blocking options |
| Hide or remove elements | page.hide, page.remove |
CSS or selector hiding |
| Custom CSS and JS | page.styles, page.scripts |
Injection options |
| Dark mode | browser.dark_mode |
Colour scheme emulation |
| Cookies, headers, basic auth | network.cookies, network.headers, network.auth |
Auth options for private pages |
| Proxy or country | network.proxy, network.country |
Geo options |
| Formats | output.format: png, jpeg, webp, avif, pdf |
Supported formats |
| Resize output | output.resize.width, .height, .fit |
Thumbnail options |
pdf.paper, pdf.margin, pdf.header, pdf.footer |
PDF support | |
| Social card sizes | preset: "og_card" (1200x630), twitter_card (1200x600) |
Presets |
| Caching | cache.ttl (1 hour to 30 days), cache.refresh |
Cache control and whether cache hits are billed |
| Upload to your bucket | storage.enabled, storage.path |
Storage integrations |
| Async | webhook.url, batch endpoint |
Webhooks and bulk |
| Public embedding | Signed URLs with expiry (max 30 days) | Signed or public URL support |
Any row where CaptureKit has a feature we do not, and your integration uses it, is a reason to stay. Write those rows down before you run the comparison.
Pricing at 1,000, 10,000 and 100,000 captures
RenderScreenshot plans, checked 2026-10-15 on our pricing page:
| Monthly captures | Plan | Monthly price | Cost per capture at that volume |
|---|---|---|---|
| 1,000 | Starter (2,500 credits) | $10 | $0.0100 |
| 10,000 | Growth (12,000 credits) | $40 | $0.0040 |
| 100,000 | Custom (largest self-serve is Scale: 60,000 credits for $100) | Quote | Quote |
The Free plan has 150 credits a month with no credit card, enough to run the shadow comparison on a 50-URL list three times. Add CaptureKit's prices for the same three volumes from its pricing page, on the same date, and compare cost per usable capture: divide by the success rate you measure, not the list volume.
Run both providers on your URLs
The script captures each URL with your current provider and with RenderScreenshot, saves both files, and compares them with pixelmatch. Each row is one of four verdicts: match (under 1% of pixels changed), review (more), size-differs (different dimensions, usually a viewport or full-page setting that does not line up) or error.
// shadow-compare.mjs: capture each URL with your current provider and with RenderScreenshot, // then report size and pixel differences so you can review only the pages that changed. // Usage: node shadow-compare.mjs urls.txt > shadow.csv import { mkdir, readFile, writeFile } from 'node:fs/promises'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); // Your current provider: a GET URL template from its docs, with {url} and {key} placeholders. async function current(url) { const endpoint = process.env.CURRENT_TEMPLATE .replace('{url}', encodeURIComponent(url)) .replace('{key}', encodeURIComponent(process.env.CURRENT_KEY ?? '')); const res = await fetch(endpoint, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`current provider HTTP ${res.status}`); return Buffer.from(await res.arrayBuffer()); } async function candidate(url) { return client.take(TakeOptions.url(url).width(1280).height(800).format('png')); } function compare(a, b) { const imgA = PNG.sync.read(a); const imgB = PNG.sync.read(b); if (imgA.width !== imgB.width || imgA.height !== imgB.height) { return { verdict: 'size-differs', sizes: `${imgA.width}x${imgA.height} vs ${imgB.width}x${imgB.height}`, diff: '' }; } const changed = pixelmatch(imgA.data, imgB.data, null, imgA.width, imgA.height, { threshold: 0.1 }); const pct = (100 * changed) / (imgA.width * imgA.height); return { verdict: pct < 1 ? 'match' : 'review', sizes: `${imgA.width}x${imgA.height}`, diff: pct.toFixed(2) }; } const urls = (await readFile(process.argv[2] ?? 'urls.txt', 'utf8')).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); await mkdir('shadow', { recursive: true }); console.log('url,verdict,sizes,diff_pct,current_bytes,candidate_bytes'); for (const [i, url] of urls.entries()) { try { const [a, b] = await Promise.all([current(url), candidate(url)]); await writeFile(`shadow/${i}-current.png`, a); await writeFile(`shadow/${i}-candidate.png`, b); const r = compare(a, b); console.log([JSON.stringify(url), r.verdict, r.sizes, r.diff, a.length, b.length].join(',')); } catch (err) { console.log([JSON.stringify(url), 'error', '', '', '', JSON.stringify(err.message)].join(',')); process.exitCode = 1; } }
npm i renderscreenshot pixelmatch@8 pngjs@7 export RS_API_KEY=rs_live_xxxxx export CURRENT_KEY=your-capturekit-key export CURRENT_TEMPLATE='<your current GET request, with {url} and {key} in place of the values>' node shadow-compare.mjs urls.txt > shadow.csv
Set the template to the exact request your code sends today, including its viewport and format options, so you compare like with like. Both sides must produce PNG for the pixel diff.
How we tested it (2026-10-15, Node.js 22.18.0, renderscreenshot 1.1.0, pixelmatch 8.0.0, pngjs 7.0.0): we pointed both providers at local mock servers that returned real captures of example.com, to exercise all three verdicts:
| "Current" image | "Candidate" image | Verdict | Changed pixels |
|---|---|---|---|
| Chrome CLI, dark (macOS dark mode leaked in) | Playwright, light | review |
97.78% |
| Chrome CLI, light forced | Playwright, light | match |
0.00% |
| 1x1 placeholder | Playwright, 1280x800 | size-differs |
n/a |
The first row is a real-world catch, not a contrived one: headless Chrome on a Mac in dark mode renders pages that support prefers-color-scheme in dark colours. A provider switch that silently changes the colour scheme is exactly the kind of difference a diff catches and a spot check misses. The second row shows that two different Chromium front ends rendered this simple page pixel-identically at the default threshold.
Read the results
- Sort by verdict. Ignore
matchrows. - Open each
reviewpair side by side. Typical causes: a cookie banner on one side only, a font fallback, an ad, a carousel frame, a timestamp. Most are fixed with a parameter from the capability map (block.cookie_banners,wait.for_selector,page.hide). - Fix every
size-differsrow first. It almost always means the two requests do not ask for the same viewport, scale or full-page mode. - Run the script again. You are ready to move traffic when the remaining
reviewrows are differences you accept.
Roll out by percentage
Do not flip a global switch. Hash each URL to a number from 0 to 99 and send it to RenderScreenshot when the number is below your rollout percentage. The same URL always goes to the same provider, so cached images and social cards do not flicker between two renderers.
Python
"""screenshots.py: one function, two providers, with a percentage rollout by URL.""" import os import sys import zlib from urllib.parse import quote import requests TIMEOUT = 60 def _renderscreenshot(url: str, width: int, height: int) -> bytes: resp = requests.post( "https://api.renderscreenshot.com/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, json={"url": url, "viewport": {"width": width, "height": height}, "output": {"format": "png"}}, timeout=TIMEOUT, ) resp.raise_for_status() return resp.content def _current(url: str, width: int, height: int) -> bytes: # CURRENT_TEMPLATE is the GET URL from your current provider's docs, # with {url}, {key}, {width} and {height} placeholders. endpoint = os.environ["CURRENT_TEMPLATE"].format( url=quote(url, safe=""), key=os.environ.get("CURRENT_KEY", ""), width=width, height=height ) resp = requests.get(endpoint, timeout=TIMEOUT) resp.raise_for_status() return resp.content def provider_for(url: str) -> str: """The same URL always goes to the same provider, so outputs stay stable while you compare.""" rollout = int(os.environ.get("RS_ROLLOUT_PERCENT", "0")) return "renderscreenshot" if zlib.crc32(url.encode()) % 100 < rollout else "current" def screenshot(url: str, width: int = 1280, height: int = 800) -> bytes: fn = _renderscreenshot if provider_for(url) == "renderscreenshot" else _current return fn(url, width, height) if __name__ == "__main__": target = sys.argv[1] if len(sys.argv) > 1 else "https://example.com" png = screenshot(target) with open("capture.png", "wb") as f: f.write(png) print(f"{provider_for(target)}: {len(png)} bytes")
Ruby
# screenshots.rb: one method, two providers, with a percentage rollout by URL. require 'cgi' require 'net/http' require 'renderscreenshot' require 'zlib' module Screenshots RS = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) module_function def provider_for(url) rollout = Integer(ENV.fetch('RS_ROLLOUT_PERCENT', '0')) (Zlib.crc32(url) % 100) < rollout ? :renderscreenshot : :current end def capture(url, width: 1280, height: 800) if provider_for(url) == :renderscreenshot RS.take(RenderScreenshot::TakeOptions.url(url).width(width).height(height).format('png')) else current(url, width, height) end end # CURRENT_TEMPLATE is the GET URL from your current provider's docs. def current(url, width, height) endpoint = ENV.fetch('CURRENT_TEMPLATE') .sub('{url}', CGI.escape(url)) .sub('{key}', CGI.escape(ENV.fetch('CURRENT_KEY', ''))) .sub('{width}', width.to_s) .sub('{height}', height.to_s) response = Net::HTTP.get_response(URI(endpoint)) raise "current provider HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) response.body end end if $PROGRAM_NAME == __FILE__ target = ARGV.fetch(0, 'https://example.com') png = Screenshots.capture(target) File.binwrite('capture.png', png) puts "#{Screenshots.provider_for(target)}: #{png.bytesize} bytes" end
Both adapters were tested on 2026-10-15 at RS_ROLLOUT_PERCENT=0 and 100 against local mocks (Python 3.14 with requests 2.34.2; Ruby 4.0.7 with the renderscreenshot gem 1.0.0). The Node.js script above plays the same role in JavaScript.
A schedule to start from: 5% for a week while you watch error rates, 25% for a week, then 100%. Keep the old provider's key active for one more billing cycle so you can roll back by setting the percentage to 0.
Response and auth differences
- Body and metadata. RenderScreenshot returns the image bytes by default, with metadata in headers:
X-Cache-URL,X-Cache-Hit,X-Screenshot-Width,X-Screenshot-Height,X-Credits-UsedandX-Request-Id. SendAccept: application/jsonto get a JSON body with the CDN URL instead. See response formats. - Authentication. Server code uses
Authorization: Bearer rs_live_.... For images loaded by browsers or crawlers, generate signed URLs that expire within 30 days, so no key appears in HTML. - Errors. Errors are JSON with
type,code,messageandretryable. Map your current provider's error handling toretryablerather than to HTTP status codes alone. - Caching. Captures are cached for 24 hours by default (
cache.ttlfrom 1 hour to 30 days). If your current integration adds a cache-busting parameter to every request, remove it and usecache.refreshonly when you need a fresh render.
When to stay with CaptureKit
- The shadow comparison shows more
reviewrows that favour CaptureKit's output than ours, after you have tuned parameters on both sides. - You depend on a CaptureKit feature that has no row in the capability map above.
- Its price at your volume, divided by its measured success rate, is lower than ours, and the integration works.
Start the comparison
Create a free key, put 30 of your real URLs in urls.txt, and run shadow-compare.mjs. You will know within an hour whether a switch is worth planning.