· Tutorials

Taking Screenshots with Playwright: The Complete Guide

Learn how to capture web page screenshots with Playwright — from installation and browser contexts to full-page captures, element screenshots, and production-ready patterns.

Code on a dark screen with syntax highlighting

Playwright is Microsoft's open-source browser automation framework, and it has become one of the most capable tools for capturing web page screenshots programmatically. While our Puppeteer guide covers Google's browser automation library and our comparison post highlights the differences between the two, this guide is a standalone, in-depth reference for Playwright screenshots specifically — covering everything from installation to production-ready patterns.

Whether you're generating social cards, capturing documentation pages, building visual regression tests, or automating link preview images, this guide walks through every technique you'll need.

Installation

Playwright has official SDKs for both Node.js and Python. Install whichever matches your stack.

Node.js

Install the Playwright package with npm:

npm install playwright

This downloads the Playwright library and all three browser engines — Chromium, Firefox, and WebKit — automatically. The full install is around 400-500 MB because it ships three browsers.

If you only need one browser (Chromium is the most common choice for screenshots), install only what you need:

npm install playwright
npx playwright install chromium

Or if you want to skip the automatic browser download during npm install and control it separately:

PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install playwright
npx playwright install chromium

You can also install playwright-core if you want the library without any bundled browsers, then install specific browsers manually:

npm install playwright-core
npx playwright install chromium

For CI environments where install time matters, installing only Chromium shaves significant time off your build.

Python

Install the Python package with pip:

pip install playwright

Then install the browsers:

playwright install

Or install only Chromium:

playwright install chromium

Python's Playwright package provides both synchronous and asynchronous APIs. We'll cover both later in this guide, but the Node.js examples come first since that's the most common environment for screenshot automation.

System Dependencies

On Linux servers and Docker containers, browsers need system-level libraries. Playwright provides a helper command that installs them:

npx playwright install-deps chromium

This installs the required packages (libx11, libxcomposite, libasound2, fonts, and others) for the specified browser. It's the equivalent of manually tracking down and installing a dozen apt-get packages, so it's a significant time saver when setting up servers.

Your First Screenshot

Here's the simplest possible Playwright screenshot in Node.js:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('https://example.com');
  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

This launches a headless Chromium instance, creates a browser context, opens a new page, navigates to the URL, saves a screenshot as a PNG, and shuts everything down.

The three-level hierarchy — browser, context, page — is central to how Playwright works and is worth understanding from the start.

  • Browser is the actual browser process. You launch one and reuse it across multiple tasks.
  • Context is an isolated browser session. Each context has its own cookies, localStorage, viewport settings, and permissions. Think of it as an incognito window — contexts don't share any state with each other.
  • Page is a single tab within a context. You navigate pages, interact with them, and take screenshots of them.

This model is cleaner than Puppeteer's approach, where viewport and many other settings are configured at the page level. In Playwright, you set up the context with all the configuration you need, and every page inherits those settings automatically. We'll see why this matters in the next section.

You can also use a shorter form that skips the explicit context creation:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('https://example.com');
  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

When you call browser.newPage() directly, Playwright creates a default context behind the scenes. This works fine for basic cases, but creating contexts explicitly gives you control over viewport size, device emulation, locale, timezone, and more — which is what you'll want for any real screenshot workflow.

Browser Contexts: Why They Matter for Screenshots

Browser contexts are one of Playwright's most powerful features for screenshot work. A context lets you configure an entire environment — viewport dimensions, device scale factor, locale, timezone, color scheme, and more — in one place. Every page opened within that context inherits those settings.

Here's a context configured for capturing a high-DPI screenshot with specific locale and timezone settings:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  const context = await browser.newContext({
    viewport: { width: 1280, height: 720 },
    deviceScaleFactor: 2,
    locale: 'en-US',
    timezoneId: 'America/New_York',
    colorScheme: 'light'
  });

  const page = await context.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'screenshot.png' });

  await context.close();
  await browser.close();
})();

Every option there applies to all pages within the context. Open ten pages in this context and they all have the same viewport, scale factor, locale, timezone, and color scheme. No per-page configuration needed.

This is meaningfully cleaner than Puppeteer, where you'd call page.setViewport() on each page individually and use page.emulateTimezone() and page.emulate() for locale and device settings. In Playwright, one context definition handles it all.

Device Emulation

Playwright ships with a built-in device registry that bundles viewport, scale factor, user agent, and touch support for dozens of devices:

