ScreenshotNeo

BlogHow-to

Fix a Playwright Web App Screenshot Stuck on Loading

Find which Playwright operation is waiting, choose a meaningful app-ready signal, and capture only after the content you need is ready.

By the ScreenshotNeo team4 October 202610 min read

If a Playwright screenshot shows a loading screen, first determine whether Playwright is still waiting or whether the capture completed before your app finished rendering. Navigation completion and application readiness are different things. Find the specific awaited operation, wait for a stable signal that the content you need is ready, and then take the screenshot.

A screenshot containing a spinner does not by itself mean page.screenshot() is hung. The awaited call could be navigation, a load-state wait, a locator or assertion, or the screenshot call itself. The right fix depends on which one is pending.

1. Find the operation that is waiting

Start with the timeout message, stack trace, and the last log line printed before the wait. Identify the exact awaited call:

  • page.goto(): navigation did not reach its requested milestone before the timeout.
  • page.waitForLoadState(): the requested document load state was not reached, or the call is waiting on a milestone that does not reflect app readiness.
  • A locator or web assertion: the expected element or state did not appear before its timeout.
  • page.screenshot(): the capture operation itself did not finish, possibly because of its timeout or page behavior.

Log before and after each awaited operation to narrow down the pending step. If the code returns an image but it contains a spinner, the capture completed; investigate the app-ready condition rather than treating that image as proof that screenshot capture hung.

console.log('before navigation');
await page.goto('https://your-app.example');
console.log('after navigation');

console.log('before app-ready wait');
await page.getByRole('heading', { name: 'Dashboard' }).waitFor({ state: 'visible' });
console.log('after app-ready wait');

console.log('before screenshot');
await page.screenshot({ path: 'dashboard.png' });
console.log('after screenshot');

Replace the example URL and heading with values from your app. Use a selector or accessible role that identifies the real content needed in the screenshot; there is no universal selector for a finished app.

2. Choose the navigation milestone you actually need

For navigation operations, Playwright supports commit, domcontentloaded, load, and networkidle. The default for page.goto() is load. These milestones describe document navigation; none guarantees that a single-page app has completed its API requests or rendered the data you want. See the Playwright Page API documentation.

Milestone What it means When it can help
commit The response arrived and document loading began. When you need to know navigation has started and will wait separately for app content.
domcontentloaded The document’s DOMContentLoaded event fired. When the initial document has been parsed and your next step uses an app-specific readiness signal.
load The document’s load event fired. When the page’s load event is the milestone your workflow requires. This is the default for page.goto().
networkidle No network connections for at least 500 ms. Playwright discourages using it as a testing readiness condition; ongoing requests can prevent it, and network quiet does not prove the app is ready.

Playwright’s guidance is explicit: networkidle is discouraged for testing; rely on web assertions to assess readiness instead. Apps that poll, stream data, or keep connections open may never become network-idle. Even when requests stop briefly, that does not establish that the particular content in your screenshot is ready.

await page.goto('https://your-app.example', { waitUntil: 'domcontentloaded' });
// Wait for the app-specific signal before capturing.
await page.getByRole('heading', { name: 'Dashboard' }).waitFor({ state: 'visible' });
await page.screenshot({ path: 'dashboard.png' });

Use waitForLoadState() only when the sequence needs that document milestone. Playwright notes that it is often unnecessary because actions wait automatically, and a call made after the state has already been reached resolves immediately.

3. Wait for a signal that means your app is ready

Choose a condition tied to the actual content or state required in the image. Examples include a page heading becoming visible, a result row appearing, or a loading indicator disappearing. Prefer a stable, meaningful locator or web assertion over a guessed duration. Locator actions and web assertions wait automatically; Playwright warns that timer-based waits in production tests are flaky. See the Page API guidance.

Wait for required content

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

