· Tutorials · By Simon Chiu

Playwright Wait for Page Load: What to Wait For

How to make Playwright wait for a page to load before a screenshot: load states, why networkidle is discouraged, locator auto-waiting, and timed results.

To make Playwright wait for a page before a screenshot, navigate with the default load state and then wait for the thing you need to see, not for the network:

await page.goto(url);                                  // waits for the load event
await page.getByTestId('price').waitFor();             // waits for the content you care about
await page.screenshot({ path: 'page.png' });

networkidle looks like the safe choice, and it is the option most people reach for first. On our test pages it timed out on two of five pages and added 500 ms to every page where it did work. This post shows the timings, explains why, and gives a decision table for the cases where load alone is not enough. It is a spoke of the Playwright screenshot guide, which covers the rest of the capture options.

Tested on 2026-10-18 with Node.js 22.18.0, [email protected] (Chromium 156.0.8078.4), macOS on an Apple M1 Pro. Every snippet on this page ran in that setup.

What "loaded" means to Playwright

Playwright tracks four milestones for a navigation. page.goto, page.reload, page.goBack and page.waitForURL take one of them as waitUntil:

State Fires when Default for
commit The response headers arrived and the document started to load Nothing
domcontentloaded The HTML is parsed and synchronous scripts ran Nothing
load The load event fired: images, stylesheets and iframes in the initial HTML are done page.goto, page.reload
networkidle No network request was in flight for at least 500 ms Nothing. Discouraged in the docs

None of these states knows about data that JavaScript fetches after load. A React or Vue page that renders "Loading..." and then calls an API is "loaded" by every definition above while it still shows a spinner. This is the root of most blank or half-rendered screenshots. The glossary entry on wait strategies explains the same states for other tools.

The test pages

We built five small pages, each with one behaviour that breaks a naive wait. A local Node http server served them, so network jitter did not affect the numbers.

Page Behaviour
/static Plain HTML with a heading. The control.
/delayed-data Renders "Loading..." and fetches /api/data, which answers after 1,500 ms.
/polling Same data fetch, plus a fetch('/api/ping') every 400 ms, like a status poller.
/slow-image A heading and one <img> whose response takes 2,000 ms.
/hanging-beacon A heading. After load, it sends a request that does not answer for 20 s, like a slow analytics beacon.

The harness opened each page three times with each waitUntil value and a 10 s timeout, then checked whether the real content was in the DOM. The table shows the median.

import { chromium } from 'playwright';

const base = 'http://localhost:4173';
const targets = ['/static', '/delayed-data', '/polling', '/slow-image', '/hanging-beacon'];
const strategies = ['commit', 'domcontentloaded', 'load', 'networkidle'];

const browser = await chromium.launch();
try {
  for (const path of targets) {
    for (const waitUntil of strategies) {
      const runs = [];
      let result = '';
      for (let i = 0; i < 3; i++) {
        const page = await browser.newPage();
        const start = performance.now();
        let status = 'ok';
        try {
          await page.goto(base + path, { waitUntil, timeout: 10_000 });
        } catch {
          status = 'timeout';
        }
        runs.push(performance.now() - start);
        const hasContent = await page.locator('[data-testid=price], #ready').count();
        result = `${status} / ${hasContent ? 'content' : 'NO content'}`;
        await page.close();
      }
      runs.sort((a, b) => a - b);
      console.log(`${path} | ${waitUntil} | ${runs[1].toFixed(0)} ms | ${result}`);
    }
  }
} finally {
  await browser.close();
}

Results: time to "ready" by strategy

Median of three runs, in milliseconds. "No content" means the call returned but the data was not in the DOM yet, so a screenshot would show the loading state.

Page commit domcontentloaded load networkidle domcontentloaded + locator wait
/static 10 8 10 509 27
/delayed-data 4, no content 9, no content 9, no content 2,015 1,820
/polling 6, no content 11, no content 7, no content 10,003, timeout 1,819
/slow-image 5, image missing 7, image missing 2,013 2,515 28, image missing
/hanging-beacon 6 10 12 10,002, timeout 29