const { chromium, devices } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const iPhone = devices['iPhone 14'];

  const context = await browser.newContext({
    ...iPhone
  });

  const page = await context.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'mobile-screenshot.png' });

  await context.close();
  await browser.close();
})();

The spread operator applies all the device's properties to the context in one line. This is ideal for generating mobile screenshots — you get correct viewport, device pixel ratio, user agent string, and touch emulation without configuring each one separately.

Dark Mode Screenshots

The colorScheme context option is particularly useful for screenshot workflows. Many modern websites support dark mode via the prefers-color-scheme CSS media query. You can capture both variants:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  // Light mode
  const lightContext = await browser.newContext({
    viewport: { width: 1280, height: 720 },
    colorScheme: 'light'
  });
  const lightPage = await lightContext.newPage();
  await lightPage.goto('https://example.com');
  await lightPage.screenshot({ path: 'light-mode.png' });
  await lightContext.close();

  // Dark mode
  const darkContext = await browser.newContext({
    viewport: { width: 1280, height: 720 },
    colorScheme: 'dark'
  });
  const darkPage = await darkContext.newPage();
  await darkPage.goto('https://example.com');
  await darkPage.screenshot({ path: 'dark-mode.png' });
  await darkContext.close();

  await browser.close();
})();

Each context is fully isolated — the dark mode capture doesn't affect the light mode one or vice versa. The site sees prefers-color-scheme: dark in the media query and renders accordingly. No CSS injection or manual toggling needed.

Multiple Contexts for Different Configurations

Because contexts are isolated, you can run multiple configurations against the same browser simultaneously:

const { chromium, devices } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  const configs = [
    { name: 'desktop', options: { viewport: { width: 1280, height: 720 } } },
    { name: 'tablet', options: { ...devices['iPad Pro 11'] } },
    { name: 'mobile', options: { ...devices['iPhone 14'] } }
  ];

  for (const config of configs) {
    const context = await browser.newContext(config.options);
    const page = await context.newPage();
    await page.goto('https://example.com');
    await page.screenshot({ path: `screenshot-${config.name}.png` });
    await context.close();
  }

  await browser.close();
})();

This captures the same page across three device configurations in a clean loop. Each context gets its own viewport, user agent, and device settings without any cross-contamination.

Full-Page Screenshots

By default, Playwright captures only the visible viewport. To capture the entire scrollable page, set the fullPage option to true:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('https://example.com');
  await page.screenshot({
    path: 'full-page.png',
    fullPage: true
  });

  await browser.close();
})();

Playwright's Auto-Scroll Advantage

This is where Playwright has a meaningful edge over Puppeteer for full-page captures. When you take a full-page screenshot with Playwright, it automatically scrolls through the page to calculate the total content height. This scrolling triggers IntersectionObserver callbacks, which is the mechanism most modern websites use for lazy loading images and dynamic content sections.

In practice, this means lazy-loaded images and dynamically rendered content sections are far more likely to appear in your Playwright full-page captures without any extra work on your part.

With Puppeteer, you'd typically need to write a manual scroll function — something that scrolls 400px at a time, pauses for content to load, checks the page height again, and repeats until it reaches the bottom. It's about 20 lines of code you'd need to write, test, and maintain. Our Puppeteer full-page guide walks through this in detail, including handling infinite scroll pages and memory concerns.

Playwright's automatic scrolling doesn't eliminate every edge case — some lazy-loading implementations use custom JavaScript rather than IntersectionObserver, and some content loads based on timers or user interaction rather than scroll position. But for the majority of modern websites, Playwright's full-page behavior works correctly without intervention.

Full-Page with Specific Viewport Width

When capturing full-page screenshots, the viewport width controls the page layout and the height is determined by content:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  const context = await browser.newContext({
    viewport: { width: 1440, height: 900 }
  });

  const page = await context.newPage();
  await page.goto('https://example.com', { waitUntil: 'networkidle' });
  await page.screenshot({
    path: 'full-page-wide.png',
    fullPage: true
  });

  await browser.close();
})();

A wider viewport typically produces a shorter page, since content flows into wider containers. A narrow viewport produces a taller page. Choose the width that matches your target use case — 1280 or 1440 for desktop, 375 for mobile.

Element Screenshots with Locators

Playwright's locator API makes element screenshots both reliable and expressive. Instead of querying the DOM directly with page.$(), you use locators that auto-wait for elements to appear and become visible.

Basic Element Screenshot

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  const element = page.locator('.pricing-table');
  await element.screenshot({ path: 'pricing.png' });

  await browser.close();
})();