test('captures the dashboard after its content appears', async ({ page }) => {
  await page.goto('https://your-app.example', { waitUntil: 'domcontentloaded' });

  // Replace this with a stable signal that the screenshot actually needs.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByRole('row', { name: /Monthly revenue/ })).toBeVisible();

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

Wait for a loading indicator to disappear

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

// Use the app's actual loading indicator selector or accessible locator.
await page.locator('[data-testid="loading-indicator"]').waitFor({ state: 'hidden' });
await page.getByRole('heading', { name: 'Dashboard' }).waitFor({ state: 'visible' });
await page.screenshot({ path: 'dashboard.png' });

For the hidden-state wait, the selector must identify the real loading indicator. If it is absent from the page from the start, a hidden wait may resolve immediately; pair it with a positive check for the content you need when that distinction matters. If the app can show a transient empty state, wait for a result or other final-state signal as well.

4. Complete runnable examples

The examples below use a sample dashboard URL and app-specific signals. Change the URL, heading, and result locator to match your app. Each example waits for document parsing, then waits for content before capturing.

Playwright Test with TypeScript

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

test('captures a ready dashboard', async ({ page }) => {
  await page.goto('https://your-app.example/dashboard', {
    waitUntil: 'domcontentloaded',
    timeout: 30_000,
  });

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({
    timeout: 15_000,
  });
  await expect(page.getByRole('row', { name: /Monthly revenue/ })).toBeVisible({
    timeout: 15_000,
  });

  await page.screenshot({ path: 'dashboard.png', fullPage: true, timeout: 15_000 });
});

Playwright for Node.js

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

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

  try {
    await page.goto('https://your-app.example/dashboard', {
      waitUntil: 'domcontentloaded',
      timeout: 30_000,
    });

    await page.getByRole('heading', { name: 'Dashboard' }).waitFor({
      state: 'visible',
      timeout: 15_000,
    });
    await page.getByRole('row', { name: /Monthly revenue/ }).waitFor({
      state: 'visible',
      timeout: 15_000,
    });

    await page.screenshot({ path: 'dashboard.png', fullPage: true, timeout: 15_000 });
  } finally {
    await browser.close();
  }
})();

Playwright for Python

from playwright.sync_api import sync_playwright

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    page = browser.new_page()
    try:
        page.goto(
            "https://your-app.example/dashboard",
            wait_until="domcontentloaded",
            timeout=30_000,
        )
        page.get_by_role("heading", name="Dashboard").wait_for(
            state="visible", timeout=15_000
        )
        page.get_by_role("row", name="Monthly revenue").wait_for(
            state="visible", timeout=15_000
        )
        page.screenshot(path="dashboard.png", full_page=True, timeout=15_000)
    finally:
        browser.close()

cURL

cURL does not run Playwright or wait on browser locators. It is useful for checking whether a URL responds, but an HTTP response alone cannot establish that a client-rendered app has finished rendering.

curl -L --max-time 30 -o response.html https://your-app.example/dashboard

5. Set timeouts at the right scope

Increasing a timeout can be appropriate when an operation is valid but predictably slow. It does not fix a readiness condition that can never be satisfied. Configure the timeout for the operation that needs it, and keep navigation, app readiness, and screenshot capture waits distinguishable in logs.

// Navigation timeout for this call
await page.goto('https://your-app.example', {
  waitUntil: 'domcontentloaded',
  timeout: 30_000,
});

// Readiness wait has its own timeout
await page.getByRole('heading', { name: 'Dashboard' }).waitFor({
  state: 'visible',
  timeout: 15_000,
});

// Screenshot operation timeout
await page.screenshot({ path: 'dashboard.png', timeout: 15_000 });

Playwright also allows defaults at the page or browser-context level. If you change a default, record it clearly: a broad timeout can make a broken or unreachable readiness condition take longer to diagnose. Consult the Page API for the available timeout controls and their scope.

6. Troubleshoot common loading and capture failures

