Website screenshot in Python: what still goes wrong, measured
A screenshot with Playwright is two lines. Some old warnings about full-page shots no longer hold in today's Chromium; others do, and they cost you the images and charts on the page. We tested each one.
Taking a screenshot of a web page from Python is two lines with Playwright. Taking the one you meant is harder. The page you see in your browser has scrolled, waited for its images and run its scripts. The headless browser takes its picture at the moment you tell it to.
Many tips about this were written for older versions of Chromium. So on 25 October 2026 we tested them again, with Playwright 1.58.0 and Chromium 145, on small test pages we served ourselves. One part of that server answered only after two seconds, like a slow image host. We ran every test at least twice, and each result below was the same every time.
What no longer goes wrong#
- Very tall pages. A page 30,000 pixels tall came out as one image of 1280 x 30,000, every part in its place. We saw no cut-off at 16,384 pixels.
- Fixed headers. A 60-pixel header with
position: fixedappeared once, at the top of the full-page image, not repeated down the page. - Full-screen heroes. A first section with
height: 100vhstayed one screen tall (800 pixels) in a 2,800-pixel full-page shot. - Web fonts. With a font file that took 2 seconds to arrive, the
loadevent waited for it, and the text was drawn in that font.
What still does#
Lazy images. An image 3,000 pixels down the page with loading="lazy", from the slow host:
- Shot at
load: missing, 3 times out of 3. The browser had not even asked for it. wait_until="networkidle": there, 3 out of 3, after about 2.7 seconds.- Scrolling down the page, then waiting for the images: there, 3 out of 3, after about 2.3 seconds.
Lazy images in the middle of a long page. Three lazy images at 4,000, 9,000 and 14,000 pixels on a page 20,000 pixels tall. A quick fix you will find suggested is to jump to the bottom and back. It loaded none of them, and waiting for them never ended, because an image the browser has not started to fetch never counts as complete. We stopped the wait at 8 seconds. Scrolling down one screen at a time loaded all three in about 4.5 seconds.
Content a script draws after load. A chart that a script adds 0.8 seconds after the page loads was missing from a shot taken at load, and also at networkidle. "Network idle" means no requests for half a second, and a timer is not a request, so the browser was idle well before the chart arrived. Only waiting for the chart's own element worked.
WebP. Playwright writes PNG and JPEG only, and asking for WebP gives an error (expected one of (png|jpeg)). Convert the PNG afterwards.
The size of the file#
The full home page of voiddo.com came out at 1280 x 4,153 pixels. As a PNG that was 1,123 KB, as a JPEG 375 KB, and as a WebP (quality 90) 266 KB. With device_scale_factor=2, for a sharp picture on high-density screens, a 1280 x 800 view becomes 2560 x 1600: four times the pixels.
The code#
screenshot() waits for the page, scrolls it a screen at a time so lazy images load, waits for them (with a limit, so one broken image cannot hang it), and saves PNG, JPEG or WebP by the file name. For a page that draws something late, pass that element's CSS selector as wait_for.
import io
from playwright.sync_api import TimeoutError as PlaywrightTimeout, sync_playwright
from PIL import Image
SCROLL = """async () => {
for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight) {
scrollTo(0, y);
await new Promise(r => setTimeout(r, 100));
}
scrollTo(0, 0);
}"""
def screenshot(url, path, width=1280, height=800, full_page=True, wait_for=None, scale=1):
"""PNG, JPEG or WebP by the file name. wait_for: a CSS selector for something a script draws after load."""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height}, device_scale_factor=scale)
page.goto(url, wait_until="load")
if wait_for:
page.wait_for_selector(wait_for)
if full_page: # lazy images load only when they come near the screen
page.evaluate(SCROLL)
try:
page.wait_for_function("[...document.images].every(i => i.complete)", timeout=10_000)
except PlaywrightTimeout:
pass # one image that never arrives must not cost the whole shot
jpeg = path.lower().endswith((".jpg", ".jpeg"))
shot = page.screenshot(full_page=full_page, type="jpeg" if jpeg else "png", animations="disabled")
browser.close()
if path.lower().endswith(".webp"): # Playwright writes PNG and JPEG only
Image.open(io.BytesIO(shot)).save(path, quality=90)
else:
with open(path, "wb") as f:
f.write(shot)screenshot("https://voiddo.com/", "home.png")
screenshot("https://voiddo.com/", "home.jpg")
screenshot("https://voiddo.com/", "home.webp")
for name in ("home.png", "home.jpg", "home.webp"):
print(name, Image.open(name).size, f"{os.path.getsize(name) / 1000:.0f} KB")Two things this does not solve. Cookie banners cover part of the page in the very shot you wanted, and every site draws its own. And sites that show a bot check to headless browsers will show it in your screenshot too. That page is the site's answer, and the right thing is to respect it.
Without running a browser#
For many pages, on a schedule, or from a tool that cannot run Chromium, our Website Screenshot Generator on Apify takes a list of addresses and returns the images.
- PNG, JPEG or WebP, with a quality setting for JPEG and WebP.
- Viewport from 320 to 2,560 pixels wide (1280 x 800 unless you say otherwise), and a pixel density setting for sharp images.
- Full page up to 16,384 CSS pixels tall or 50 megapixels, an extra wait of up to 3 seconds after load, and an option that hides common cookie dialogs (best effort).
- It reads robots.txt and skips pages it closes, and it also takes inline HTML as a
data:text/htmladdress.
One run takes up to 1,000 addresses, each a row with a link to its image. It is billed per screenshot.
written
5 min readHTML to PDF in Python with Playwright: the defaults that bite
A headless browser is a common way to turn HTML into a PDF. Its defaults catch people out: US Letter, no backgrounds, no margins, an invisible footer. Each one measured, and a function that sets them right.
8 min readKDP cover size and spine width: the formula, in Python
A KDP paperback cover is one PDF: back, spine and front, with bleed. The formula from KDP's own pages, the two places where those pages disagree, how much room spine text really gets, and a guide PDF in plain Python.
5 min readSudoku generator in Python with a unique-solution check
A sudoku with two answers is a misprint. About fifty lines of Python that fill a grid, empty it square by square and keep only puzzles with exactly one solution, and what 200 of them showed about "unique" against "easy".