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(), andlocator.waitFor(), the locator-based replacement forpage.waitForSelector()(compared in waitForSelector vs locator auto-wait). - Page and navigation waits:
page.waitForURL(),page.waitForLoadState()and thewaitUntiloption ofpage.goto(). - Event and data waits:
page.waitForResponse(),page.waitForRequest(),page.waitForFunction()andpage.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… | Use | Why |
|---|---|---|
| An element to appear, disappear or show some text | await 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 route | page.waitForURL() or expect(page).toHaveURL() | Matches a glob, RegExp or predicate. See the waitForURL guide. |
| A popup or new tab to finish loading | page.waitForLoadState() | Defaults to load. See the waitForLoadState guide. |
| A specific API call to finish | page.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 message | page.waitForEvent() | Returns the event object, such as the new Page. |
| "Some time" to pass | page.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 novisibility:hidden; this is the default), or'hidden'(detached, empty box, orvisibility:hidden).timeout: maximum time in milliseconds. Without it, Playwright Test appliesactionTimeout, which is0(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.
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
attachedordetached, which have no single assertion equivalent (the closest aretoBeAttached()andtoHaveCount(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.
// 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:
| waitUntil | Resolves when… | Use it for |
|---|---|---|
'load' (default) | the load event fires (HTML, scripts, styles, images) | Almost everything |
'domcontentloaded' | the HTML is parsed | Faster tests on pages with heavy images or ads |
'commit' | the response is received and the document starts loading | Checking redirects or headers, not content |
'networkidle' | no network connections for at least 500 ms | Discouraged by Playwright itself |
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:
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'); });
waitForURLresolves 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.waitForResponseonly 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.waitForFunctionpolls a function inside the browser until it returns something truthy. Use it for app state the DOM doesn't show.waitForEventfollows the same promise-first pattern:'popup','download','dialog','console'and more.
Quiz: Do You Really Understand Playwright Waits?
Five questions, about two minutes. Pick an answer to see the explanation.
1/5 A user clicks a link in a single-page app and the route changes from / to /docs/intro without a full page load. Which wait fits best before you act on the new view?
2/5 Why is await page.waitForTimeout(3000) considered an anti-pattern in a real test?
3/5 What is the main risk of await page.waitForLoadState('networkidle')?
4/5 A button triggers a GET to /api/v1/fruits and your test needs that response. Which code is correct?
5/5 await expect(page.locator('#finish')).toBeVisible() is called while the element is still hidden. What happens?
Waits are one of the most common causes of flaky Playwright tests. A few gaps here usually means random CI failures later. The good news: it's a small set of patterns, and they're learnable in a weekend.
Learn waits the right way →You know your waits. Now find out what else you know: locators, fixtures, configuration, API testing and CI, in exam-style questions with explanations.
Take the Playwright practice tests →Playwright Wait Timeouts: What Stops Each Wait
| Wait | Default limit in Playwright Test | Change it with |
|---|---|---|
expect(...) assertions | 5 seconds | expect: { timeout } in config, or { timeout } per call |
Actions and locator.waitFor() | None (actionTimeout: 0), so the test timeout applies | use: { actionTimeout }, or { timeout } |
goto, waitForURL, waitForLoadState | None (navigationTimeout: 0), so the test timeout applies | use: { navigationTimeout }, or { timeout } |
waitForResponse, waitForEvent, waitForFunction | None in Playwright Test, so the test timeout applies | { timeout } per call |
| The whole test | 30 seconds | timeout 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
- 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. - Using
networkidleas "page ready". It hangs on pages that poll and passes early on pages that fetch late. Assert on content. - Asserting text on a hidden element. As shown above,
toHaveTextpassed on the hidden result before the click. AddtoBeVisible()when visibility is what the user cares about. - Starting
waitForResponseorwaitForEventafter the action. If the response or popup arrives first, the wait never sees it and hangs until the timeout. - A
waitForURLpattern 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. - Adding
waitFor()before every action.click()andfill()already wait. Extra waits add noise, not stability. - Using
slowMoto hide timing bugs. It slows every action and fixes nothing. See what slowMo is really for. - 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.
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
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.
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
Ready now? Enroll on Udemy · Know your waits? Take the practice tests