4

🚦 Handling Waits in Playwright: Auto-Waits and Explicit Waits

Conquer flaky tests by understanding Playwright's auto-waiting mechanism and learn when and how to use explicit waits like waitForSelector and waitForFunction.

Introduction

One of the biggest headaches in web automation is dealing with timing. Modern web apps are dynamic – elements load, appear, disappear, and update asynchronously. If your test script tries to interact with an element that isn't ready yet, you get flaky tests or outright failures.

Playwright has a robust system for handling this, primarily through its auto-waiting mechanism. But sometimes, you might need more explicit control. Let's explore how Playwright handles waits and when you might need to step in.

The Magic of Auto-Waiting ✨

Playwright's auto-waiting is a game-changer. For most interactions, Playwright automatically waits for elements to be actionable before performing an action. This means:

  • When you call locator.click(), Playwright waits until the element is visible, enabled, and not obscured.
  • When you call locator.fill(), Playwright waits until the input field is ready to receive text.
  • When you call expect(locator).toBeVisible(), Playwright waits until the element becomes visible (up to a timeout).

This significantly reduces the need for manual sleep or wait commands that plague older automation tools and lead to brittle tests. Playwright performs a range of actionability checks, such as:

  • Attached: Element is attached to the DOM.
  • Visible: Element has a non-empty bounding box and no visibility:hidden.
  • Stable: Element has stopped animating or has settled.
  • Enabled: Element is not disabled (for inputs/buttons).
  • Editable: Element is not read-only (for inputs).

By default, these actions and assertions have a timeout (configurable in playwright.config.ts, typically 5-30 seconds depending on the action/assertion). If the condition isn't met within the timeout, the test will fail.

Example of Auto-Waiting in Action:

import { test, expect } from '@playwright/test';
 
test('auto-waiting example', async ({ page }) => {
  await page.goto('https://your-dynamic-app.com');
 
  // Imagine a button that appears after a few seconds
  const dynamicButton = page.getByRole('button', { name: 'Load Data' });
 
  // Playwright automatically waits for this button to be clickable
  await dynamicButton.click(); 
 
  const resultsContainer = page.locator('#results');
  // Playwright waits for this to be visible (or for text to appear if using toHaveText)
  await expect(resultsContainer).toBeVisible({ timeout: 10000 }); // Can override default timeout here
});

When Auto-Waiting Isn't Enough: Explicit Waits

While auto-waiting covers most scenarios, there are times you need more explicit control. This is usually when you're waiting for something other than an element to be actionable, or for a very specific state.

Here are some common explicit wait methods:

1. page.waitForSelector(selector, options) / locator.waitFor(options)

This is one of the most common explicit waits. It waits for an element matching the selector to appear in the DOM and become visible (by default).

// Wait for an element with id 'my-element' to be attached and visible
await page.waitForSelector('#my-element'); 
 
// Or using a locator instance
const myElement = page.locator('#my-element');
await myElement.waitFor({ state: 'visible', timeout: 15000 });

Common states for locator.waitFor():

  • 'attached': Element is in the DOM.
  • 'detached': Element is no longer in the DOM.
  • 'visible': Element is attached, visible, and has a non-empty bounding box.
  • 'hidden': Element is attached but not visible (e.g., display:none or zero size).

2. page.waitForLoadState(state, options)

Waits for the page to reach a specific load state. This is useful for ensuring the page is fully interactive before proceeding.

// Wait until the network is idle (no new network requests for 500ms)
await page.waitForLoadState('networkidle');
 
// Other states: 'load' (load event), 'domcontentloaded'

3. page.waitForTimeout(milliseconds)

This is Playwright's equivalent of a sleep or pause. Use this sparingly! It introduces fixed delays and can make your tests slower and less reliable. It's generally better to wait for a specific condition or element state.

// NOT RECOMMENDED FOR MOST CASES - but can be a last resort for debugging or specific scenarios
await page.waitForTimeout(3000); // Pauses for 3 seconds

4. page.waitForFunction(expression, arg, options)

This is a very powerful wait. It waits until the provided JavaScript expression (executed in the browser context) returns a truthy value.

// Wait until a global JavaScript variable `window.myAppReady` becomes true
await page.waitForFunction(() => window.myAppReady === true);
 
// Wait until an element has specific text (more complex than getByText auto-wait)
const specialElement = page.locator('#status-message');
await page.waitForFunction(element => element.textContent === 'Process Complete', specialElement.elementHandle());

5. page.waitForURL(url, options) / page.waitForNavigation(options) (Legacy)

Waits for the page URL to change to the specified URL (can be a string, regex, or function). page.waitForNavigation() is an older version, page.waitForURL() is generally preferred.

await page.getByRole('link', { name: 'Go to Profile' }).click();
// Wait for the URL to become /profile
await page.waitForURL('**/profile'); 

6. page.waitForEvent(event, optionsOrPredicate)

A generic way to wait for any Playwright event, like 'request', 'response', 'dialog', etc. This is more advanced but very flexible.

// Example: Click a button that triggers a download, then wait for the download event
const [ download ] = await Promise.all([
  page.waitForEvent('download'), // Start waiting for the download
  page.getByRole('button', { name: 'Download Report' }).click() // Click the button
]);
const path = await download.path();
console.log(`Downloaded to: ${path}`);

Timeouts Everywhere!

Remember that almost all wait operations in Playwright have a timeout:

  • Global Test Timeout: Set in playwright.config.ts (e.g., timeout: 30000 for each test).
  • Action/Assertion Timeouts: Playwright's internal timeouts for actions like click() or assertions like toBeVisible(). Usually around 5-30 seconds. These can often be overridden directly in the call: await expect(locator).toBeVisible({ timeout: 10000 });
  • Explicit Wait Timeouts: waitForSelector, waitForFunction, etc., also have their own timeout option.

Best Practices for Waits

  1. Rely on Auto-Waiting First: Let Playwright do the heavy lifting. It's designed to handle most cases.
  2. Use Explicit Waits for Specific Conditions: When auto-waiting isn't enough (e.g., waiting for a network response to complete before checking UI, waiting for a custom JS condition).
  3. Avoid page.waitForTimeout() (Hard Sleeps): Only as a last resort or for debugging. It makes tests brittle.
  4. Be Specific: The more specific your wait condition (e.g., waitForSelector for a particular element vs. a generic pause), the more reliable your test.
  5. Configure Timeouts Sensibly: Don't set timeouts too low (flaky tests) or too high (slow tests). Find a balance based on your application's performance.

Understanding Playwright's waiting mechanisms is key to writing stable and reliable automated tests. By leveraging auto-waits and using explicit waits wisely, you can conquer the challenges of dynamic web content.