Symptom Likely explanation What to do
page.goto() times out The requested navigation milestone was not reached in time, or the page did not respond as expected. Confirm the URL and navigation behavior, identify the requested waitUntil milestone, inspect request failures, and use an earlier milestone only if you will separately wait for app readiness.
waitForLoadState('networkidle') waits indefinitely The app keeps making requests, or network quiet is the wrong proxy for readiness. Replace it with a locator or web assertion tied to required content. Playwright discourages networkidle for testing.
A locator or assertion times out The locator does not match, the element is hidden, the app has not reached that state, or an error prevented rendering. Check the locator against the actual page, inspect the rendered state, and look for console errors and failed requests. Assert a signal the app really exposes.
The image contains a spinner, but the call completed The screenshot was taken before the app’s content was ready. Wait for the desired content or for a meaningful loading-state transition, then capture.
page.screenshot() times out The screenshot operation exceeded its configured timeout or capture could not complete. Confirm the stack trace points to the screenshot call, check its timeout and requested capture scope, and retry after diagnosing the page state.
A fixed delay works sometimes Load duration varies, so the elapsed time does not reliably identify readiness. Use the delay only as a temporary debugging probe. Replace it with an app-specific locator or assertion in production tests.
Capture works locally but differs elsewhere Rendering can vary by OS, browser version, settings, hardware, power source, or headless mode. Keep the screenshot environment consistent and treat visual variation separately from a wait that times out. Playwright documents these sources of rendering differences in its visual comparisons guidance.

Inspect browser and network diagnostics

If the expected state never appears, inspect the browser console and request failures. These diagnostics can show that a script threw an error, an API request failed, or a resource was blocked. The title alone cannot identify a universal root cause; the pending call, timeout, observed page state, and browser or network errors are needed to diagnose a specific app.

page.on('console', message => {
  if (message.type() === 'error') console.error('Browser console:', message.text());
});

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

7. Make screenshots reliable and manage performance

  • Wait for the minimum useful signal. A specific heading or result is usually a clearer readiness condition than waiting for every possible request to stop.
  • Keep waits scoped. Give navigation, content readiness, and screenshot capture their own visible timeout boundaries so logs reveal which stage failed.
  • Use stable locators. Prefer semantic roles or selectors your app deliberately provides. Avoid selectors based on incidental layout when a stable app signal exists.
  • Use fixed delays only to investigate. A delay may help you observe whether content eventually appears, but it can waste time on fast runs and still be too short on slow ones.
  • Keep visual environments consistent. Browser and operating-system differences can change rendered images even when the wait completes successfully.

Increasing timeouts can improve resilience to slow but valid operations; it also increases the time spent waiting when an operation is stuck. A readiness assertion improves diagnosis because it states what the screenshot requires, but it must match a state the app actually reaches. Playwright’s visual comparison documentation also explains why screenshot differences can occur across environments independently of loading.

8. Or skip the browser setup

If you need a website screenshot without managing a browser session, ScreenshotNeo provides a screenshot API and MCP server. One request returns an image or PDF. Its clean-shot flow accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status.

See the ScreenshotNeo API documentation for request options and setup.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

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)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card.

9. FAQ

Does page.screenshot() wait for my app’s API calls?

Do not assume that navigation or screenshot capture means your app-specific data is ready. Wait for a locator or assertion that represents the content the image needs.

Should I always use domcontentloaded instead of load?

No. Choose the navigation milestone that fits the sequence. If your screenshot depends on client-rendered data, use an app-ready signal regardless of which document milestone you choose.

Is a screenshot with a loading spinner a Playwright bug?

The image alone cannot establish that. Check whether the capture completed and which awaited operation was pending; the spinner may simply mean the app was not ready when capture occurred.

What information is useful when asking for help?

Share the awaited call and its timeout message, the relevant code, the app state at the time, and any related browser console or request errors. Those details help distinguish navigation, readiness, and capture problems.

Sources