Disclosure: we build RenderScreenshot, a screenshot API that runs the browser for you. We do not make Playwright or Selenium. Both are free, and the numbers below come from running both on the same machine against the same pages; the scripts are on this page.
Short answer: for screenshots, Playwright needs less code and, with its default binary, captures faster and starts faster. Selenium wins when your team works in Ruby or C#, already runs Selenium Grid, or must test in real Safari through safaridriver. Speed alone is not a good reason to switch an existing Selenium suite: drive the same Chrome build and the gap per capture is small.
This page is part of our tool comparisons. For Puppeteer against Selenium on the same Chrome build, including the full captureBeyondViewport walkthrough, see Puppeteer vs Selenium.
At a glance
| Playwright | Selenium | |
|---|---|---|
| Protocol | Its own protocol over CDP (Chromium) and patched Firefox/WebKit builds | W3C WebDriver through a driver process (chromedriver, geckodriver, safaridriver) |
| Official languages | JavaScript/TypeScript, Python, Java, .NET | Java, Python, C#, Ruby, JavaScript |
| Browsers | Bundled Chromium, Firefox, WebKit builds | Installed Chrome, Edge, Firefox, Safari |
| Full-page screenshot | fullPage: true everywhere |
Firefox only; Chrome needs a DevTools command |
| Element screenshot | locator.screenshot(), waits for visibility |
element.screenshot(), wait first yourself |
| Viewport | Set on the context, exact | Window size, minus browser UI in headless Chrome |
| Waiting | Auto-waiting locators | Explicit WebDriverWait conditions |
| Freeze animations, mask elements | Built in | Inject CSS |
| Scale out | Many contexts per browser, test-runner workers | Selenium Grid across machines |
Test setup
Tested on 2026-10-15 on macOS, Apple M1 Pro, 32 GB, Node 22.18.0.
- Playwright 1.64.1, in two modes: its default
chrome-headless-shell(Chromium 156.0.8078.4), andchannel: 'chromium', which runs full Chromium in new headless mode. - Selenium WebDriver 4.51.0 for Node, Chrome 155.0.8059.40 with chromedriver 155.0.8059.79 (resolved by Selenium Manager),
--headless=new. - Viewport 1280 x 800 in both. For Selenium we set it with the DevTools command
Emulation.setDeviceMetricsOverride, so both tools captured exactly 1280 x 800. - Pages:
example.com; a local page with a 2,000-row table; a local single-page app that renders its content 1,200 ms after load. Each tool waited for the same#readyelement on the SPA. - 20 warm captures per page with one long-lived browser after one warm-up capture; 10 cold runs (launch, capture
example.com, quit). Memory is the peak summed RSS of all child processes during the warm runs.
Results
| Setup | example.com p50 / p95 | 2,000-row table p50 / p95 | SPA p50 | Cold start p50 / p95 | Peak RSS |
|---|---|---|---|---|---|
| Playwright, default (headless shell) | 82 / 166 ms | 61 / 89 ms | 1,318 ms | 490 / 534 ms | 452 MB |
Playwright, channel: 'chromium' |
113 / 208 ms | 91 / 97 ms | 1,324 ms | 642 / 2,765 ms | 1,184 MB |
Selenium + Chrome, --headless=new |
143 / 225 ms | 100 / 109 ms | 1,291 ms | 883 / 981 ms | 1,264 MB |
What the numbers say:
- Most of Playwright's lead comes from the binary. The headless shell is a lighter build. When Playwright ran full Chromium too, its per-capture times moved close to Selenium's (91 vs 100 ms on the table page).
- Cold start favors Playwright. Selenium starts chromedriver and then Chrome, and every command crosses that extra process. Playwright's median cold run was 240 to 390 ms shorter. The one 2.8 s outlier in Playwright's full-Chromium cold runs shows why p95 needs more runs than we did.
- The SPA row is the page, not the tool. All three spent most of their time waiting for the app's 1,200 ms delay.
- Memory follows the binary. Full Chrome under either tool used more than twice the RSS of the headless shell. Summed RSS double-counts shared memory, so compare the rows; do not size servers from them.
- Bytes were identical. Both tools produced the same PNG sizes for the same page and viewport (for example 49,136 bytes for the table page). Selenium returns screenshots as base64 over the WebDriver connection, which adds a third to the transfer, but the decoded file is the same.
An earlier run with 15 captures per page gave the same ranking. Network variance affects the example.com column; the two local pages are the cleaner comparison.
Same capture, both tools (Python)
Python is where most teams choose between these two, so the main examples are Python. Both scripts ran against example.com and against a long local page, where the full-page captures came out identical: 1280 x 56,102 px.
Selenium 4.51:
"""Selenium 4 (Python): viewport-exact and full-page screenshots in headless Chrome.""" import base64 import sys from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait URL = sys.argv[1] if len(sys.argv) > 1 else "https://example.com" options = webdriver.ChromeOptions() options.add_argument("--headless=new") options.add_argument("--hide-scrollbars") driver = webdriver.Chrome(options=options) # Selenium Manager finds or downloads chromedriver try: # --window-size sets the outer window; set the viewport through CDP instead. driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride", {"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False}) driver.get(URL) # returns when document.readyState is "complete" WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "p"))) driver.save_screenshot("selenium-viewport.png") # Chrome has no full-page method in Selenium; ask CDP to capture beyond the viewport. metrics = driver.execute_cdp_cmd("Page.getLayoutMetrics", {}) size = metrics["cssContentSize"] shot = driver.execute_cdp_cmd("Page.captureScreenshot", { "format": "png", "captureBeyondViewport": True, "clip": {"x": 0, "y": 0, "width": size["width"], "height": size["height"], "scale": 1}, }) with open("selenium-full.png", "wb") as f: f.write(base64.b64decode(shot["data"])) print("ok", size["width"], size["height"]) finally: driver.quit()
Playwright 1.64:
"""Playwright (Python, sync API): viewport and full-page screenshots.""" import sys from playwright.sync_api import sync_playwright URL = sys.argv[1] if len(sys.argv) > 1 else "https://example.com" with sync_playwright() as p: browser = p.chromium.launch() try: context = browser.new_context(viewport={"width": 1280, "height": 800}, device_scale_factor=1) page = context.new_page() page.goto(URL, wait_until="load") page.locator("p").first.wait_for() page.screenshot(path="playwright-viewport.png") page.screenshot(path="playwright-full.png", full_page=True) print("ok") finally: browser.close()
The Selenium version is twice as long for the same two images, and the extra lines are exactly the two places where screenshot work differs: setting an exact viewport, and capturing beyond it. execute_cdp_cmd works only with Chromium-based drivers. For Firefox, use driver.get_full_page_screenshot_as_file(); Safari has no full-page method.
Default sizes: the silent difference
Run both tools with no size settings and you get different images. We captured the same element (a card with padding: 24px inside a page with 40 px margins) with each tool's defaults:
| Element screenshot width | |
|---|---|
| Playwright, default context (1280 x 720 viewport) | 1,200 px |
| Selenium, default headless Chrome window | 676 px |
With --window-size=1280,800, Selenium's headless Chrome reported innerHeight 657, and the viewport screenshot was 1280 x 657: the window size includes space for browser UI even in headless mode. The Puppeteer vs Selenium page shows a window-resize workaround; the CDP override above is the other fix. In Playwright the viewport you pass is the viewport you get.
Waiting before the capture
Element screenshots show the design difference most clearly.
# Selenium: wait explicitly, then screenshot the element card = WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.ID, "ready"))) card.screenshot("selenium-card.png") # Playwright: the locator waits for visibility on its own page.locator("#ready").screenshot(path="playwright-card.png", timeout=10_000)
Both lines worked on our SPA page, where the element appears 1.2 s after load. Without the WebDriverWait, Selenium raises NoSuchElementException straight away. Playwright's locator also waits until the element stops moving, which avoids half-finished CSS transitions in the image.
| Wait for | Selenium | Playwright |
|---|---|---|
| Document loaded | driver.get() returns at readyState == "complete" (page load strategy normal) |
goto(url, wait_until="load") |
| DOM only | page_load_strategy = "eager" |
wait_until="domcontentloaded" |
| Network quiet | No built-in condition | wait_until="networkidle" (discouraged in its docs) |
| Element visible | EC.visibility_of_element_located |
locator.wait_for() or any locator action |
| Custom JS condition | WebDriverWait(...).until(lambda d: d.execute_script(...)) |
page.wait_for_function("...") |
For pages that never go quiet, a selector that appears only after the data loads is the best signal in both tools. See waiting for single-page apps.
The same in Node.js
Node teams have both options too. The Selenium driver below is the one we benchmarked.
import { Builder, By, until } from 'selenium-webdriver'; import chrome from 'selenium-webdriver/chrome.js'; import { writeFile } from 'node:fs/promises'; const options = new chrome.Options().addArguments('--headless=new', '--hide-scrollbars'); const driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build(); try { await driver.sendDevToolsCommand('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); await driver.get('https://example.com'); await driver.wait(until.elementLocated(By.css('p')), 10_000); await writeFile('selenium.png', Buffer.from(await driver.takeScreenshot(), 'base64')); } finally { await driver.quit(); }
import { chromium } from 'playwright'; const browser = await chromium.launch(); try { const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await context.newPage(); await page.goto('https://example.com', { waitUntil: 'load' }); await page.screenshot({ path: 'playwright.png' }); } finally { await browser.close(); }
Decision table
| Your situation | Choose |
|---|---|
| New screenshot service in Python, Node, Java or .NET | Playwright |
| Ruby application (Rails, Sidekiq jobs) | Selenium, or a Ruby CDP library |
| Existing Selenium suite that already captures screenshots on failure | Stay with Selenium; add the CDP full-page call where needed |
| Need real Safari, not WebKit | Selenium with safaridriver on macOS |
| Many viewport or device variants per URL | Playwright contexts |
| Captures spread across many machines today | Selenium Grid, or Playwright on a job queue |
| Visual regression tests with stable images | Playwright (animations: 'disabled', mask, toHaveScreenshot) |
| Lowest cold start in serverless jobs | Playwright with the headless shell |
Or skip the browser
Both tools leave the same operations work when screenshots run in production: browser installs and updates, fonts on Linux, memory limits, zombie processes and retries. If screenshots are a feature and not your product, a hosted renderer removes that layer. The two captures above as one request:
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "viewport": { "width": 1280, "height": 800 }, "capture": { "mode": "full_page" }, "wait": { "for_selector": "p" } }' \ --output example.png
Parameters are in viewport, capture and wait. You can run the same three pages through it with a free key and compare the output with your local captures.
Related
- Puppeteer vs Playwright for screenshots, with the same benchmark
- Selenium screenshots, the full Selenium tutorial
- Taking screenshots with Playwright
- Playwright Python screenshots