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.0driving the installed Google Chrome 155.0.8059.80 with chromedriver 155.0.8059.84 from Selenium Manager, andhttpx==0.28.1. Our sandbox blocked outbound traffic from the browsers, so the browser listings captured a saved copy ofexample.comand a long test page served fromlocalhost. The API listing ran against a local stub of our API that returns the documented image and error bodies, including a 429 withRetry-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-sizeis not the viewport. Our first version used--window-size=1280,800and got a 1280 x 657 image, because the window size includes browser UI even in headless mode. The CDP commandEmulation.setDeviceMetricsOverridesets the viewport and the device scale factor exactly. It works with Chrome and Edge, not Firefox.save_screenshotreturnsFalseon 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
LIMITwith all your workers in mind, and respectRetry-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
- Get a free API key and run
api_batch.pywithRS_API_KEYset. The free plan includes 150 credits every month. - Tune the capture: wait parameters and viewport parameters.
- Rendering HTML strings instead of URLs: HTML to image in Python.
- The same comparison for Node.js: website screenshots in Node.js.
Related in this topic
- How to Take Website Screenshots with Code: 6 Ways Take a screenshot of a website programmatically: DevTools, the Chrome CLI, Puppeteer, Playwright, Selenium or an API. Timed runs and code for each.
- Python Screenshot: Desktop (mss, PyAutoGUI) vs Web Pages Take a screenshot in Python: mss, PyAutoGUI and DXcam for the desktop, Playwright or an API for web pages. Tested code, measured timings and fixes.
- Selenium Screenshots in Python, Java and C# (Selenium 4) Take a screenshot with Selenium 4 in Python, Java and C#: page, element, exact viewport and PDF. Same test in three languages, timed, with the fixes.
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Website Screenshots in Node.js: Puppeteer, Playwright, API Take website screenshots in Node.js 22 with TypeScript and ESM: Puppeteer, Playwright or one fetch call to an API, with measured install cost and tested code.