Waits & Sync October 28, 2026 12 min read

Playwright Wait: Which Wait to Use and When

Every Playwright wait in one place: locator.waitFor() and its four states, web-first assertions that retry, waitForURL, waitForLoadState, waitForResponse, waitForFunction and waitForEvent, plus how to wait for a page to load without networkidle. A decision table, real timeouts, and runnable JavaScript examples on Playwright 1.62.

Every Playwright wait answers one question: what exactly is the test waiting for? An element, a URL, an API response, a popup? Pick the wait that matches that condition and your tests stop failing at random. Pick a fixed sleep or networkidle and they start.

This guide maps every wait in Playwright to the job it does: locator.waitFor() (the "Playwright waitFor" most people search for), web-first assertions, waitForURL, waitForLoadState, waitForResponse, waitForFunction, waitForEvent, and the one to avoid, waitForTimeout. Every example is JavaScript, targets a public practice site, and was run on Playwright 1.62.

Quick answer: for elements, use await expect(locator).toBeVisible() (it retries) or locator.waitFor({ state }). For navigation, use page.waitForURL(). For data, use page.waitForResponse(), started before the action. Never use page.waitForTimeout() in a real test.


What Is a Playwright Wait?

A Playwright wait is any instruction that pauses a test until the page reaches a condition, such as an element becoming visible, the URL changing, or an API response arriving. Playwright already auto-waits before actions and retries web-first assertions, so explicit waits are only needed for conditions those built-in checks can't see.

Auto-waiting means click(), fill() and other actions first check that the element is attached, visible, stable, enabled and receiving events. That is covered in depth in Playwright auto-waiting explained; this guide is about the waits you add yourself, and when you actually need one.

Waits fall into three groups:

  • Element waits: web-first assertions such as expect(locator).toBeVisible(), and locator.waitFor(), the locator-based replacement for page.waitForSelector() (compared in waitForSelector vs locator auto-wait).
  • Page and navigation waits: page.waitForURL(), page.waitForLoadState() and the waitUntil option of page.goto().
  • Event and data waits: page.waitForResponse(), page.waitForRequest(), page.waitForFunction() and page.waitForEvent().

Which Playwright Wait Should I Use?

Start from the condition you're waiting for, not from the method name. This table covers almost every case you'll meet:

You are waiting for…UseWhy
An element to appear, disappear or show some textawait expect(locator).toBeVisible(), toBeHidden(), toHaveText()Web-first assertions retry until they pass or time out, and they check the result at the same time.
An element state, with no assertion (a spinner to go away before the next step)locator.waitFor({ state })States: attached, detached, visible (default), hidden.
The URL to change after a click, redirect or SPA routepage.waitForURL() or expect(page).toHaveURL()Matches a glob, RegExp or predicate. See the waitForURL guide.
A popup or new tab to finish loadingpage.waitForLoadState()Defaults to load. See the waitForLoadState guide.
A specific API call to finishpage.waitForResponse() / page.waitForRequest()Start the wait before the action. See the waitForResponse guide.
A condition inside the browser (localStorage, a window variable)page.waitForFunction()Runs your function in the page until it returns a truthy value.
A popup, download, dialog or console messagepage.waitForEvent()Returns the event object, such as the new Page.
"Some time" to passpage.waitForTimeout()Anti-pattern. Too short on slow CI, wasted time everywhere else. Debugging only.

The rule behind the table: wait for evidence, never for time. If you can name what changes on screen, in the URL or on the network, there is a wait for it.


Playwright waitFor: locator.waitFor() Explained

locator.waitFor() pauses until the element matching a locator reaches a state. If it's already in that state, it returns immediately. It takes two options:

  • state: 'attached' (in the DOM), 'detached' (not in the DOM), 'visible' (non-empty bounding box and no visibility:hidden; this is the default), or 'hidden' (detached, empty box, or visibility:hidden).
  • timeout: maximum time in milliseconds. Without it, Playwright Test applies actionTimeout, which is 0 (no limit) by default, so the test timeout of 30 seconds is what stops it.

