CaptureKit Alternative: Compare Renders Before You Switch

Evaluating a CaptureKit alternative? Map each capability to RenderScreenshot, run both APIs on your URLs with a pixel-diff script, and roll out by percentage.

By Simon Chiu · Updated

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 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

  1. Sort by verdict. Ignore match rows.
  2. Open each review pair 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).
  3. Fix every size-differs row first. It almost always means the two requests do not ask for the same viewport, scale or full-page mode.
  4. Run the script again. You are ready to move traffic when the remaining review rows 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-Used and X-Request-Id. Send Accept: application/json to 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, message and retryable. Map your current provider's error handling to retryable rather than to HTTP status codes alone.
  • Caching. Captures are cached for 24 hours by default (cache.ttl from 1 hour to 30 days). If your current integration adds a cache-busting parameter to every request, remove it and use cache.refresh only when you need a fresh render.

When to stay with CaptureKit

  • The shadow comparison shows more review rows 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.

Frequently asked questions

How do I know if RenderScreenshot renders my pages the same way as CaptureKit?
Run both on the same URL list with the shadow comparison script on this page. It saves both images, compares their sizes and counts changed pixels, so you only review pages marked review or size-differs.
Can I switch from CaptureKit gradually?
Yes. Route a percentage of URLs to RenderScreenshot by hashing the URL, so each page always goes to the same provider. Raise the percentage as the comparison stays clean.
Do I need to change my image URLs in HTML when I switch?
Only if you embed the provider URL directly in img or og:image tags. Replace those with RenderScreenshot signed URLs, or store the X-Cache-URL CDN link that each response returns.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.