The locator automatically waits for .pricing-table to appear in the DOM before taking the screenshot. If the element doesn't appear within the default timeout (30 seconds), you get a clear error message. No null checks needed — unlike Puppeteer's page.$() which returns null if the element doesn't exist, potentially causing a confusing crash on the next line.

You can use any of Playwright's locator strategies:

// By CSS selector
const byCSS = page.locator('.hero-section');

// By text content
const byText = page.locator('text=Sign Up');

// By role
const byRole = page.getByRole('button', { name: 'Submit' });

// By test ID
const byTestId = page.getByTestId('dashboard-chart');

// Combined — element containing specific text
const combined = page.locator('.card').filter({ hasText: 'Premium' });

Each of these can be screenshotted directly:

await page.getByTestId('dashboard-chart').screenshot({ path: 'chart.png' });

Masking Sensitive Data

Playwright has a built-in mask option that overlays colored boxes over specified elements in the screenshot. This is useful for hiding sensitive data like email addresses, account numbers, or personal information in documentation or test screenshots:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com/account');

  await page.screenshot({
    path: 'account-masked.png',
    mask: [
      page.locator('.email-address'),
      page.locator('.account-number'),
      page.locator('.billing-info')
    ]
  });

  await browser.close();
})();

The masked elements appear as solid-colored rectangles (magenta by default) in the screenshot. You can customize the mask color:

await page.screenshot({
  path: 'account-masked.png',
  mask: [page.locator('.email-address')],
  maskColor: '#000000'
});

This is far more convenient than injecting CSS to hide elements manually — the mask applies only to the screenshot output, not to the live page, so it doesn't affect layout or trigger reflows.

Disabling Animations

Animated elements — spinners, transitions, carousels — can produce inconsistent screenshots depending on timing. Playwright lets you freeze animations at their end state:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  await page.locator('.hero-section').screenshot({
    path: 'hero-static.png',
    animations: 'disabled'
  });

  await browser.close();
})();

When animations is set to 'disabled', CSS animations and transitions are fast-forwarded to their final state, and CSS transitions are disabled. This produces deterministic screenshots regardless of when the capture happens during an animation cycle.

You can also apply this to full-page screenshots:

await page.screenshot({
  path: 'page-static.png',
  fullPage: true,
  animations: 'disabled'
});

This is especially valuable for visual regression testing, where you need pixel-perfect consistency between runs.

Wait Strategies

Capturing a screenshot at the right moment is the hardest part of browser automation. Take it too early and you get blank areas or loading spinners. Wait too long and your capture pipeline is slow. Playwright provides several mechanisms for timing your screenshots correctly.

Built-In Auto-Waiting

Playwright's most distinctive feature is automatic waiting. When you interact with a page — clicking, filling, or even taking a locator screenshot — Playwright automatically waits for the target element to be visible, enabled, stable (not moving), and receiving events. This means a lot of timing issues that would require explicit waits in other tools are handled transparently.

For screenshot workflows, this matters most when you need to interact with a page before capturing it — dismissing a cookie banner, clicking a tab to show specific content, or expanding a collapsed section:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  // Playwright auto-waits for the button to be clickable
  await page.getByRole('button', { name: 'Accept Cookies' }).click();

  // Auto-waits for the tab to be clickable
  await page.getByRole('tab', { name: 'Pricing' }).click();

  // Auto-waits for the pricing content to be visible
  await page.locator('.pricing-content').screenshot({ path: 'pricing.png' });

  await browser.close();
})();

No waitForSelector calls needed between actions. Playwright handles the timing of each step automatically.

waitUntil Options for Navigation

The page.goto() method accepts a waitUntil option that controls when navigation is considered complete:

// Wait until the load event fires (default)
await page.goto('https://example.com', { waitUntil: 'load' });

// Wait until the DOMContentLoaded event fires
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

// Wait until there are no network connections for 500ms
await page.goto('https://example.com', { waitUntil: 'networkidle' });

// Wait until the page commits a response (fastest, least reliable)
await page.goto('https://example.com', { waitUntil: 'commit' });

For screenshot workflows, networkidle gives the best results most of the time. It waits until the page has finished loading all its resources — CSS, JavaScript, images, fonts, API calls — and the network has been quiet for at least 500 milliseconds.

The catch is that some pages never reach network idle. Sites with persistent WebSocket connections, analytics pings, or polling requests will keep the network active indefinitely. For those sites, use domcontentloaded or load and combine it with a more targeted wait strategy.

Waiting for Specific Elements

When you know what content needs to appear before your screenshot is complete, wait for it explicitly:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  // Wait for the hero image to be visible
  await page.waitForSelector('.hero-image', { state: 'visible' });

  // Wait for the main content to render
  await page.waitForSelector('.main-content', { state: 'visible' });

  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

