Selenium Alternative for Screenshots: Measured Migration

A Selenium alternative for screenshot jobs: what a Selenium capture costs in time and memory, the viewport and dark-mode traps, and API code in 3 languages.

By Simon Chiu · Updated

Selenium is a testing tool that many teams also use as a screenshot service. For test suites that click and assert, it is fine. For jobs whose only output is an image, it brings a browser, a driver and a set of traps that have nothing to do with the image you want. We measured a minimal Selenium screenshot job on 2026-10-15: about 1.3 to 1.8 seconds per capture, 8 processes holding up to about 995 MiB of resident memory, a viewport 143 pixels shorter than the window size suggested, and dark colours on a Mac in dark mode. Replacing that job with an API call removes the browser and the traps. This page shows the measurements, the fixes if you stay, and the migration code in Python, Node.js and Ruby.

Disclosure: I build RenderScreenshot. Selenium is free and remains the right tool for browser tests; the last section says when to keep it.

Part of our screenshot API comparison series. For the full Selenium tutorial, see Selenium screenshots; for the test-automation question, see Playwright vs Selenium.

Verdict

Keep Selenium if Move screenshot jobs to RenderScreenshot if
The screenshot is part of an end-to-end test that clicks, types and asserts The job's only output is an image or a PDF
You need the exact browser your users run, including Firefox or Safari via Grid Chromium output is what you need
The pages are on a private network only your runners reach The pages are public, or reachable with cookies or headers
You already run a Selenium Grid with spare capacity You do not want to run Chrome, drivers or a Grid for captures

What a Selenium screenshot job costs, measured

Method: Apple M1 Pro, 32 GB, macOS 27.0.1; Python 3.14 with Selenium 4.51.0; Chrome 155.0.8059.40 with chromedriver 155.0.8059.79 fetched by Selenium Manager; target https://example.com; --headless=new and --window-size=1280,800; four runs, each starting a fresh browser. Times are wall-clock from perf_counter() around each step.

Run Start driver + browser Navigate Viewport screenshot Full page via DevTools Quit Total
1 1,470 ms 178 ms 42 ms 56 ms 86 ms 1,832 ms
2 1,063 ms 229 ms 71 ms 173 ms 81 ms 1,617 ms
3 869 ms 237 ms 60 ms 51 ms 117 ms 1,336 ms
4 769 ms 198 ms 91 ms 103 ms 90 ms 1,252 ms

Starting the driver and the browser took 61% to 80% of each job. Reusing one session across many captures cuts that, but then you own session health: crashed tabs, memory growth and zombie processes.

Memory and disk:

  • With the page loaded, chromedriver and Chrome ran as 8 processes with a combined resident set of 994 to 997 MiB across three runs (sum of RSS from psutil; shared pages are counted more than once, so the real footprint is lower, but this is the number that shows up in naive container metrics).
  • Selenium Manager's chromedriver cache took 117 MB on disk, on top of the Chrome install.

With an API, the machine that runs the job holds an HTTP client. The browser, its memory and its updates are on the provider's side.

Two traps we hit in a 20-line script

The window is not the viewport

--window-size=1280,800  →  window.innerWidth x innerHeight = 1280 x 657

--window-size sizes the browser window, including its frame. The page got a 1280x657 viewport, and save_screenshot() produced a 1280x657 PNG, in both the Python and the Node.js bindings. Anything that depends on a fixed size (social cards, visual baselines, thumbnails) comes out wrong.

The OS theme leaks into headless Chrome

On a Mac in dark mode, matchMedia('(prefers-color-scheme: dark)').matches returned true inside headless Chrome, and example.com rendered with a dark background. The same script on a Linux CI runner renders light. Same code, different screenshots, depending on the host.

Selenium capture of example.com that is 1280 by 657 pixels with a dark background, caused by window size and the macOS dark theme
Selenium 4.51.0, Chrome 155, --headless=new --window-size=1280,800, macOS in dark mode, 2026-10-15: 1280x657 PNG, 73,556 bytes.
Selenium capture of example.com at the intended 1280 by 800 pixels with a light background after both fixes
Same machine with Emulation.setDeviceMetricsOverride (1280x800) and Emulation.setEmulatedMedia (light): 1280x800 PNG.

If you keep Selenium: the fixed script

"""selenium_fixed.py: Selenium screenshot with a true 1280x800 viewport, light scheme and memory reading."""
import sys

import psutil
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

URL = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"