Here are all four states on real pages. The dynamic loading demo shows a "Loading..." bar for about five seconds, then "Hello World!". Page 1 has the result hidden in the DOM from the start; page 2 only adds it at the end, which is exactly the difference between visible and attached.

tests/locator-waitfor.spec.js
const { test, expect } = require('@playwright/test');

test('visible, then hidden: wait for a spinner to come and go', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/dynamic_loading/1');
  await page.getByRole('button', { name: 'Start' }).click();

  const loading = page.locator('#loading');
  await loading.waitFor();                    // state defaults to 'visible'
  await loading.waitFor({ state: 'hidden', timeout: 10000 });

  await expect(page.locator('#finish')).toHaveText('Hello World!');
});

test('attached: wait for an element to be added to the DOM', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/dynamic_loading/2');
  await expect(page.locator('#finish')).toHaveCount(0); // not rendered yet

  await page.getByRole('button', { name: 'Start' }).click();
  await page.locator('#finish').waitFor({ state: 'attached', timeout: 10000 });

  await expect(page.locator('#finish')).toHaveText('Hello World!');
});

test('detached: wait for an element to be removed', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/dynamic_controls');
  await page.getByRole('button', { name: 'Remove' }).click();

  await page.locator('#checkbox').waitFor({ state: 'detached', timeout: 10000 });
  await expect(page.locator('#message')).toHaveText("It's gone!");
});

Notice that each test still ends with an expect. waitFor() only waits; it doesn't verify anything. That is why, in most tests, an assertion alone is the better choice.

When to use waitFor() instead of an assertion

  • You need a spinner or overlay gone before the next step, and it isn't something you want to report on.
  • You're writing a helper or Page Object method such as waitUntilLoaded() that shouldn't contain assertions.
  • You need attached or detached, which have no single assertion equivalent (the closest are toBeAttached() and toHaveCount(0)).

Don't put waitFor() before a click() or fill() on the same locator. The action already waits for it. More on that in the Playwright click guide.


Web-First Assertions: The Wait You Should Use Most

An assertion such as await expect(locator).toBeVisible() doesn't check once. It keeps re-checking the page until the condition is true or the timeout runs out (5 seconds by default, set with expect.timeout). When it does fail, the error shows what it expected, what it last saw, and how many times it retried.

Snippet
// Waits AND verifies: retries until visible or 10s pass
await expect(page.locator('#finish')).toBeVisible({ timeout: 10000 });

// Other waiting assertions
await expect(page.locator('#loading')).toBeHidden();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
await expect(page).toHaveURL(/\/secure$/);

Two details matter. First, the 5-second default is shorter than the dynamic loading demo, which takes about 5 seconds to finish, so the test above needs { timeout: 10000 }. Second, pick the assertion that matches what the user sees. On /dynamic_loading/1, toHaveText('Hello World!') passes before you even click Start, because the hidden element already contains that text. toBeVisible() is the assertion that actually waits for the result to appear. Find good locators for these with the getByRole guide and the locators guide.

Your tests aren't flaky. Your waits are. Most "random" failures are a sleep that's too short, a networkidle that never settles, or an assertion that passed on the wrong element. See how to fix flaky Playwright tests for good →


How to Wait for a Page to Load in Playwright

page.goto() already waits for the page to load. Its waitUntil option decides what "loaded" means:

waitUntilResolves when…Use it for
'load' (default)the load event fires (HTML, scripts, styles, images)Almost everything
'domcontentloaded'the HTML is parsedFaster tests on pages with heavy images or ads
'commit'the response is received and the document starts loadingChecking redirects or headers, not content
'networkidle'no network connections for at least 500 msDiscouraged by Playwright itself
tests/page-load.spec.js
const { test, expect } = require('@playwright/test');

test('goto waits for the load event by default', async ({ page }) => {
  await page.goto('https://playwright.dev/'); // same as waitUntil: 'load'
  await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
});