The state option can be: - 'visible' — element is present in the DOM and visible (has non-zero size, no display: none, no visibility: hidden) - 'attached' — element is present in the DOM (may or may not be visible) - 'detached' — element is not present in the DOM - 'hidden' — element is either not in the DOM or not visible

For screenshots, 'visible' is almost always what you want.

Waiting for Load State

After navigation, you can wait for additional load states:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Navigate with a basic wait
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  // Then wait for network to settle
  await page.waitForLoadState('networkidle');

  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

This is useful when you navigate with a fast wait condition (domcontentloaded) but then want to ensure the page has fully loaded before capturing. It's also useful after performing actions that trigger additional network requests — clicking a button that loads new content, for example.

Custom Wait Conditions

For complex single-page applications where standard wait strategies aren't sufficient, use page.waitForFunction() to wait for a custom JavaScript expression to evaluate to true:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  // Wait for a React app to finish rendering
  await page.waitForFunction(() => {
    return document.querySelector('#root')?.children.length > 0;
  });

  // Wait for a specific data attribute to appear
  await page.waitForFunction(() => {
    return document.querySelector('[data-loaded="true"]') !== null;
  });

  // Wait for a minimum number of items to render
  await page.waitForFunction(() => {
    return document.querySelectorAll('.product-card').length >= 12;
  });

  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

waitForFunction polls the expression repeatedly until it returns a truthy value. This is the most reliable wait strategy for applications that don't follow standard loading patterns — dashboards that load data from multiple APIs, single-page apps with complex routing, or pages that render content in stages.

You can also pass a timeout to avoid waiting indefinitely:

await page.waitForFunction(
  () => document.querySelector('.chart-rendered') !== null,
  { timeout: 10000 }
);

Output Formats

Playwright supports three image formats: PNG, JPEG, and WebP. Each has different tradeoffs for screenshot use cases.

PNG

PNG is the default format and produces lossless output. Every pixel is preserved exactly as rendered:

await page.screenshot({
  path: 'screenshot.png',
  type: 'png'
});

PNG files support transparency, which matters for element screenshots. If you capture a single element that doesn't fill a rectangular area, the background will be transparent in PNG format. The downside is file size — PNG files are significantly larger than compressed formats, especially for photographic content.

JPEG

JPEG produces smaller files through lossy compression. Quality ranges from 0 to 100:

await page.screenshot({
  path: 'screenshot.jpg',
  type: 'jpeg',
  quality: 85
});

Quality 85 is a good balance for most screenshot use cases — visually indistinguishable from the original at normal viewing sizes, but 5-10x smaller than PNG. JPEG doesn't support transparency, so element screenshots will have a white background.

WebP

WebP offers better compression than JPEG at equivalent visual quality:

await page.screenshot({
  path: 'screenshot.webp',
  type: 'webp',
  quality: 80
});

WebP at quality 80 typically produces smaller files than JPEG at 85 with comparable visual quality. Browser support for WebP is essentially universal at this point, making it a good default choice if you're serving screenshots on the web.

Buffer vs. File

When you omit the path option, Playwright returns the screenshot as a Buffer instead of writing to disk:

const buffer = await page.screenshot({ type: 'png' });
console.log(`Screenshot size: ${buffer.length} bytes`);

This is useful when you want to upload the screenshot to cloud storage, return it in an HTTP response, or process it further without writing a temporary file.

Getting a Base64 String

If you need the screenshot as a base64-encoded string (for embedding in HTML, sending in JSON responses, or storing in a database), convert the buffer:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');

  const buffer = await page.screenshot({ type: 'png' });
  const base64 = buffer.toString('base64');
  const dataUri = `data:image/png;base64,${base64}`;

  console.log(`Data URI length: ${dataUri.length}`);

  await browser.close();
})();

Base64 encoding increases the data size by roughly 33%, so for large screenshots consider using the binary buffer directly and transmitting it with appropriate Content-Type headers instead.

Choosing the Right Format

Here's a quick guide:

  • PNG — Use when you need pixel-perfect output, transparency support, or element screenshots with no background. Best for UI component screenshots and visual regression testing.
  • JPEG — Use when file size matters and the content is photographic or complex. Good for full-page screenshots of content-heavy sites. Quality 80-90 is the sweet spot.
  • WebP — Use when you need the smallest file size and your downstream consumers support WebP. Good default for web-served screenshots.

Advanced Techniques

Style and Script Injection

