ScreenshotNeo

BlogEngineering

Why Playwright Screenshots Are Slow and How to Fix Them

Find the real cause of slow Playwright screenshots and fix scope, scaling, animations, readiness waits, fonts, and visual comparison retries.

By the ScreenshotNeo team1 October 20268 min read

Playwright screenshots are usually slow for one of five reasons: too much content is being captured, the output has too many device pixels, the page is still changing, the test is waiting on an arbitrary delay, or a visual assertion is repeatedly comparing large images. Fix them in that order: reduce the capture area, use scale: "css" when physical-pixel output is unnecessary, disable animations, replace sleeps with readiness signals, then profile fonts, images, and screenshot comparison.

The examples below cover Playwright’s page, locator, and visual-assertion APIs in Node.js and Python. The official documentation covers screenshots, the Page screenshot API, and page.waitForTimeout().

1. Identify which screenshot operation is slow

Start by measuring the exact operation. A slow page.screenshot() has different causes from a slow locator.screenshot() or expect(page).toHaveScreenshot().

import { test, expect } from '@playwright/test';

test('measure screenshot phases', async ({ page }) => {
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

  const t0 = performance.now();
  await page.screenshot({ path: 'page.png' });
  console.log(`page.screenshot: ${(performance.now() - t0).toFixed(0)} ms`);

  const t1 = performance.now();
  await page.locator('main').screenshot({ path: 'main.png' });
  console.log(`locator.screenshot: ${(performance.now() - t1).toFixed(0)} ms`);

  const t2 = performance.now();
  await expect(page).toHaveScreenshot('page.png');
  console.log(`toHaveScreenshot: ${(performance.now() - t2).toFixed(0)} ms`);
});

Record the Playwright version, browser engine, viewport, device scale factor, CI machine, URL, screenshot options, and elapsed time. Without those values, a timing comparison is difficult to reproduce.

2. Capture less content

Viewport capture

A normal page screenshot captures the current viewport. It is generally the cheapest choice when the behavior under test is visible without scrolling.

await page.screenshot({ path: 'viewport.png', fullPage: false });

Element capture

Use a locator when the test concerns one component. This avoids rendering and encoding unrelated page content.

await page.locator('[data-testid="checkout-summary"]').screenshot({
  path: 'checkout-summary.png'
});

Clipped capture

A clip is useful when a stable rectangle answers the test question.

await page.screenshot({
  path: 'chart.png',
  clip: { x: 80, y: 120, width: 900, height: 500 }
});

Use full-page mode only when the whole document matters

fullPage: true captures the entire scrollable page. Long documents, many images, sticky elements, large canvases, and complex layout all increase the rendering and image-encoding work.

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

Before enabling full-page capture, ask whether an element, viewport, or clip proves the behavior. For a full-page requirement, reduce page weight where possible and make lazy-loaded content deterministic.

3. Reduce unnecessary device pixels

Playwright supports scale: "css" and scale: "device". Device scale can produce a larger image on high-DPI environments. Use CSS scale when a report or regression artifact does not require physical-pixel fidelity.

await page.screenshot({
  path: 'css-scale.png',
  scale: 'css'
});

Use scale: "device" when you explicitly need device-pixel output, such as checking a high-DPI rendering issue.

await page.screenshot({
  path: 'device-scale.png',
  scale: 'device'
});

Scale changes pixel count, file size, and visual-comparison work. Keep the setting consistent between baseline generation and comparison.

4. Stop animations and unstable motion

Moving pixels prevent a screenshot from representing a stable state. Playwright’s screenshot options can disable animations. Finite animations are fast-forwarded and infinite animations are canceled for the capture workflow.

await page.screenshot({
  path: 'stable.png',
  animations: 'disabled'
});

You can also hide known dynamic elements with the screenshot style option.

await page.screenshot({
  path: 'stable-dashboard.png',
  animations: 'disabled',
  style: `
    [data-live-clock],
    [data-random-avatar],
    . 광고, .ad, [aria-live="polite"] {
      visibility: hidden !important;
    }
  `
});

Remove selectors that do not exist in your application; the example demonstrates the technique. Prefer stable test attributes over broad selectors.

5. Replace fixed sleeps with readiness signals

page.waitForTimeout() should only be used for debugging. The Playwright documentation warns that tests waiting for time are inherently flaky. A fixed delay can be both too short, causing failures, and too long, wasting time.

Wait for a locator

await page.goto('https://example.com/dashboard');
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await page.screenshot({ path: 'dashboard.png' });

Wait for a specific application marker

await page.goto('https://example.com/dashboard');
await page.locator('[data-page-ready="true"]').waitFor({ state: 'attached' });
await page.screenshot({ path: 'dashboard.png' });

Wait for a required response

const responsePromise = page.waitForResponse(
  response => response.url().endsWith('/api/report') && response.ok()
);
await page.goto('https://example.com/report');
await responsePromise;
await page.screenshot({ path: 'report.png' });

Use network idle carefully

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

Network idle can be unsuitable for applications with analytics, polling, advertisements, or long-lived connections. Prefer the selector or response that represents the state your test actually needs.

6. Make full-page and lazy-loaded pages deterministic

Full-page capture may expose content that was not loaded in the initial viewport. If your application lazy-loads images, scroll or trigger the application’s loading mechanism before capture, then wait for the relevant images.

await page.goto('https://example.com/catalog', { waitUntil: 'domcontentloaded' });
await page.evaluate(async () => {
  for (const image of document.images) image.scrollIntoView({ block: 'center' });
});
await page.locator('img').evaluateAll(images =>
  Promise.all(images.map(image => image.complete
    ? Promise.resolve()
    : new Promise(resolve => {
        image.addEventListener('load', resolve, { once: true });
        image.addEventListener('error', resolve, { once: true });
      })))
);
await page.screenshot({ path: 'catalog.png', fullPage: true });