test('goto with waitUntil: domcontentloaded', async ({ page }) => {
  // Return as soon as the HTML is parsed; don't wait for images and fonts
  await page.goto('https://playwright.dev/', { waitUntil: 'domcontentloaded' });
  await expect(page.getByRole('heading', { level: 1 })).toContainText('Playwright');
});

test('page is "loaded" when the content you need is there', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/api-mocking');
  // The fruit list comes from an API call made after load.
  // Assert on it instead of waiting for networkidle.
  await expect(page.getByText('Strawberry')).toBeVisible();
});

Why networkidle is discouraged

Playwright's own docs mark networkidle as DISCOURAGED and say to rely on web assertions instead. In practice it fails in both directions: pages with analytics, polling, chat widgets or websockets may never be idle for 500 ms, so the wait runs until the timeout; and a page can go briefly idle before its data request starts, so the wait passes too early. Either way, it measures the network, not your page.

The reliable rule: wait for the content

"Loaded" means the thing your test needs is on screen. The third test above doesn't wait for the network at all: it asserts that "Strawberry" is visible, which is only true once the fruit API has responded and the list has rendered. If you do need a load state later, for example on a popup, use page.waitForLoadState(), covered in the waitForLoadState guide.


Waiting for a URL, an API Response, a Function or an Event

When the condition isn't an element, use the page-level waits. One runnable file with one test per wait:

tests/wait-toolkit.spec.js
const { test, expect } = require('@playwright/test');

test('web-first assertion: wait for an element to appear', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/dynamic_loading/1');
  await page.getByRole('button', { name: 'Start' }).click();
  // Retries until visible. The demo takes about 5s, so allow 10s.
  await expect(page.locator('#finish')).toBeVisible({ timeout: 10000 });
  await expect(page.locator('#finish')).toHaveText('Hello World!');
});

test('waitForURL: wait for the redirect after login', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/login');
  await page.getByLabel('Username').fill('tomsmith');
  await page.getByLabel('Password').fill('SuperSecretPassword!');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForURL('**/secure');
  await expect(page.locator('#flash')).toContainText('You logged into a secure area!');
});

test('waitForResponse: wait for a specific API call', async ({ page }) => {
  // Start waiting BEFORE the action that triggers the request
  const responsePromise = page.waitForResponse('**/api/v1/fruits');
  await page.goto('https://demo.playwright.dev/api-mocking');
  const response = await responsePromise;
  expect(response.status()).toBe(200);
  const fruits = await response.json();
  await expect(page.getByText(fruits[0].name)).toBeVisible();
});

test('waitForFunction: wait for app state in the browser', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');
  await input.fill('Write better waits');
  await input.press('Enter');
  // Wait until the app has saved the todo to localStorage
  await page.waitForFunction(() => {
    const todos = JSON.parse(localStorage.getItem('react-todos') || '[]');
    return todos.length === 1;
  });
});

test('waitForEvent: wait for a popup window', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/windows');
  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Click Here' }).click();
  const popup = await popupPromise;
  await expect(popup.getByRole('heading')).toHaveText('New Window');
});
  • waitForURL resolves immediately if the page is already on a matching URL, so calling it after the click is fine. Make the pattern specific. Details in the Playwright waitForURL guide.
  • waitForResponse only sees responses that arrive after you call it. Create the promise first, then trigger the request. Full patterns in the waitForResponse guide and the network interception guide.
  • waitForFunction polls a function inside the browser until it returns something truthy. Use it for app state the DOM doesn't show.
  • waitForEvent follows the same promise-first pattern: 'popup', 'download', 'dialog', 'console' and more.

Playwright Wait Timeouts: What Stops Each Wait

WaitDefault limit in Playwright TestChange it with
expect(...) assertions5 secondsexpect: { timeout } in config, or { timeout } per call
Actions and locator.waitFor()None (actionTimeout: 0), so the test timeout appliesuse: { actionTimeout }, or { timeout }
goto, waitForURL, waitForLoadStateNone (navigationTimeout: 0), so the test timeout appliesuse: { navigationTimeout }, or { timeout }
waitForResponse, waitForEvent, waitForFunctionNone in Playwright Test, so the test timeout applies{ timeout } per call
The whole test30 secondstimeout in config or test.setTimeout()