You often need to modify a page before capturing it. Cookie banners, chat widgets, notification bars, and other overlays can ruin an otherwise clean screenshot. Playwright lets you inject CSS and JavaScript to customize the page before capture.

Injecting Custom CSS

Use page.addStyleTag() to inject CSS that hides unwanted elements:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com', { waitUntil: 'networkidle' });

  // Hide cookie banners, chat widgets, and notification bars
  await page.addStyleTag({
    content: `
      .cookie-banner,
      .chat-widget,
      .notification-bar,
      #intercom-container,
      .drift-widget,
      [class*="cookie-consent"],
      [id*="onetrust"] {
        display: none !important;
      }
    `
  });

  await page.screenshot({ path: 'clean-screenshot.png' });

  await browser.close();
})();

You can also inject CSS from an external file:

await page.addStyleTag({ path: './screenshot-overrides.css' });

This keeps your override styles in a maintainable separate file rather than embedded in strings.

Injecting JavaScript

Use page.addScriptTag() to inject scripts, or page.evaluate() to run JavaScript directly:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com', { waitUntil: 'networkidle' });

  // Remove all fixed/sticky positioned elements
  await page.evaluate(() => {
    const allElements = document.querySelectorAll('*');
    allElements.forEach((el) => {
      const style = getComputedStyle(el);
      if (style.position === 'fixed' || style.position === 'sticky') {
        el.style.display = 'none';
      }
    });
  });

  // Expand all collapsed sections
  await page.evaluate(() => {
    document.querySelectorAll('details:not([open])').forEach((el) => {
      el.setAttribute('open', '');
    });
  });

  await page.screenshot({ path: 'expanded-screenshot.png', fullPage: true });

  await browser.close();
})();

For scripts that should be injected before the page loads (to intercept early rendering), use page.addInitScript():

await page.addInitScript(() => {
  // Override window.confirm to always return true
  window.confirm = () => true;

  // Disable animations globally
  const style = document.createElement('style');
  style.textContent = '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }';
  document.head.appendChild(style);
});

await page.goto('https://example.com');
await page.screenshot({ path: 'no-animations.png' });

addInitScript runs before any page scripts execute, making it ideal for disabling animations, mocking browser APIs, or setting up interceptors.

Network Interception

Playwright's page.route() method lets you intercept and modify network requests. This is powerful for screenshot consistency — you can block ads and trackers that add visual noise, mock API responses to get deterministic content, or replace images with placeholders.

Blocking Ads and Trackers

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Block common ad and tracking domains
  await page.route('**/*', (route) => {
    const url = route.request().url();
    const blockedDomains = [
      'googleads.g.doubleclick.net',
      'pagead2.googlesyndication.com',
      'www.google-analytics.com',
      'connect.facebook.net',
      'platform.twitter.com',
      'cdn.segment.com'
    ];

    if (blockedDomains.some((domain) => url.includes(domain))) {
      return route.abort();
    }

    return route.continue();
  });

  await page.goto('https://example.com');
  await page.screenshot({ path: 'no-ads.png' });

  await browser.close();
})();

Blocking Resource Types

You can also block entire categories of resources to speed up page loading:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Block videos, media, and fonts to speed up loading
  await page.route('**/*', (route) => {
    const resourceType = route.request().resourceType();
    if (['media', 'font', 'video'].includes(resourceType)) {
      return route.abort();
    }
    return route.continue();
  });

  await page.goto('https://example.com');
  await page.screenshot({ path: 'lightweight.png' });

  await browser.close();
})();

Mocking API Responses

For screenshots that depend on dynamic data — dashboards, feeds, user profiles — you can mock API responses to get consistent, deterministic content:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Mock the API response for the dashboard data
  await page.route('**/api/dashboard/stats', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        users: 12847,
        revenue: 94521.30,
        growth: 23.5,
        activeProjects: 342
      })
    });
  });

  await page.goto('https://example.com/dashboard');
  await page.screenshot({ path: 'dashboard-consistent.png' });

  await browser.close();
})();

This ensures your dashboard screenshot always shows the same numbers, regardless of what the real API returns. Useful for documentation, marketing materials, or visual regression tests where you need pixel-perfect consistency.

Authenticated Pages

Many screenshot use cases involve pages behind a login — admin dashboards, user profiles, settings pages, gated content. Playwright's storageState feature lets you save and restore authentication state (cookies, localStorage, sessionStorage) so you can log in once and reuse those credentials across multiple screenshot captures.

