· Tutorials · By Simon Chiu

Website Screenshots in Python: Selenium, Playwright, API

Capture a website as an image in Python with Selenium, Playwright or an API: measured setup and per-shot time, async batches, and fixes for common failures.

To capture a website as an image in Python, drive a headless browser with Playwright or Selenium, or send the URL to a screenshot API. The shortest working version uses Playwright's sync API:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com")
    page.screenshot(path="example.png")
    browser.close()

This guide compares the three routes on what you pay before the first image (install time and disk), what each image costs once the browser runs, and the code you need for viewport, full-page and batch captures. Every listing ran on the date below. For screenshots of the desktop rather than a web page, see desktop vs web page screenshots in Python. For the SDK route in a FastAPI or Django app, see the Python screenshot API page.

Tested on 2026-10-18 on macOS 27.0.1 (Apple M1 Pro) with Python 3.15.0 in a venv: playwright==1.64.0 (Chromium 156.0.8078.4), selenium==4.51.0 driving the installed Google Chrome 155.0.8059.80 with chromedriver 155.0.8059.84 from Selenium Manager, and httpx==0.28.1. Our sandbox blocked outbound traffic from the browsers, so the browser listings captured a saved copy of example.com and a long test page served from localhost. The API listing ran against a local stub of our API that returns the documented image and error bodies, including a 429 with Retry-After. No number below includes network time.

Setup cost, measured

We created a fresh venv and timed each install once, with an empty Playwright browser cache. The API route needs only pip install httpx, which we did not time:

Step Wall time Added to disk
pip install playwright 55.5 s the package wheel
python -m playwright install chromium 47.9 s 572 MB (Chromium, headless shell, ffmpeg)
pip install selenium 3.1 s the package; uses a Chrome you already have

Install times depend on the network, so read them as an order of magnitude from one run. The disk column is the stable part. Playwright brings its own browser build, about 570 MB per machine, CI cache and Docker image. Selenium installs quickly because it uses the Chrome on the machine, and Selenium Manager fetches a matching chromedriver on first use. On a server with no Chrome, Selenium Manager downloads Chrome for Testing as well, so the disk cost comes back. On Debian or Ubuntu, python -m playwright install --with-deps chromium also installs the system libraries Chromium needs.

Selenium

# selenium_shot.py - Python 3.15, selenium 4.51.0
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

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

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")

driver = webdriver.Chrome(options=options)  # Selenium Manager finds or downloads chromedriver
try:
    # Set the viewport itself; --window-size sets the outer window, which is taller.
    driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride",
                           {"width": 1280, "height": 800, "deviceScaleFactor": 2, "mobile": False})
    driver.set_page_load_timeout(30)
    driver.get(target)
    WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.CSS_SELECTOR, "body p")))
    if not driver.save_screenshot("out/selenium.png"):
        sys.exit("screenshot failed")
    print("saved out/selenium.png")
finally:
    driver.quit()

This wrote a 2560 x 1600 PNG: a 1280 x 800 viewport at scale 2. Two things we hit while writing it (the Selenium Python screenshot guide covers more):

  • --window-size is not the viewport. Our first version used --window-size=1280,800 and got a 1280 x 657 image, because the window size includes browser UI even in headless mode. The CDP command Emulation.setDeviceMetricsOverride sets the viewport and the device scale factor exactly. It works with Chrome and Edge, not Firefox.
  • save_screenshot returns False on failure instead of raising. Check the return value, or a cron job reports success with no file.

Selenium has no full-page option for Chrome. Ask Chrome directly through CDP:

# se_full.py - full-page PNG from Chrome through Selenium's CDP bridge
import base64, sys
from selenium import webdriver

target = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
try:
    driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride",
                           {"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False})
    driver.get(target)
    shot = driver.execute_cdp_cmd("Page.captureScreenshot",
                                  {"format": "png", "captureBeyondViewport": True})
    with open("out/selenium-full.png", "wb") as f:
        f.write(base64.b64decode(shot["data"]))
    print("saved out/selenium-full.png")
finally:
    driver.quit()

On our long test page it returned a 1280 x 7288 PNG, the full scroll height. With Firefox, use driver.get_full_page_screenshot_as_file() instead.

Playwright, sync

# playwright_shot.py - Python 3.15, playwright 1.64.0
import sys

from playwright.sync_api import sync_playwright

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

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        page = browser.new_page(viewport={"width": 1280, "height": 800}, device_scale_factor=2)
        page.goto(target, wait_until="load", timeout=30_000)
        page.locator("body p").first.wait_for()
        page.screenshot(path="out/playwright.png", full_page=True)
        print("saved out/playwright.png")
    finally:
        browser.close()

The viewport and scale go straight into new_page(), and full_page=True covers the scroll height, so the Selenium workarounds are not needed. On the long test page this wrote a 2560 x 14576 PNG. Pick your wait selector from the live page: we first waited for h1, and it timed out, because the copy of example.com we fetched on 2026-10-18 has no <h1>. The wait strategies guide covers how to choose a condition.

Playwright, async batch

For many URLs, use the async API with one browser and a semaphore (our Playwright Python guide compares sync and async):

# playwright_batch.py - one browser, N pages at a time
import asyncio
import sys
from pathlib import Path

from playwright.async_api import async_playwright

LIMIT = 4  # pages open at once; each one costs memory


async def capture(browser, sem, url, path):
    async with sem:
        context = await browser.new_context(viewport={"width": 1280, "height": 800})
        try:
            page = await context.new_page()
            await page.goto(url, wait_until="load", timeout=30_000)
            await page.screenshot(path=path)
            return url, None
        except Exception as exc:  # report per URL, keep the batch going
            return url, exc
        finally:
            await context.close()


async def main(urls):
    Path("out").mkdir(exist_ok=True)
    sem = asyncio.Semaphore(LIMIT)
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        try:
            results = await asyncio.gather(
                *(capture(browser, sem, u, f"out/batch-{i}.png") for i, u in enumerate(urls))
            )
        finally:
            await browser.close()
    failed = [(u, e) for u, e in results if e]
    for u, e in failed:
        print(f"FAILED {u}: {e}", file=sys.stderr)
    print(f"{len(urls) - len(failed)}/{len(urls)} captured")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main(sys.argv[1:])))