Four things stand out:

  1. networkidle pays a 500 ms tax on every page. The static page was ready in 10 ms and networkidle returned at 509 ms. That is the quiet window Playwright waits for, by design.
  2. networkidle never fires on pages that keep talking. The poller and the hanging beacon both ran into the 10 s timeout. The content was on screen the whole time. With the default 30 s timeout, each of those captures would block for 30 s and then throw.
  3. load and domcontentloaded are fast but blind to API data. Both returned before /api/data answered.
  4. A locator wait is the only strategy that was right and fast on every page that has data. It returned as soon as the price was in the DOM, and the background traffic did not matter.

The one case a locator wait alone missed is the slow image: the heading was there, the image was not. That needs the load state or an image check, covered below.

Why the Playwright team discourages networkidle

The Playwright API reference marks networkidle with "DISCOURAGED" and says to rely on web assertions instead. The table shows the two reasons.

First, the definition does not match what a person means by "done". Modern pages keep the network busy after they render: analytics, feature flags, chat widgets, ad refreshes, long-polling, server-sent events. Any of these keeps the page from ever being idle. Second, when it does work, it adds latency you can never get back. A batch job that takes 2,000 screenshots spends at least 1,000 seconds in quiet windows.

It is still the right tool in one narrow case: a page you do not control, that loads its content in several chained requests, where you have no selector to wait for. Even there, cap it with a short timeout and capture anyway on timeout (see the last recipe).

Recipe 1: wait for the content, not the page

Locators wait automatically. Every action (click, fill, screenshot) and every expect assertion retries until the element is ready or the timeout runs out. To wait without acting, call waitFor:

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

waitFor takes a state: visible (the default), attached, hidden or detached. Use hidden to wait for a spinner to go away:

await page.locator('.spinner').waitFor({ state: 'hidden' });

In a test, prefer the assertion form, because the failure message is clearer and it uses the expect timeout:

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

test('dashboard renders', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByTestId('chart')).toBeVisible();
  await expect(page.getByText('Loading')).toHaveCount(0);
});

Pick a selector that only exists in the final state. A data-testid on the populated chart or table is better than a class name that the skeleton screen also uses.

Recipe 2: element screenshots wait by themselves

locator.screenshot() waits for the element to be visible and stable before it captures, and it scrolls the element into view. If you only need one element, you need no explicit wait at all:

await page.goto('http://localhost:4173/delayed-data');
await page.getByTestId('price').screenshot({ path: 'price.png' });

This call returned once the price was rendered (1,979 ms in our run, the 1,500 ms API delay plus page setup), with no waitFor and no waitUntil. The same is true of expect(locator).toHaveScreenshot() in the test runner. The visual comparisons guide covers that path.

Recipe 3: a "visually ready" helper for full-page captures

A full-page screenshot has no single element to wait for, and images matter. This helper combines a content locator, font loading and an image check. It is the helper the 2,047 ms /slow-image result came from:

import { chromium } from 'playwright';

async function waitForVisualReady(page, readyLocator) {
  await readyLocator.waitFor({ state: 'visible' });
  await page.evaluate(() => document.fonts.ready);
  await page.waitForFunction(() =>
    Array.from(document.images).every((img) => img.complete)
  );
}

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.goto('http://localhost:4173/slow-image', { waitUntil: 'domcontentloaded' });
  await waitForVisualReady(page, page.locator('#ready'));
  await page.screenshot({ path: 'slow-image.png', fullPage: true });
} finally {
  await browser.close();
}

On our pages, the helper returned in 2,047 ms for /slow-image (the image delay), 1,833 ms for /delayed-data and 1,832 ms for /polling (the API delay), and 41 ms for /hanging-beacon. It ignored the background traffic that broke networkidle.

Two notes. img.complete is also true for an image that failed to load, which is what you want for a screenshot: a broken image should not hang the job. And img.complete is true for lazy images that have not started to load. If the page uses loading="lazy", scroll first; the lazy-loaded images guide has a scroll loop.

Recipe 4: wait for a JavaScript condition

When the page exposes its own ready signal, use waitForFunction. It polls the predicate in the page on every animation frame by default:

await page.waitForFunction(() => window.__APP_READY__ === true, null, { timeout: 15_000 });

This works well for pages you control. Set the flag in your app after the last data request resolves and the charts finish their first paint. For canvas charts, set it in the chart library's animation.onComplete callback, or turn chart animations off for capture runs.

Recipe 5: wait after a click

When a click starts a navigation, the click call does not wait for the new page. Wait for the URL or a load state afterwards:

await page.getByRole('link', { name: 'Pricing' }).click();
await page.waitForURL('**/pricing');
await page.getByRole('heading', { name: 'Plans' }).waitFor();

page.waitForLoadState('load') works too. It resolves at once if the current document already reached that state, which is a common reason it seems to "not wait" after a client-side route change. A single-page app route change fires no load event at all, so wait for content. The single-page application guide covers hydration and route changes in more depth.

Recipe 6: cap networkidle when you have nothing better

For arbitrary third-party URLs with no selector, give networkidle a short budget and capture anyway when the budget runs out:

await page.goto(url); // load
try {
  await page.waitForLoadState('networkidle', { timeout: 3_000 });
} catch {
  // The page never went quiet. Capture what is there.
}
await page.screenshot({ path: 'page.png', fullPage: true });

This bounds the worst case at load + 3 s instead of the 30 s default. The cost is that a page which is still fetching data gets captured in whatever state it reached.

Decision table

Your situation Wait with Why
Server-rendered page, no late data Default goto (load) load already covers images and CSS in the HTML
Data arrives from an API after load locator.waitFor() on an element that exists only with data Fast and ignores background traffic
You screenshot one element Nothing extra: locator.screenshot() It waits for visible and stable
Full page with images and web fonts waitForVisualReady helper Checks content, fonts and img.complete
Spinner or skeleton waitFor({ state: 'hidden' }) on the spinner Waits for the loading state to leave
You control the app waitForFunction on a ready flag The app knows when it is done
Unknown third-party URL load, then networkidle capped at a few seconds Bounded worst case
Animations still running animations: 'disabled' on screenshot Finishes finite CSS animations, cancels infinite ones
Never page.waitForTimeout(ms) as the main wait The docs say to never wait for time in production; a fixed sleep is either too long or too short

Troubleshooting

Symptom Cause Fix
TimeoutError: page.goto: Timeout 30000ms exceeded with networkidle A poller, beacon, WebSocket fallback or ad refresh keeps the network busy Use load plus a locator wait, or cap networkidle as in Recipe 6
Screenshot shows "Loading..." The data request finished after load Wait for an element that only exists with data
Screenshot shows a skeleton that looks finished The selector you wait for also exists in the skeleton Wait for a data-testid set only on the populated state
Images are blank boxes Capture ran before image responses arrived Use the load state or check img.complete
Lazy images blank below the fold loading="lazy" images had not started Scroll the page before you wait for images
Fonts swap after capture font-display: swap and a slow font request await page.evaluate(() => document.fonts.ready)
Chart is half drawn Canvas animation still running Wait for a ready flag or turn animations off
waitForLoadState returns at once after a click The document already reached that state; client-side routes fire no load waitForURL, then wait for content
Waits pass locally, time out in CI CI machines are slower and share CPU Raise timeout per call, not a global sleep

The same wait with one API call

If you capture pages you do not own, the same rules apply on our side. RenderScreenshot exposes them as wait options: wait.until (load, domcontentloaded, networkidle), wait.for_selector with visible, hidden, attached or detached, wait.delay and an overall wait.timeout of up to 60 seconds. One difference from Recipe 6: when the API's wait condition is not met in time, the request fails with a timeout error instead of capturing a partial page, and a failed request uses no credit. With the Node SDK, Recipe 1 looks like this:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

const image = await client.take(
  TakeOptions.url('https://example.com/dashboard')
    .waitForSelector('[data-testid="chart"]')
    .waitForTimeout(30)
);
await writeFile('dashboard.png', image);

You get 150 free credits every month to try it; create a key and pass the same selector you use in your Playwright code.

Related in this topic