One of the biggest challenges in browser automation is flakiness, when tests pass sometimes and fail other times, even though nothing changed in your code. This usually happens because the test tries to interact with elements before they’re ready maybe a button hasn’t appeared yet, or an AJAX call is still loading.
Playwright’s auto-waiting feature already solves most of these problems automatically. However, there are cases where you’ll need to manage waiting manually for precise synchronization.
In this chapter, we’ll cover everything from basic waiting techniques to advanced synchronization patterns, helping you write stable, reliable Playwright tests that work every time.
Why waiting and synchronization matter
Web applications are dynamic, elements load asynchronously, animations delay visibility, and network responses can vary by environment. If your test doesn’t wait for the right conditions, it can click an invisible button, read an outdated message, or fail randomly.
Proper synchronization ensures your test actions happen only when the page is ready.
1. Auto-waiting: Playwright’s secret weapon
Playwright automatically waits for elements before interacting with them. You don’t have to add arbitrary sleep() calls.
For example:
await page.click('#submit');
Playwright internally waits until:
- The
#submitbutton exists in the DOM. - It’s visible.
- It’s stable (not moving or animating).
- It’s not disabled.
Then it clicks it.
This eliminates most flakiness without you writing any wait code.
Best practice: Always rely on Playwright’s auto-waiting first before adding manual waits.
2. Waiting for navigation
Sometimes, clicking a button or link triggers navigation. Playwright automatically waits for navigation events if they are part of the action. However, you can also explicitly wait for them.
Example:
await Promise.all([
page.waitForNavigation(), // Waits for page load
page.click('text=Login') // Triggers the navigation
]);
You can customize the waiting strategy:
await page.waitForNavigation({ waitUntil: 'networkidle' });
| Option | Description |
|---|---|
load | Waits for the load event |
domcontentloaded | Waits until the DOM is parsed |
networkidle | Waits until no network requests for 500ms |
3. Waiting for elements
If you need to wait for a specific element, use waitForSelector().
Example:
await page.waitForSelector('#dashboard');
await expect(page.locator('#dashboard')).toBeVisible();
You can specify the state to wait for:
await page.waitForSelector('#loading', { state: 'hidden' }); // Wait until element disappears
| State | Meaning |
attached | Element is present in the DOM |
detached | Element is removed from the DOM |
visible | Element is visible |
hidden | Element is hidden or not in the DOM |
4. Waiting for network calls
Sometimes your test depends on API responses. You can wait for network events using waitForResponse() or waitForRequest().
Example:
await Promise.all([
page.waitForResponse('**/api/login'),
page.click('text=Login')
]);
You can also wait for a specific condition:
await page.waitForResponse(response =>
response.url().includes('/api/products') && response.status() === 200
);
5. Waiting for specific element states
Locators allow you to wait for elements to reach a specific state before proceeding.
await page.locator('#checkout').waitFor({ state: 'visible' });
await page.locator('#checkout').click();
Or wait until a button is enabled:
await expect(page.locator('#submit')).toBeEnabled();
This ensures the element is truly ready for interaction.
6. Avoiding fixed timeouts
In older test frameworks, you might see code like this:
await page.waitForTimeout(3000); // Wait 3 seconds
This is not recommended, it makes tests slower and more brittle. The delay might be too short for some environments and unnecessarily long for others.
Instead, use conditional waits or Playwright’s built-in waiting.
Avoid:
waitForTimeout()unless debugging.
Prefer:
waitForSelector(),waitForResponse(), orexpect().
7. Using expect() for built-in waiting
Playwright’s expect() automatically retries until the condition is met or a timeout is reached.
await expect(page.locator('#message')).toHaveText('Success');
If the text takes 2 seconds to appear, Playwright keeps checking, no need for manual waits.
You can increase the timeout for specific assertions:
await expect(page.locator('#message')).toHaveText('Success', { timeout: 10000 });
8. Handling animations and transitions
Animations can cause flakiness if you interact with elements before they stop moving.
Options:
- Wait for stability: Playwright auto-waits for element stability before clicking.
- Disable animations in test environments:
* {
transition: none !important;
animation: none !important;
}
You can inject this CSS at runtime:
await page.addStyleTag({ content: '* { transition: none !important; animation: none !important; }' });
This makes tests faster and more predictable.
9. Custom wait conditions (advanced)
You can build your own wait conditions using JavaScript logic.
Example: Wait until text changes
await page.waitForFunction(() =>
document.querySelector('#status').innerText.includes('Complete')
);
Example: Wait until data appears in localStorage
await page.waitForFunction(() => localStorage.getItem('authToken'));
Example: Wait until a variable exists in the window
await page.waitForFunction(() => window.appReady === true);
Custom waiters give you ultimate control in complex apps.
10. Handling slow networks or backends
Playwright allows you to control or simulate slow network conditions. it is useful for testing synchronization.
await page.route('**/api/data', route => {
setTimeout(() => route.continue(), 3000); // Delay API by 3 seconds
});
You can also set default timeouts for all tests in playwright.config.js:
use: {
actionTimeout: 10000, // 10 seconds max for each action
navigationTimeout: 20000, // 20 seconds max for navigation
}
11. Debugging flakiness
If your test still fails intermittently, Playwright offers powerful debugging tools:
Run in debug mode
npx playwright test --debug
Enable trace viewer
npx playwright test --trace on
Then open the trace:
npx playwright show-trace trace.zip
You’ll see screenshots, console logs, and timing information for each step, perfect for diagnosing waits and timing issues.
12. Best practices for synchronization
- Rely on Playwright’s auto-waiting first.
- Use locators and expect() instead of manual timeouts.
- Wait for specific states (
visible,hidden,attached,detached). - Disable animations in test environments.
- Avoid
waitForTimeout()except for debugging. - Use network and function waits only when absolutely needed.
- Use trace and debug tools to identify timing issues.
Exercise
Task 1: Write a test that waits for a success message after submitting a form.
Task 2: Use Promise.all() to wait for navigation after clicking a button.
Task 3: Add a custom wait that waits until a counter reaches a certain value.
Bonus: Simulate a slow network response and synchronize the test.
Cheat Sheet
| Method | Description |
page.waitForSelector() | Wait for element state |
page.waitForNavigation() | Wait for page navigation |
page.waitForResponse() | Wait for a specific network response |
page.waitForFunction() | Wait for a custom JS condition |
expect(locator) | Automatically retries assertion |
Promise.all() | Combine navigation and actions |
waitForTimeout() | Use only for debugging |
Summary
In this chapter, you learned:
- The importance of waiting and synchronization in Playwright.
- How Playwright’s auto-waiting prevents most flakiness.
- How to wait for elements, navigation, and network events.
- How to handle animations, transitions, and dynamic content.
- How to create custom wait conditions for complex scenarios.
By combining Playwright’s built-in auto-waiting with smart synchronization techniques, you can build rock-solid, non-flaky tests that pass consistently.