options = Options()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
try:
    # Window size includes browser UI; set the viewport itself instead.
    driver.execute_cdp_cmd(
        "Emulation.setDeviceMetricsOverride",
        {"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False},
    )
    # Headless Chrome follows the OS theme; pin it so output does not depend on the host.
    driver.execute_cdp_cmd(
        "Emulation.setEmulatedMedia",
        {"features": [{"name": "prefers-color-scheme", "value": "light"}]},
    )
    driver.get(URL)
    driver.save_screenshot("selenium-fixed.png")
    print("viewport:", driver.execute_script("return [innerWidth, innerHeight]"))

    # Resident memory of chromedriver plus every Chrome process it started.
    root = psutil.Process(driver.service.process.pid)
    procs = [root] + root.children(recursive=True)
    rss = sum(p.memory_info().rss for p in procs if p.is_running())
    print(f"processes: {len(procs)}, rss: {rss / 1024 / 1024:.0f} MiB")
finally:
    driver.quit()

Tested on 2026-10-15 (Selenium 4.51.0, psutil from PyPI, Python 3.14): it printed viewport: [1280, 800] and processes: 8, rss: 994 MiB to 997 MiB across three runs. Note that execute_cdp_cmd works only with Chromium-based drivers; on Firefox you would use its own full-page and window APIs.

For full-page captures in Chrome, Selenium's standard WebDriver has no option; call Page.captureScreenshot with captureBeyondViewport: True through execute_cdp_cmd, as our Selenium full-page guide shows.

Selenium to RenderScreenshot: mapping

Selenium (Chrome) RenderScreenshot Notes
webdriver.Chrome(options) + driver.quit() Not needed No local browser
--window-size=W,H (window) viewport.width, viewport.height (the real viewport) No frame offset
Emulation.setDeviceMetricsOverride with deviceScaleFactor viewport.scale (1 to 3)
Mobile emulation options viewport.device (for example iphone_14_pro) See devices
driver.get(url) + implicit or explicit waits wait.until, wait.for_selector, wait.delay
WebDriverWait(...).until(visibility_of_element_located) wait.for_selector with for_selector_mode: "visible"
set_page_load_timeout(30) wait.timeout: 30 Seconds, max 60
driver.save_screenshot() Response body PNG by default
Page.captureScreenshot + captureBeyondViewport capture.mode: "full_page" Add capture.scroll.simulate for lazy images
element.screenshot() capture.mode: "element", capture.selector
Emulation.setEmulatedMedia (colour scheme) browser.dark_mode: true or false
driver.add_cookie() (after a first page load) network.cookies (set before navigation) No dummy first load
driver.execute_script("...remove()") page.remove, page.hide, page.scripts
Clicking the cookie banner block.cookie_banners
--user-agent=... browser.user_agent
Selenium Grid for parallelism Batch endpoint or parallel requests Within your plan's concurrency

Parameter details are in the viewport, wait and capture references.

Migrate: Python

The API version pins the viewport and the colour scheme in the request, and retries only when the error says it is retryable, waiting for Retry-After on rate limits:

"""capture_api.py: the Selenium screenshot job as an API call, retried only when the error says so."""
import os
import sys
import time

import requests

API = "https://api.renderscreenshot.com/v1/screenshot"


def capture(url: str, full_page: bool = False, attempts: int = 3) -> bytes:
    body = {
        "url": url,
        "viewport": {"width": 1280, "height": 800},  # the real viewport, not the window
        "browser": {"dark_mode": False},             # same output on every host
        "output": {"format": "png"},
    }
    if full_page:
        body["capture"] = {"mode": "full_page", "scroll": {"simulate": True}}

    for attempt in range(1, attempts + 1):
        resp = requests.post(
            API,
            headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"},
            json=body,
            timeout=90,
        )
        if resp.status_code == 200:
            return resp.content
        error = resp.json().get("error", {})
        if not error.get("retryable") or attempt == attempts:
            raise RuntimeError(f"{error.get('code')}: {error.get('message')}")
        time.sleep(int(resp.headers.get("Retry-After", 2**attempt)))
    raise AssertionError("unreachable")


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"
    with open("viewport.png", "wb") as f:
        f.write(capture(target))
    with open("full.png", "wb") as f:
        f.write(capture(target, full_page=True))
    print("saved viewport.png and full.png")

Migrate: Node.js

The Node.js Selenium job being replaced:

// selenium-before.mjs: the Node.js Selenium screenshot job being replaced.
import { writeFile } from 'node:fs/promises';
import { Builder } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';

const options = new chrome.Options().addArguments('--headless=new', '--window-size=1280,800');
const driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build();
try {
  await driver.get(process.argv[2] ?? 'https://example.com');
  await writeFile('selenium.png', await driver.takeScreenshot(), 'base64');
  console.log('viewport:', await driver.executeScript('return [innerWidth, innerHeight]'));
} finally {
  await driver.quit();
}

It printed viewport: [ 1280, 657 ] with selenium-webdriver 4.51.0, the same trap as in Python. The replacement captures the viewport, the full page and one element in parallel, three things that take three different code paths in Selenium:

// capture-api.mjs: the Node.js Selenium job, plus the full-page and element captures Selenium makes hard.
import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const url = process.argv[2] ?? 'https://example.com';
const base = () => TakeOptions.url(url).width(1280).height(800).format('png');

try {
  const [viewport, full, element] = await Promise.all([
    client.take(base()),
    client.take(base().fullPage()),
    client.take(base().element('body > p')),
  ]);
  await writeFile('viewport.png', viewport);
  await writeFile('full.png', full);
  await writeFile('element.png', element);
  console.log('saved viewport.png, full.png and element.png');
} catch (err) {
  console.error(`capture failed: ${err.code ?? ''} ${err.message}`);
  process.exitCode = 1;
}

Migrate: Ruby

# capture_api.rb: the Selenium job in Ruby, waiting out rate limits with Retry-After.
require 'renderscreenshot'

client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
url = ARGV.fetch(0, 'https://example.com')
options = RenderScreenshot::TakeOptions.url(url).width(1280).height(800).full_page.format('png')

attempts = 0
begin
  attempts += 1
  File.binwrite('full.png', client.take(options))
  puts 'saved full.png'
rescue RenderScreenshot::RateLimitError => e
  abort "rate limited after #{attempts} attempts" if attempts >= 3

  sleep(e.retry_after || 2**attempts)
  retry
rescue RenderScreenshot::Error => e
  abort "capture failed: #{e.code} (retryable: #{e.retryable?})"
end

Tested on 2026-10-15 against a local mock of the RenderScreenshot API: Python 3.14 with requests 2.34.2, Node.js 22.18.0 with renderscreenshot 1.1.0, Ruby 4.0.7 with the renderscreenshot gem 1.0.0. We also checked the retry path: with the mock answering HTTP 429, retryable: true and Retry-After: 1, the Python and Ruby versions each made three attempts, about one second apart, then exited with an error. The mock checks request shape and error handling; it does not measure our rendering.

Running both during the switch

  1. Pick one screenshot job that has no assertions attached (a nightly capture, a thumbnail generator).
  2. Run the Selenium job and the API job on the same URLs for a few days and compare the files. Expect differences where the Selenium job had the viewport and dark-mode issues above.
  3. Switch the job, then remove its browser and driver from that runner's image.
  4. Repeat per job. Leave test suites on Selenium (or plan a separate move to Playwright).

Cost at 1,000, 10,000 and 100,000 captures

Selenium is free; its cost is the runner capacity for a browser per concurrent capture and the time to keep Chrome, the driver and Grid healthy. RenderScreenshot plans, checked 2026-10-15 (see pricing):

Captures per month Plan Price
1,000 Starter (2,500 credits) $10
10,000 Growth (12,000 credits) $40
100,000 Above Scale (60,000 credits for $100): custom plan Quote

The Free plan (150 credits a month, no card) covers the side-by-side comparison in step 2.

When to keep Selenium

  • The screenshot is evidence in an end-to-end test that also clicks, types and asserts.
  • You must capture with Firefox, Safari or Edge, not only Chromium.
  • The pages are only reachable from inside your network.
  • You already run a Grid with spare capacity, and capture volumes are small.

Move one job today

Create a free key, run capture_api.py on the URLs your Selenium job captures, and compare the output side by side. Check the image sizes first: the 1280x657 problem may already be in your archive.

Frequently asked questions

Why is my Selenium screenshot smaller than the window size I set?
--window-size sets the browser window, including its frame, not the page viewport. In our test, a 1280x800 window gave a 1280x657 viewport in headless Chrome. Set the viewport with the DevTools Emulation.setDeviceMetricsOverride command, or ask a screenshot API for viewport.width and viewport.height directly.
Can Selenium take a full-page screenshot in Chrome?
Not through standard WebDriver. Firefox has a full-page method; for Chrome you call the DevTools command Page.captureScreenshot with captureBeyondViewport true, or use a screenshot API with a full-page option.
Should I replace Selenium entirely?
Replace it for jobs that only capture pages. Keep Selenium, or move to Playwright, for end-to-end tests that click, type and assert, where the screenshot is a by-product of the test.
How much memory does a Selenium screenshot job use?
In our test on macOS, chromedriver plus headless Chrome ran as 8 processes with about 995 MiB of combined resident memory while holding example.com. That figure counts shared pages more than once, so treat it as an upper bound, but it shows why a server needs headroom per concurrent session.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.