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.
--headless=new --window-size=1280,800, macOS in dark mode, 2026-10-15: 1280x657 PNG, 73,556 bytes.
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
- Pick one screenshot job that has no assertions attached (a nightly capture, a thumbnail generator).
- 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.
- Switch the job, then remove its browser and driver from that runner's image.
- 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.