Step 1: Log In and Save State

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // Navigate to the login page
  await page.goto('https://example.com/login');

  // Fill in credentials
  await page.fill('input[name="email"]', '[email protected]');
  await page.fill('input[name="password"]', 'your-password');
  await page.click('button[type="submit"]');

  // Wait for navigation to complete after login
  await page.waitForURL('**/dashboard');

  // Save the authenticated state to a file
  await context.storageState({ path: 'auth-state.json' });

  await browser.close();
})();

The auth-state.json file now contains all cookies and localStorage entries from the authenticated session.

Step 2: Reuse State for Screenshots

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  // Create a context with the saved authentication state
  const context = await browser.newContext({
    storageState: 'auth-state.json',
    viewport: { width: 1280, height: 720 }
  });

  const page = await context.newPage();

  // Navigate directly to authenticated pages — no login needed
  await page.goto('https://example.com/dashboard');
  await page.screenshot({ path: 'dashboard.png' });

  await page.goto('https://example.com/settings');
  await page.screenshot({ path: 'settings.png' });

  await page.goto('https://example.com/profile');
  await page.screenshot({ path: 'profile.png' });

  await browser.close();
})();

The context starts with all the cookies from the previous login, so https://example.com/dashboard loads directly without a redirect to the login page. This is significantly faster than logging in before every screenshot, and it's more reliable since you separate the login logic from the capture logic.

Keep in mind that auth-state.json contains session tokens, so treat it as a secret. Don't commit it to version control, and regenerate it when sessions expire.

Parallel Capture with Multiple Contexts

For high-throughput screenshot workflows, you can run multiple browser contexts in parallel within the same browser process. Each context is isolated, so they won't interfere with each other:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();

  const urls = [
    'https://example.com',
    'https://example.com/about',
    'https://example.com/pricing',
    'https://example.com/blog',
    'https://example.com/docs'
  ];

  // Capture all URLs in parallel
  const screenshots = await Promise.all(
    urls.map(async (url, index) => {
      const context = await browser.newContext({
        viewport: { width: 1280, height: 720 }
      });
      const page = await context.newPage();

      try {
        await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
        const buffer = await page.screenshot({ type: 'png' });
        return { url, buffer, success: true };
      } catch (error) {
        return { url, error: error.message, success: false };
      } finally {
        await context.close();
      }
    })
  );

  // Process results
  for (const result of screenshots) {
    if (result.success) {
      const fs = require('fs');
      const filename = result.url.replace(/[^a-z0-9]/gi, '_') + '.png';
      fs.writeFileSync(filename, result.buffer);
      console.log(`Captured: ${result.url} -> ${filename}`);
    } else {
      console.error(`Failed: ${result.url}${result.error}`);
    }
  }

  await browser.close();
})();

Each context creates its own page, navigates, captures, and cleans up independently. The Promise.all runs them concurrently. The try/finally pattern ensures contexts are always closed, even if navigation or capture fails.

Be mindful of resource usage — each concurrent context with a loaded page consumes memory. On a machine with 4 GB of RAM, 5-10 concurrent contexts is a reasonable ceiling. Beyond that, you'll want to use a semaphore pattern to limit concurrency:

async function captureWithConcurrencyLimit(browser, urls, maxConcurrent = 5) {
  const results = [];
  const executing = new Set();

  for (const url of urls) {
    const promise = (async () => {
      const context = await browser.newContext({
        viewport: { width: 1280, height: 720 }
      });
      const page = await context.newPage();

      try {
        await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
        const buffer = await page.screenshot({ type: 'png' });
        return { url, buffer, success: true };
      } catch (error) {
        return { url, error: error.message, success: false };
      } finally {
        await context.close();
      }
    })();

    executing.add(promise);
    promise.then(() => executing.delete(promise));

    if (executing.size >= maxConcurrent) {
      await Promise.race(executing);
    }

    results.push(promise);
  }

  return Promise.all(results);
}

This pattern keeps at most maxConcurrent captures running at any time, queuing additional URLs until a slot opens up. It's a practical approach for processing large batches of screenshots without overwhelming the machine.

Debugging with Trace Viewer

When a screenshot doesn't capture what you expect — blank areas, missing content, wrong layout — Playwright's trace viewer is the best debugging tool available. It records every action, network request, DOM snapshot, and console message during a browser session, letting you step through the capture timeline and see exactly what happened.

Recording a Trace

Enable tracing on a context before performing actions:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1280, height: 720 }
  });

  // Start tracing
  await context.tracing.start({
    screenshots: true,
    snapshots: true,
    sources: true
  });

  const page = await context.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'screenshot.png' });

  // Stop tracing and save to a file
  await context.tracing.stop({ path: 'trace.zip' });

  await browser.close();
})();