Use an application-level loaded marker when possible. Waiting for every image can itself be slow on pages with optional or third-party media.

7. Check fonts and other rendering dependencies

Fonts can delay layout and cause screenshot failures when they cannot be loaded. Inspect font URLs, browser console errors, network failures, authentication, and cross-origin policy. Compare the exact Playwright and browser versions used locally and in CI.

page.on('console', message => console.log(`[console:${message.type()}] ${message.text()}`));
page.on('requestfailed', request => {
  console.log(`request failed: ${request.url()} — ${request.failure()?.errorText}`);
});
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'fonts-ready.png' });

A Playwright issue discusses PW_TEST_SCREENSHOT_NO_FONTS_READY=1 during investigation of font-related screenshot timeouts. Treat that variable as a diagnostic experiment, not a universal performance fix; verify it against your Playwright and browser versions before using it.

8. Understand why toHaveScreenshot() can be much slower

Visual assertions wait until two consecutive screenshots match before comparing the result with the expected snapshot. On a large or changing page, repeated captures and pixel comparisons can dominate runtime.

await expect(page).toHaveScreenshot('home.png', {
  animations: 'disabled',
  scale: 'css',
  maxDiffPixels: 100
});

Use comparison thresholds only when small rendering differences are acceptable for the test. A threshold should not hide real regressions.

Reduce assertion cost

  • Assert on a locator instead of the whole page when the feature is local.
  • Use scale: "css" when device pixels are unnecessary.
  • Disable animations and stabilize timestamps, random data, ads, and live counters.
  • Keep the snapshot region as small as the requirement allows.
  • Inspect retry logs to determine whether capture or image comparison is taking the time.
await expect(page.locator('[data-testid="invoice"]')).toHaveScreenshot(
  'invoice.png',
  { animations: 'disabled', scale: 'css' }
);

9. Complete Node.js example

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });

page.on('requestfailed', request => {
  console.error('request failed:', request.url(), request.failure()?.errorText);
});

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.locator('body').waitFor({ state: 'attached' });
await page.evaluate(() => document.fonts.ready);

await page.screenshot({
  path: 'example.webp',
  type: 'webp',
  scale: 'css',
  animations: 'disabled'
});

await browser.close();

10. Complete Python example

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 1280, "height": 800})
    page.goto("https://example.com", wait_until="domcontentloaded")
    page.locator("body").wait_for(state="attached")
    page.evaluate("document.fonts.ready")
    page.screenshot(
        path="example.png",
        full_page=False,
        animations="disabled",
        scale="css",
    )
    browser.close()

11. Troubleshooting checklist

Symptom Likely cause Fix
Full-page screenshots take far longer than viewport shots Long document, many images, complex layout, or lazy loading Capture a locator or clip; otherwise load only required content and use deterministic readiness checks.
Screenshot output is unexpectedly huge Device-pixel scale on a high-DPI context Use scale: "css" when physical pixels are not required.
Screenshot never stabilizes Animations, transitions, polling, clocks, random data, or live widgets Set animations: "disabled"; hide or mock dynamic elements and wait for a stable marker.
Tests spend seconds in toHaveScreenshot() Repeated captures and large pixel comparisons Compare a smaller locator, use CSS scale, stabilize content, and inspect retries.
waitForTimeout() must be increased in CI The test has no real readiness condition Wait for a locator, response, state attribute, or application-ready signal.
Fonts differ or screenshot times out Font request failure, blocked URL, authentication, or version-specific behavior Inspect network and console errors, await document.fonts.ready, and verify Playwright/browser versions.
Images are blank in full-page output Lazy loading or failed image requests Trigger the loading mechanism, wait for required images, and inspect failed requests.
Local capture is fast but CI is slow Different CPU, browser, viewport, fonts, network, or device scale Record environment details and compare the same browser and options.

12. Performance and reliability checklist

  • Measure page.screenshot, locator.screenshot, and toHaveScreenshot separately.
  • Choose viewport, locator, or clip before full-page mode.
  • Use CSS scale unless device-pixel fidelity is part of the requirement.
  • Disable animations and neutralize clocks, random values, live counters, and third-party widgets.
  • Replace fixed sleeps with selectors, responses, and application readiness markers.
  • Confirm font and image requests succeed.
  • Keep baseline and comparison settings identical.
  • Record Playwright version, browser, viewport, CI hardware, and URL with timing data.

Or skip the browser setup

If you need a clean website image rather than a browser test artifact, ScreenshotNeo provides a single screenshot API request. Its capture can accept cookie or consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the result in X-Page-Verdict and X-Billed headers. ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

There are 1,000 screenshots a month free with no card. Paid plans start at $5 for 3,000 shots, and every feature is on every plan. Create a free ScreenshotNeo account.

FAQ

Is fullPage: true always slower?

Usually, because it captures more scrollable content and may trigger more layout, image, and encoding work. The exact cost depends on page length and complexity.

Should I always use waitUntil: "networkidle"?

No. Polling, analytics, advertisements, and persistent connections can prevent a useful idle point. A selector or response tied to the required state is often more precise.

Does disabling animations change the application?

It changes the capture workflow so the image is stable. Use it for deterministic screenshots; leave animations enabled when motion itself is what you are testing.

Why can a small visual assertion still be slow?

The assertion may take multiple screenshots while waiting for two consecutive images to match. Dynamic content or font loading can cause extra attempts even when the final region is small.

What should I optimize first?

Measure the operation, then reduce scope. After that, use CSS scale, disable motion, replace sleeps with readiness signals, and investigate fonts, images, and comparison retries.