When I first switched from Selenium to Playwright, I noticed that sometimes the waits in Playwright didn’t behave the way I expected, which led to flaky and unstable test cases. Later, I soon realized that most of the waiting is handled automatically by Playwright. Once I understood this core concept, I applied it in my team project and shared my learnings with the team. Here’s what I learned from my past mistakes.
1. Auto-wait with Playwright
Auto-waiting is the feature in Playwright that most testers underestimate. Every action like .click() or .fill(), and every assertion like expect(locator) already includes smart, built-in waiting.
To see this in action, let’s take a look at what really happens under the hood when you call page.click() in the flowchart below. It shows how Playwright processes the click step by step and resolves all the waiting automatically.

As illustrated in the flow chart, Playwright wraps a locator.click() in a sequence of actionability checks with automatic retries. It first waits for the element to exist and the locator to resolve to exactly one element. Next, it ensures the element is visible; if not, it keeps retrying until the timeout. It then waits for the element to become stable (not animating) and scrolls it into view. Playwright also verifies the target can receive pointer events (not covered or off-screen) and continuously rechecks that it hasn’t detached from the DOM. If the element is detached, it re-runs all checks against the refreshed element. Playwright only performs the click after all of the checks have been completed, eliminating manual sleeps and reducing flakiness of the test cases.
2. The right way to manual waits
Playwright removes the need for most manual waits. But when you do need to wait for specific logic, you should rely on the waiting mechanisms Playwright already provides.
2.1. waitFor()
Some people will be surprised when I recommend using waitFor() instead of waitForSelector(), so here’s why. When you wait for a selector, it’s usually because you want to perform an action on that element right after click, fill, or assert. In Playwright, every action like .click() or .fill() already includes the same waiting logic that waitFor() provides. It waits for the element to exist, be visible, stable, and interactable.
The difference is that waitFor() is more powerful and flexible than waitForSelector(). You can explicitly wait for specific element states like attached, visible, hidden, or detached to make it a much better replacement. While most actions already include auto-waiting, using waitFor() gives you precise control over waiting when needed. In the snippet below, the waitFor() method is used to wait for a toast message to appear before performing an assertion:
await page.locator('.toast-success').waitFor();
await expect(page.locator('.toast-success')).toHaveText("successfully");
In this case, no user interaction (click, fill, etc.) is being performed, so the explicit wait is necessary. The waitFor() ensures that Playwright pauses until the element is attached, visible, and stable, preventing flaky tests caused by timing issues.
2.2. waitForURL()
When your test triggers navigation, whether it’s clicking a link, submitting a form, or moving between routes in a single-page application, you often need to wait for the new URL to load before continuing. That’s where waitForURL() comes in. It pauses the test until the browser’s URL matches the expected value, ensuring the next steps run on the correct page. In the example below, we click the Login button and then immediately wait for the URL to update:
await page.getByText('Login').click();
await page.waitForURL('**/login');
In this example, waitForURL() makes sure the browser has actually switched to the new route before the test continues. It’s especially helpful when the URL doesn’t change right away or when navigation happens asynchronously. By waiting for the expected URL, you confirm that the page has moved to the right place, and your test can run smoothly.
2.3. waitForResponse()
waitForResponse() lets you pause your test until a certain network request completes. This is useful in cases where the UI depends heavily on API calls, such as saving data, loading dynamic tables, or fetching user details.
const responsePromise = page.waitForResponse('**/api/fetch_data');
await page.getByText('Update').click();
const response = await responsePromise;
This ensures your test only moves forward after the expected backend call has completed successfully. Personally, I haven’t used this method very often, because many UI checks like expect(locator).toHaveText() or waitFor() already wait for the UI state that comes after the response. But it’s good to know it exists, especially for API-heavy apps.
2.4. waitForFunction()
waitForFunction() is a flexible tool that waits until a function you write returns truthy. It’s useful when there’s no built-in Playwright method for the condition you need. Let’s go in depth and see this method in action with the example in Puppeteer documentation:
const watchDog = page.waitForFunction('window.innerWidth < 100');
await page.setViewport({width: 50, height: 50});
await watchDog;
In this example, waitForFunction() is used to pause execution until a specific JavaScript condition window.innerWidth < 100 becomes true in the browser. Then the code changes the viewport size to 50×50. Once the viewport shrinks, the condition evaluates to true, and waitForFunction() resolves, allowing the script to continue. That said, I haven’t had many real-world cases where I needed this. Most UI or network waits can be handled more cleanly using locators or assertions. Still, it’s helpful to know that waitForFunction() is still there when you need it.
3. Misconception with playwright auto-wait
Here are the common mistakes that I believe not only me but also automation tester switching to Playwright (especially from Selenium) often run into.
3.1. Using waitForSelector() before clicking
This is one of the mistakes that I made the most when I first switched to Playwright:
await page.waitForSelector('text=Login'); // This is a redundant wait and should be removed
await page.click('text=Login');
In Playwright this is redundant. The .click() method already includes built-in waits as we have mention before. It ensures the element exists, is visible, stable, and interactable before performing the click. Adding waitForSelector() in this case doesn’t make the test more reliable since it only slows it down and adds unnecessary retries. The test case will perform better if we remove the redundant waitForSelector().
3.2. Using waitForTimeout() to Stabilize Tests
When I switched to Playwright in my first project, I encountered an app that displayed a loading screen after performing a search on a large bucket of items. The results often took several seconds to appear. Thinking that a fixed wait would stabilize my test, I added a waitForTimeout() to pause for a short duration before checking the search results:
await page.locator('#search').fill('John');
await page.locator('#search-button').click();
// Wait for the loading screen to disappear (which is strongly discouraged)
await page.waitForTimeout(5000);
await expect(page.locator('.search-result')).toHaveText('John Doe');
At first, this seemed like a safe solution. However, it didn’t work as expected. The test became much slower, and sometimes it was still flaky since the test case need to wait too long when results appeared quickly, or not waiting long enough when the server lagged. So instead of using waitForTimeout() in this case, we can use waitFor() instead to wait for the loading screen detached from the DOM before we proceed.
await page.locator('.loading').waitFor({ state: 'detached' });
4. Conclusion
In conclusion, Playwright’s auto-waiting transforms how we approach automated testing, eliminating much of the effort around timing and stability. Also, by understanding and trusting Playwright’s built-in waiting mechanisms, such as waitFor(), waitForURL(), waitForResponse(), and waitForFunction(), we can write tests that are both reliable and efficent.