The screenshots option captures a screenshot at each step. The snapshots option records DOM snapshots that you can inspect. The sources option includes your test source code in the trace for context.

Viewing the Trace

Open the trace file with Playwright's built-in viewer:

npx playwright show-trace trace.zip

This opens a visual timeline showing every action, the page state at each step, network requests, console logs, and before/after screenshots. You can click on any step to see the DOM state and the screenshot at that point in time.

You can also view traces in Playwright's online viewer at trace.playwright.dev — just drag and drop the zip file. This is convenient for sharing traces with team members who don't have Playwright installed locally.

Headed Mode for Visual Debugging

For quick debugging, run the browser in headed mode so you can watch what's happening:

const browser = await chromium.launch({
  headless: false,
  slowMo: 500 // slow down each action by 500ms
});

The slowMo option adds a delay between each Playwright action, making it easier to follow the sequence visually. This is the fastest way to debug obvious issues — you can see if the page is loading correctly, whether elements are visible, and when the screenshot is taken.

For a more interactive debugging experience, add a page.pause() call to stop execution and open Playwright's inspector:

const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
await page.goto('https://example.com');

// Execution pauses here — inspect the page, run commands in the console
await page.pause();

await page.screenshot({ path: 'screenshot.png' });

The inspector lets you try locators, run page commands, and step through actions one at a time. It's invaluable when you're trying to figure out the right selector or wait condition for a complex page.

Python Examples

Playwright's Python SDK provides the same capabilities as Node.js with Pythonic syntax. Here are the key screenshot operations in Python.

Synchronous API — Basic Screenshot

The synchronous API is the simplest to use and works well for scripts and command-line tools:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(viewport={'width': 1280, 'height': 720})
    page = context.new_page()

    page.goto('https://example.com')
    page.screenshot(path='screenshot.png')

    browser.close()

Synchronous API — Element Screenshot

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

    # Locator auto-waits for the element to appear
    page.locator('.pricing-table').screenshot(path='pricing.png')

    # Screenshot with mask
    page.screenshot(
        path='masked.png',
        mask=[page.locator('.email'), page.locator('.phone')]
    )

    browser.close()

Asynchronous API — Basic Screenshot

The async API is better for applications that need to handle concurrent operations, web servers, or integration with other async frameworks:

import asyncio
from playwright.async_api import async_playwright

async def capture_screenshot():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context(
            viewport={'width': 1280, 'height': 720},
            device_scale_factor=2
        )
        page = await context.new_page()

        await page.goto('https://example.com', wait_until='networkidle')
        await page.screenshot(path='screenshot.png')

        await browser.close()

asyncio.run(capture_screenshot())

Asynchronous API — Authenticated Pages

import asyncio
from playwright.async_api import async_playwright

async def save_auth_state():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context()
        page = await context.new_page()

        await page.goto('https://example.com/login')
        await page.fill('input[name="email"]', '[email protected]')
        await page.fill('input[name="password"]', 'your-password')
        await page.click('button[type="submit"]')
        await page.wait_for_url('**/dashboard')

        # Save authentication state
        await context.storage_state(path='auth-state.json')
        await browser.close()

async def capture_authenticated_pages():
    async with async_playwright() as p:
        browser = await p.chromium.launch()

        # Reuse saved authentication
        context = await browser.new_context(
            storage_state='auth-state.json',
            viewport={'width': 1280, 'height': 720}
        )
        page = await context.new_page()

        await page.goto('https://example.com/dashboard')
        await page.screenshot(path='dashboard.png')

        await page.goto('https://example.com/settings')
        await page.screenshot(path='settings.png')

        await browser.close()

asyncio.run(save_auth_state())
asyncio.run(capture_authenticated_pages())

Asynchronous API — Parallel Capture

import asyncio
from playwright.async_api import async_playwright

async def capture_url(browser, url, filename):
    context = await browser.new_context(
        viewport={'width': 1280, 'height': 720}
    )
    page = await context.new_page()

    try:
        await page.goto(url, wait_until='networkidle', timeout=30000)
        await page.screenshot(path=filename)
        print(f'Captured: {url} -> {filename}')
    except Exception as e:
        print(f'Failed: {url}{e}')
    finally:
        await context.close()

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()

        urls = [
            ('https://example.com', 'home.png'),
            ('https://example.com/about', 'about.png'),
            ('https://example.com/pricing', 'pricing.png'),
            ('https://example.com/blog', 'blog.png'),
        ]

        # Run all captures concurrently
        tasks = [capture_url(browser, url, filename) for url, filename in urls]
        await asyncio.gather(*tasks)

        await browser.close()