With three URLs, one of them on a port Chrome refuses, it printed FAILED http://127.0.0.1:9/nothing: Page.goto: net::ERR_UNSAFE_PORT, then 2/3 captured, and exited with status 1. A new context per URL keeps cookies and storage separate, and closing it in finally frees the page even when goto fails.

The API route, async

The third option sends the URL to an API and gets the image back. With httpx.AsyncClient the batch looks almost the same, minus the browser:

# api_batch.py - many captures through the API with httpx.AsyncClient
import asyncio
import os
import sys

import httpx

API = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com")
LIMIT = 5  # stay under your plan's per-minute limit; it is shared by all your keys


async def capture(client, sem, url, path, attempts=3):
    async with sem:
        for attempt in range(1, attempts + 1):
            r = await client.post(f"{API}/v1/screenshot", json={
                "url": url,
                "viewport": {"width": 1280, "height": 800},
                "output": {"format": "png"},
            })
            if r.status_code == 200:
                with open(path, "wb") as f:
                    f.write(r.content)
                return url, None
            err = r.json()["error"]
            if r.status_code == 429 and attempt < attempts:
                await asyncio.sleep(int(r.headers.get("Retry-After", "60")))
                continue
            return url, f'{r.status_code} {err["code"]}: {err["message"]}'


async def main(urls):
    headers = {"Authorization": f'Bearer {os.environ["RS_API_KEY"]}'}
    sem = asyncio.Semaphore(LIMIT)
    async with httpx.AsyncClient(headers=headers, timeout=90) as client:
        results = await asyncio.gather(
            *(capture(client, sem, u, f"out/api-{i}.png") for i, u in enumerate(urls))
        )
    failed = [(u, e) for u, e in results if e]
    for u, e in failed:
        print(f"FAILED {u}: {e}", file=sys.stderr)
    print(f"{len(urls) - len(failed)}/{len(urls)} captured")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main(sys.argv[1:])))

Against the stub, the first URL got a 429 with Retry-After: 1, slept one second and succeeded on the second try. not-a-url came back as 400 invalid_url and was not retried. The script printed 2/3 captured and exited with status 1. Three details carry over to production:

  • The rate limit is per account, shared by every key and process (rate limits). Set LIMIT with all your workers in mind, and respect Retry-After.
  • The client timeout is 90 seconds because a capture can wait up to 60 seconds for a slow page. httpx's default of 5 seconds would cancel most real renders.
  • The body is nested: viewport.width, output.format. For 2 to 10 URLs with the same options, the batch endpoint does the fan-out on our side in one request.

The comparison

Timing rows come from our own three rounds of ten captures of the local example.com copy at 1280 x 800, one reused browser per round. "Steady" is the median of captures 2 to 10. Selenium reused one tab; Playwright opened a new context per capture, so its numbers include that extra work.

Selenium 4.51 Playwright 1.64 Screenshot API
Browser on disk Your Chrome, or Chrome for Testing 572 MB bundled None
Browser start in our runs 946 to 1,525 ms 102 to 273 ms No start
Steady capture, median 70 to 141 ms 92 to 110 ms Not measured here (network-bound)
Exact viewport and scale Via CDP command new_page(viewport=..., device_scale_factor=...) viewport.width, viewport.scale
Full page (Chrome) Via CDP captureBeyondViewport full_page=True capture.mode: "full_page"
Async API No (use threads) Yes Yes, with httpx
Cookie banners and ads Your code Your code block.cookie_banners, block.ads
Fonts and Linux libraries You install You install Handled

How to read it. Once a browser runs, both libraries capture a small local page in about a tenth of a second, and the run-to-run spread is larger than the gap between them. The real difference is start-up: Selenium launched full Chrome plus chromedriver, roughly a second each time, while Playwright launched its headless shell in a few hundred milliseconds. That matters for scripts that start a browser per job. The API has no timings here because we did not call the production API; its latency depends on loading the target page over the network.

Choose Selenium if you already run it for tests or a grid. Choose Playwright for new scraping and capture code. Choose the API when you capture third-party pages, run on serverless, or do not want Chromium in your image.

Troubleshooting

Symptom Cause Fix
Selenium image is 657 px tall, not 800 --window-size includes browser UI Emulation.setDeviceMetricsOverride via execute_cdp_cmd
TimeoutException from WebDriverWait The selector is not on the page Check the live DOM; our h1 wait failed on example.com
save_screenshot wrote nothing, no error It returns False on failure Check the return value and exit non-zero
Executable doesn't exist from Playwright Browsers not installed for this venv python -m playwright install chromium
error while loading shared libraries on Linux Chromium's system libraries missing python -m playwright install --with-deps chromium
net::ERR_UNSAFE_PORT Chrome refuses some ports Serve the page on another port
Error: It looks like you are using Playwright Sync API inside the asyncio loop Sync API called in Jupyter or an async app Use playwright.async_api
Boxes instead of emoji or CJK text Fonts not installed on the server apt-get install -y fonts-noto-color-emoji fonts-noto-cjk
httpx.ReadTimeout from the API Default 5 s client timeout httpx.AsyncClient(timeout=90)
Many 429 responses Workers exceed the shared per-account limit Lower concurrency; sleep Retry-After

Next steps

Related in this topic