A timeout error means the condition never happened, not that the limit is too low. Read the call log first; the timeout errors guide walks through the common messages.


Common Playwright Wait Mistakes

  1. Using waitForTimeout() as a fix. await page.waitForTimeout(3000) is too short on a slow CI runner and three wasted seconds everywhere else. Playwright's docs say it should only be used for debugging. Replace it with the condition you were really waiting for.
  2. Using networkidle as "page ready". It hangs on pages that poll and passes early on pages that fetch late. Assert on content.
  3. Asserting text on a hidden element. As shown above, toHaveText passed on the hidden result before the click. Add toBeVisible() when visibility is what the user cares about.
  4. Starting waitForResponse or waitForEvent after the action. If the response or popup arrives first, the wait never sees it and hangs until the timeout.
  5. A waitForURL pattern that matches the current page. '**/login' after a failed login resolves instantly, because the app redirects you back to /login. Wait for the destination you expect, and assert on its content.
  6. Adding waitFor() before every action. click() and fill() already wait. Extra waits add noise, not stability.
  7. Using slowMo to hide timing bugs. It slows every action and fixes nothing. See what slowMo is really for.
  8. Raising every timeout. A 120-second timeout doesn't make a missing element appear; it just makes the failure slower. Debug it with the trace instead (how to debug Playwright tests).

Fixing these is most of what the flaky tests fix guide is about. Run the examples here with the commands in how to run Playwright tests.

Still seeing tests pass locally and fail in CI?

It's almost always a wait. See the waits-first approach that makes Playwright tests stable, in JavaScript, with Claude AI helping you find the weak spots.

See how to fix it →

Frequently Asked Questions

How do I wait for an element in Playwright?

Use a web-first assertion such as await expect(locator).toBeVisible(). It retries until the element is visible or the expect timeout (5 seconds by default) runs out. If you only need to wait without asserting, use await locator.waitFor(), which waits for the visible state by default.

What does locator.waitFor() do in Playwright?

locator.waitFor() pauses until the element reaches a state: attached, detached, visible (the default) or hidden. It returns immediately if the element is already in that state, and accepts a timeout option in milliseconds. It waits but does not verify anything, so follow it with an assertion.

How do I wait for a page to load in Playwright?

page.goto() already waits for the load event by default. Change that with the waitUntil option: domcontentloaded, load, networkidle or commit. The most reliable approach is to assert on the content you need, for example await expect(page.getByRole('heading')).toBeVisible().

Why is waitForTimeout bad in Playwright?

page.waitForTimeout() waits a fixed time no matter what the page does. On a slow CI runner the time is too short and the test fails; everywhere else it wastes time. Playwright's docs say it should only be used for debugging. Wait for the actual condition instead.

Should I use networkidle in Playwright?

No. Playwright's docs mark networkidle as discouraged. Pages with polling, analytics or websockets may never be idle for 500 ms, and a page can look idle before its data request starts. Assert on the content the request produces, or wait for that request with page.waitForResponse().

What is the default wait timeout in Playwright?

In Playwright Test, expect assertions time out after 5 seconds. Actions, locator.waitFor() and navigation waits have no limit of their own by default (actionTimeout and navigationTimeout are 0), so the 30-second test timeout stops them.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn

Playwright + Claude AI Course

Your Tests Aren't Flaky. Your Waits Are.

A wait that guesses (a sleep, networkidle, an assertion on the wrong element) is the root of most flaky Playwright tests. The course shows you how to write tests that wait for evidence, in JavaScript, and how to use Claude AI and the Playwright MCP Server to find and fix the weak spots.

  • Web-first assertions, locators and waits done right
  • Debugging timeouts with traces instead of raising limits
  • Claude AI + Playwright MCP Server for writing and fixing tests
  • 4.5★ on Udemy, 510+ students, 30-day money-back guarantee
Stop the Flaky Tests →

Ready now? Enroll on Udemy · Know your waits? Take the practice tests