If you have outgrown Puppeteer — or want multi-browser screenshots, built-in auto-wait, and visual regression testing — Playwright is the natural next step. This tutorial covers everything from basic page.screenshot() to the production challenges that make some teams switch to an API.
Basic Screenshots
Full Page Screenshot
The simplest Playwright screenshot takes two lines of real code:
import { chromium } from 'playwright'
const browser = await chromium.launch()
const page = await browser.newPage()
await page.setViewportSize({ width: 1200, height: 630 })
await page.goto('https://example.com', { waitUntil: 'networkidle' })
const buffer = await page.screenshot({
type: 'png',
fullPage: true, // capture entire scrollable area
omitBackground: true, // transparent background
})
await browser.close()# Python equivalent (playwright-python)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 630})
page.goto("https://example.com")
page.screenshot(path="example.png", full_page=True)
browser.close()The fullPage: true option captures the entire scrollable page, not just the viewport. Without it, you get exactly what fits in the viewport dimensions you set.
Element Screenshot with Locators
Playwright's locator API is the preferred way to capture specific elements. Unlike Puppeteer's elementHandle.screenshot(), locators automatically wait for the element to be visible:
// No need for waitForSelector — the locator handles it
const card = page.locator('.social-card')
const buffer = await card.screenshot({
type: 'png',
omitBackground: true,
})You can also use more expressive selectors:
// Screenshot the first article with a specific data attribute
await page.locator('article[data-status="published"]').first().screenshot({
path: 'article-card.png',
})
// Screenshot an element by text content
await page.getByRole('heading', { name: 'Pricing' }).screenshot({
path: 'pricing-heading.png',
})This locator-based approach is more resilient than CSS selectors alone — if the DOM structure changes but the role and text stay the same, your screenshot script keeps working.
Multi-Browser Screenshots
Playwright's defining feature is multi-browser support. You can render the same HTML in Chromium, Firefox, and WebKit to catch rendering differences:
import { chromium, firefox, webkit } from 'playwright'
const html = `
<div style="font-family: system-ui; padding: 40px; background: white;">
<h1 style="font-size: 48px;">Cross-Browser Test</h1>
<p style="color: #635C53;">Rendered in three engines.</p>
</div>
`
const browsers = [
{ name: 'chromium', engine: chromium },
{ name: 'firefox', engine: firefox },
{ name: 'webkit', engine: webkit },
]
for (const { name, engine } of browsers) {
const browser = await engine.launch()
const page = await browser.newPage()
await page.setViewportSize({ width: 1200, height: 630 })
await page.setContent(html, { waitUntil: 'networkidle' })
await page.screenshot({ path: `output-${name}.png` })
await browser.close()
}In practice, the differences are subtle but real: font hinting varies between engines, sub-pixel antialiasing behaves differently in WebKit, and Firefox renders certain CSS gradients with slightly different color interpolation. If you are generating images that must look identical across platforms — OG cards, email headers — testing all three engines helps catch these inconsistencies early.
Advanced Playwright Features for Screenshots
Auto-Wait
Playwright's biggest quality-of-life improvement over Puppeteer is auto-wait. When you call locator.screenshot(), Playwright automatically waits for the element to be:
- Attached to the DOM
- Visible (not
display: noneorvisibility: hidden) - Stable (no ongoing animations or layout shifts)
This eliminates most waitForSelector and waitForTimeout calls. In Puppeteer, you would write:
// Puppeteer — manual wait required
await page.waitForSelector('.chart-rendered', { visible: true })
await page.waitForTimeout(500) // hope the animation finishes
const el = await page.$('.chart-rendered')
await el.screenshot({ path: 'chart.png' })In Playwright, the equivalent is:
// Playwright — auto-wait handles it
await page.locator('.chart-rendered').screenshot({ path: 'chart.png' })Trace Viewer for Debugging
When a screenshot does not look right, Playwright's trace viewer shows you exactly what happened:
const context = await browser.newContext()
await context.tracing.start({ screenshots: true, snapshots: true })
const page = await context.newPage()
await page.goto('https://example.com')
await page.screenshot({ path: 'output.png' })
await context.tracing.stop({ path: 'trace.zip' })Open the trace with npx playwright show-trace trace.zip and you get a timeline of every network request, DOM snapshot, and console message — far more useful than staring at a blank PNG trying to guess what went wrong.
Visual Regression Testing
Playwright Test includes toHaveScreenshot(), which compares screenshots against a baseline and fails if pixels differ beyond a threshold:
import { test, expect } from '@playwright/test'
test('social card renders correctly', async ({ page }) => {
await page.setContent(socialCardHtml)
await expect(page.locator('.card')).toHaveScreenshot('social-card.png', {
maxDiffPixelRatio: 0.01, // allow 1% pixel difference
})
})This is built into Playwright — no extra dependencies. It generates baseline images on first run and diffs against them on subsequent runs. The output includes a visual diff highlighting exactly which pixels changed.
Network Interception
Need to screenshot a page with specific data? Mock the API responses before capturing:
await page.route('**/api/user/profile', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: 'Jane Doe',
avatar: 'https://example.com/avatar.jpg',
stats: { followers: 12400, posts: 342 },
}),
})
})
await page.goto('https://app.example.com/profile')
await page.screenshot({ path: 'profile-mocked.png' })This is invaluable for generating consistent screenshots in CI — no flaky test data, no dependency on external services.
Playwright vs Puppeteer: Quick Comparison
If you are coming from Puppeteer, here is what changes:
| Playwright | Puppeteer | |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium only |
| Auto-wait | Built-in for all actions | Manual waitForSelector |
| TypeScript | First-class, strict types | Partial, some any types |
| Visual testing | toHaveScreenshot() built-in | Requires third-party tools |
| Element screenshot | locator.screenshot() | elementHandle.screenshot() |
| API style | Locator-based (resilient) | Handle-based (fragile) |
| Maintained by | Microsoft |
Both tools produce identical Chromium screenshots — the rendering engine is the same. The difference is in the developer experience and the breadth of what you can do beyond Chromium. For the Puppeteer-specific workflow, see our Puppeteer screenshot guide.
Production Challenges
The screenshots above work perfectly on your laptop. Running them in production is a different problem entirely.
Browser Binary Management
Playwright downloads browser binaries on install (~400MB for Chromium alone, ~700MB for all three). In Docker, this means:
FROM mcr.microsoft.com/playwright:v1.50.0-noble
# This image is ~2GB because it includes all three browsers + system depsIn CI, every pipeline run downloads these binaries unless you cache them. The PLAYWRIGHT_BROWSERS_PATH environment variable controls where they are stored, but getting the caching right across different CI providers is its own project.
Memory and Concurrency
Each browser page consumes 100-200MB of RAM. A screenshot service handling 10 concurrent requests needs 1-2GB just for browser pages, plus the Node.js runtime overhead. At 50 concurrent requests, you are looking at dedicated servers with 8-16GB RAM.
You need a tab pool — reuse browser contexts instead of launching new browsers per request. You also need a concurrency limiter to queue excess requests rather than letting them OOM-kill your process.
Without process management, Playwright browsers accumulate zombie processes that slowly eat all available memory. You need health checks, automatic restarts, and container memory limits. This is not optional — it is a matter of when, not if, the process leaks.
Font and Rendering Consistency
Playwright renders using the system fonts available in its environment. The same HTML produces different screenshots on macOS (San Francisco), Linux (DejaVu Sans), and Windows (Segoe UI) unless you explicitly install and reference web fonts. In Docker, you need to install font packages or embed fonts via CSS @font-face.
Cold Start and Latency
Browser launch takes 2-5 seconds. Even with a warm browser pool, creating a new context and navigating to a page adds 500ms-1.5s. For real-time use cases — generating OG images on request, rendering receipts at checkout — this latency is visible to users.
Measured on a 2-vCPU Linux container in US-East (early 2026). Your results will vary based on hardware and page complexity.
Playwright-Specific CI Issues
Unlike Puppeteer, which bundles a single Chromium binary, Playwright manages three separate browser installations. This creates friction in CI environments:
- Browser version pinning: Playwright expects exact browser versions matching its release. A mismatch between the Playwright npm package and installed browsers causes cryptic launch failures.
- System dependencies: Each browser engine requires different system libraries. Chromium needs
libnss3andlibatk-bridge2.0, WebKit needslibwoff1andlibgstreamer. Missing any one of these produces a segfault, not a helpful error message. - Install time:
npx playwright install --with-depscan take 60-90 seconds in CI, even with a fast network. Caching the~/.cache/ms-playwrightdirectory helps, but the cache invalidates with every Playwright version bump.
The API Alternative
If your use case is "take HTML, get an image back," you may not need a browser automation framework at all.
Full disclosure: the example below uses RendShot, which is our product.
RendShot provides an HTTP API that handles all the production infrastructure described above — browser pool, concurrency limiting, font management, memory cleanup — so you send HTML and receive a CDN-hosted image URL:
const response = await fetch('https://api.rendshot.ai/v1/image', {
method: 'POST',
headers: {
'Authorization': 'Bearer rs_live_your_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: '<div style="font-family: Inter; padding: 40px;">Hello World</div>',
width: 1200,
height: 630,
format: 'png',
deviceScaleFactor: 2,
}),
})
const { url } = await response.json()RendShot uses Playwright internally — you get the same rendering quality without managing the infrastructure. The tab pool, concurrency limiter, and SSRF protection described in this post are exactly what RendShot runs in production. RendShot does not support JavaScript execution in templates, PDF output, or geolocation — if you need those, keep using Playwright directly.
Use Playwright directly when you need full browser interaction (clicking, filling forms, navigating), cross-browser testing, or visual regression testing in CI.
Use an API when you need HTML-to-image conversion at scale without managing browser infrastructure — social cards, email images, reports, invoices, OG images.