asyncio.run(main())

Python's asyncio.gather is the equivalent of JavaScript's Promise.all, running multiple captures concurrently within the same browser process.

Production Considerations

Running Playwright screenshot capture in production introduces challenges beyond what you encounter in development scripts.

Resource Management

Each browser context with a loaded page consumes 50-300 MB of RAM depending on page complexity. For production services, manage browser lifecycle carefully:

const { chromium } = require('playwright');

class ScreenshotService {
  constructor() {
    this.browser = null;
  }

  async initialize() {
    this.browser = await chromium.launch({
      args: ['--disable-dev-shm-usage', '--no-sandbox']
    });
  }

  async capture(url, options = {}) {
    const context = await this.browser.newContext({
      viewport: options.viewport || { width: 1280, height: 720 }
    });

    const page = await context.newPage();

    try {
      await page.goto(url, {
        waitUntil: 'networkidle',
        timeout: options.timeout || 30000
      });
      return await page.screenshot({
        type: options.format || 'png',
        quality: options.quality,
        fullPage: options.fullPage || false
      });
    } finally {
      await context.close();
    }
  }

  async shutdown() {
    if (this.browser) {
      await this.browser.close();
    }
  }
}

Key points for production:

  • Reuse the browser instance across requests. Launching a new browser for each screenshot is expensive — it takes 1-3 seconds and consumes significant CPU and memory. Launch once and create new contexts for each capture.
  • Always close contexts in a finally block. Leaked contexts accumulate memory over time and will eventually crash the browser process.
  • Set timeouts on everything. Navigation, wait conditions, and screenshot operations should all have explicit timeouts to prevent hanging requests from consuming resources indefinitely.
  • Use --disable-dev-shm-usage on Linux. By default, Chrome uses /dev/shm for shared memory, which is often limited in Docker containers (typically 64 MB). This flag tells Chrome to use /tmp instead.

Browser Reuse and Health Checks

Browser processes can become unhealthy over time — memory leaks, crashed tabs, or corrupted state. Implement periodic restarts:

async function ensureHealthyBrowser(service, maxAge = 3600000) {
  if (!service.browser || !service.browser.isConnected()) {
    await service.initialize();
    service.launchTime = Date.now();
  }

  // Restart every hour to prevent memory leaks
  if (Date.now() - service.launchTime > maxAge) {
    await service.shutdown();
    await service.initialize();
    service.launchTime = Date.now();
  }
}

Docker Considerations

Running Playwright in Docker requires installing browser dependencies. Playwright provides official Docker images that handle this:

FROM mcr.microsoft.com/playwright:v1.50.0-noble

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

CMD ["node", "server.js"]

The official Playwright Docker image includes all system dependencies, fonts, and browser binaries. If you're building your own image, use npx playwright install-deps to install system libraries and npx playwright install chromium to install only the browser you need.

Set appropriate resource limits in your Docker configuration:

services:
  screenshot:
    image: your-screenshot-service
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: "2"
    shm_size: "1gb"

The shm_size setting is important — Chrome uses shared memory for rendering, and the default 64 MB in Docker is not enough for complex pages.

When to Consider an API Instead

Playwright is an excellent tool when you need full browser automation — interacting with pages, filling forms, testing user flows, scraping dynamic content. For those use cases, there's no substitute for having direct control over a real browser.

But if your primary goal is capturing web page screenshots — for social cards, link previews, documentation images, or visual monitoring — running browser infrastructure introduces significant operational overhead. You're managing browser processes, handling memory limits, configuring fonts, tuning wait strategies, and scaling rendering capacity. None of that work is directly related to your product.

RenderScreenshot handles the entire browser infrastructure. A single HTTP request replaces the browser launch, context creation, navigation, waiting, capture, and cleanup:

curl "https://api.renderscreenshot.com/v1/screenshot?url=https://example.com&width=1280&height=720" \
  -H "Authorization: Bearer rs_live_..."

You get the same capabilities — viewport control, device emulation, full-page capture, element screenshots, wait strategies — through request parameters instead of code. Screenshots are rendered on edge infrastructure, cached on a global CDN, and returned as optimized images.

No browser processes to manage. No Docker images to maintain. No memory leaks to debug. No font packages to install across environments.

Sign up free and get 50 credits to test it with your URLs. That's enough to validate the API fits your workflow before making a decision.


Have questions about Playwright screenshots? Check our documentation or reach out at [email protected].