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');
});
⚠️ Don't launch a new browser per request

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;
}
📐 Viewport affects full-page width

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:

OptionWaits forUse case
commitNetwork response receivedFastest, raw HTML only
domcontentloadedDOM parsedStatic pages without images
loadWindow load eventPages with inline images
networkidleNo requests for 500msSPAs, lazy-loaded content
💡 networkidle can timeout on chat widgets

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):

Playwright (cold start)
1,850ms
Playwright (pool, warm)
720ms
Puppeteer (pool, warm)
820ms
SnapAPI (screenshot)
340ms

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:

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:

FactorPlaywright (self-hosted)SnapAPI
Infrastructure costVPS/container always runningPay per call
Cold start latency~1.8s (no pool)~340ms
Anti-bot / stealthManual plugin setupBuilt-in
Proxy rotationManualBuilt-in
PDF generationBuilt-inBuilt-in
AI page analysisWrite your ownBuilt-in
Maintenance burdenHigh (browser updates, crashes)Zero
Serverless compatibleWith workaroundsNative

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.