Chapter 10 – Waiting and Synchronization

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 #submit button 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' });
OptionDescription
loadWaits for the load event
domcontentloadedWaits until the DOM is parsed
networkidleWaits 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
StateMeaning
attachedElement is present in the DOM
detachedElement is removed from the DOM
visibleElement is visible
hiddenElement 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(), or expect().

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:

  1. Wait for stability: Playwright auto-waits for element stability before clicking.
  2. 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

  1. Rely on Playwright’s auto-waiting first.
  2. Use locators and expect() instead of manual timeouts.
  3. Wait for specific states (visible, hidden, attached, detached).
  4. Disable animations in test environments.
  5. Avoid waitForTimeout() except for debugging.
  6. Use network and function waits only when absolutely needed.
  7. 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

MethodDescription
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.