Why Playwright for Screenshots?
Playwright is Microsoft's answer to browser automation and has largely superseded Puppeteer as the go-to headless browser library for Node.js. Its screenshot API is more capable out-of-the-box: built-in network idle detection, automatic waiting, multi-browser support (Chromium, Firefox, WebKit), and a cleaner async API.
But running Playwright in production — especially on serverless or containerized infrastructure — comes with real costs. Let's cover the full picture.
Basic Playwright Screenshot
Install Playwright and its browsers:
npm install playwright
npx playwright install chromium # ~280MB download
The simplest possible screenshot:
const { chromium } = require('playwright');
async function screenshot(url) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
const buffer = await page.screenshot({ fullPage: false });
await browser.close();
return buffer; // PNG Buffer
}
screenshot('https://example.com').then(buf => {
require('fs').writeFileSync('screenshot.png', buf);
console.log('Done:', buf.length, 'bytes');
});
The code above creates and destroys a browser instance for every screenshot. In production this is extremely slow (~800ms per launch) and will exhaust memory under any meaningful load. See the browser pool pattern below.
Page Screenshot Options
The page.screenshot() method accepts a rich set of options:
const buffer = await page.screenshot({
fullPage: true, // capture entire scrollable page
type: 'png', // 'png' | 'jpeg' | 'webp'
quality: 85, // jpeg/webp only, 0-100
clip: { // capture specific region
x: 0, y: 0,
width: 1200, height: 630
},
omitBackground: true, // transparent background (PNG only)
animations: 'disabled', // freeze CSS animations
scale: 'device', // 'css' | 'device'
timeout: 30000, // ms, default 30s
});
Full-Page Screenshot
Full-page screenshots capture the entire scrollable document, not just the visible viewport. This is the most requested feature and Playwright handles it cleanly:
const { chromium } = require('playwright');
async function fullPageScreenshot(url) {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 }
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// Wait for any lazy-loaded images
await page.evaluate(() => {
return new Promise(resolve => {
if (document.readyState === 'complete') resolve();
else window.addEventListener('load', resolve);
});
});
const buffer = await page.screenshot({ fullPage: true });
await browser.close();
return buffer;
}
The width of your full-page screenshot is determined by the viewport width you set. A 1440px viewport gives you a desktop-width screenshot of the full page height. Common widths: 375px (mobile), 768px (tablet), 1280px/1440px (desktop).
Element Screenshot
Screenshot a specific DOM element using locators:
const { chromium } = require('playwright');
async function elementScreenshot(url, selector) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// Wait for element to be visible
const element = page.locator(selector);
await element.waitFor({ state: 'visible', timeout: 10000 });
// Screenshot just the element
const buffer = await element.screenshot({ type: 'png' });
await browser.close();
return buffer;
}
// Example: capture a chart, card, or specific section
elementScreenshot('https://example.com', '.pricing-table');
Device Emulation (Mobile Screenshots)
Playwright ships with 50+ device presets — phones, tablets, retina displays:
const { chromium, devices } = require('playwright');
async function mobileScreenshot(url, deviceName = 'iPhone 14') {
const browser = await chromium.launch();
const context = await browser.newContext({
...devices[deviceName], // sets viewport, userAgent, deviceScaleFactor
locale: 'en-US',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
const buffer = await page.screenshot();
await browser.close();
return buffer;
}
// Available devices: 'iPhone 14', 'iPad Pro 11', 'Pixel 7', 'Galaxy S23', etc.
// console.log(Object.keys(devices)); // full list
PDF Generation
Playwright can generate PDFs directly from pages (Chromium only):
const pdfBuffer = await page.pdf({
format: 'A4', // 'Letter', 'A4', 'A3', etc.
printBackground: true, // include CSS background colors/images
margin: {
top: '20mm', right: '15mm',
bottom: '20mm', left: '15mm'
},
landscape: false,
preferCSSPageSize: true, // respect @page CSS rules
});
require('fs').writeFileSync('output.pdf', pdfBuffer);
Production Pattern: Browser Pool
The biggest mistake teams make is launching a new browser per request. Here's a proper reusable browser context pool:
const { chromium } = require('playwright');
const genericPool = require('generic-pool');
// npm install generic-pool
const factory = {
create: async () => {
const browser = await chromium.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // critical in Docker
'--disable-gpu',
],
});
return browser;
},
destroy: async (browser) => browser.close(),
};
const browserPool = genericPool.createPool(factory, {
min: 2, // keep 2 warm
max: 10, // never exceed 10 browsers
acquireTimeoutMillis: 15000,
idleTimeoutMillis: 30000,
});
// Middleware/handler usage
async function screenshot(url) {
const browser = await browserPool.acquire();
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
const buffer = await page.screenshot({ fullPage: false });
await context.close(); // close context, not browser
return buffer;
} finally {
await browserPool.release(browser);
}
}
// Express endpoint
const express = require('express');
const app = express();
app.get('/screenshot', async (req, res) => {
try {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url required' });
const buf = await screenshot(url);
res.set('Content-Type', 'image/png');
res.send(buf);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(3000);
waitUntil Strategies
Choosing the right waitUntil option is critical for screenshot accuracy:
| Option | Waits for | Use case |
|---|---|---|
commit | Network response received | Fastest, raw HTML only |
domcontentloaded | DOM parsed | Static pages without images |
load | Window load event | Pages with inline images |
networkidle | No requests for 500ms | SPAs, lazy-loaded content |
Many sites have persistent WebSocket connections (Intercom, Crisp, Drift) that prevent networkidle from settling. Use load and add an explicit page.waitForTimeout(1000) delay, or wait for a specific DOM element instead.
Handling Authentication
For pages behind login, use Playwright's built-in cookie/storage state:
// Save auth state once
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.example.com/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'secret');
await page.click('[type="submit"]');
await page.waitForURL('**/dashboard');
// Save cookies + localStorage
await context.storageState({ path: './auth.json' });
await browser.close();
// Reuse in subsequent contexts
const authedContext = await browser.newContext({
storageState: './auth.json'
});
const authedPage = await authedContext.newPage();
await authedPage.goto('https://app.example.com/reports');
const buf = await authedPage.screenshot({ fullPage: true });
Performance Benchmarks
Measured on a 2-core VPS with browser pool warmed up (p50 latency):
Test: Full-page screenshot of a React SPA. Pool size: 4 browsers. Playwright v1.50, Puppeteer v22, SnapAPI March 2026.
Common Production Issues
1. Memory Leaks from Unclosed Contexts
Always close browser contexts (not just pages) after each request. Browser contexts hold cookies, cache, and event listeners. Leaving them open leaks ~50MB per context within hours:
// ✅ Always close context in finally
const context = await browser.newContext();
try {
const page = await context.newPage();
// ... work ...
} finally {
await context.close(); // releases memory immediately
}
2. Docker: /dev/shm Too Small
Chromium uses /dev/shm (shared memory) for GPU acceleration. Docker's default 64MB limit causes frequent crashes. Fix:
# Option 1: increase shm_size in docker-compose.yml
shm_size: '1gb'
# Option 2: disable GPU (add launch arg)
args: ['--disable-gpu', '--disable-dev-shm-usage']
3. Lambda / Serverless Cold Starts
Playwright's 280MB Chromium binary hits Lambda's 250MB deployment limit. Workarounds:
- Use
@sparticuz/chromium— stripped Lambda-compatible Chromium - Use Lambda layers to separate the binary
- Use a screenshot API (SnapAPI, ScreenshotOne) — no binary deployed, no cold starts
4. Anti-Bot Detection
Many sites detect headless Chromium via JavaScript fingerprinting (navigator.webdriver, canvas fingerprint, etc.). Playwright doesn't stealth by default — you need playwright-extra + puppeteer-extra-plugin-stealth adapted for Playwright, or use a managed API with built-in stealth.
When to Use a Screenshot API Instead
Playwright is excellent for in-process browser automation. But for dedicated screenshot services, a managed API often wins on every metric:
| Factor | Playwright (self-hosted) | SnapAPI |
|---|---|---|
| Infrastructure cost | VPS/container always running | Pay per call |
| Cold start latency | ~1.8s (no pool) | ~340ms |
| Anti-bot / stealth | Manual plugin setup | Built-in |
| Proxy rotation | Manual | Built-in |
| PDF generation | Built-in | Built-in |
| AI page analysis | Write your own | Built-in |
| Maintenance burden | High (browser updates, crashes) | Zero |
| Serverless compatible | With workarounds | Native |
Quick SnapAPI Comparison
If you're using Playwright purely to take screenshots or generate PDFs — not for complex automation — here's how the same job looks with SnapAPI:
// Playwright: requires browser pool, server, 280MB binary, memory mgmt
const buf = await page.screenshot({ fullPage: true });
// SnapAPI: one HTTP call, no infrastructure
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': 'sk_live_your_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com',
full_page: true,
format: 'png',
viewport_width: 1440,
viewport_height: 900,
wait_until: 'networkidle',
block_ads: true,
}),
});
const buf = Buffer.from(await res.arrayBuffer());
SnapAPI also supports element screenshots (via selector), device emulation (30+ presets), stealth mode, custom CSS/JS injection, and PDF generation — all via the same API key. Free tier: 200 calls/month to try it.
Summary
Playwright's screenshot API is powerful and production-ready when used correctly — with browser pooling, proper context lifecycle management, and the right waitUntil strategy. The main production pitfalls are memory leaks from unclosed contexts, Docker shm limits, and Lambda deployment size.
For teams who just need screenshots or PDFs as a service — without managing browser infrastructure — a dedicated screenshot API eliminates all of this operational overhead at a fraction of the cost of running your own cluster.