Getty Images/iStockphoto

Tip

Understanding Playwright waits

Playwright provides several built-in mechanisms called waits that ensure tests are not executed until a preset condition is met. Learn why Playwright waits are important.

One major challenge of end-to-end testing and browser automation is handling wait conditions efficiently.

Determining when certain network events occur, when page navigation is completed, when an element's state changes, when animations are done or when elements have fully loaded should all happen before testing can begin.

Consider a button that opens a modal or input field that is disabled by default. How would you test it? This might sound simple at first, but handling multiple complex frontends like this in production with their various wait conditions and achieving reliability is quite the task.

Playwright, however, provides several built-in mechanisms, called waits, which help with this process. Waits ensure test execution is paused until a preset condition is met. These can be categorized into smart/auto waits and manual or explicit waits.

Why is waiting in Playwright important?

Modern web apps are dynamic -- constantly changing and updating with content. Frontend frameworks, such as React and Vue, do not load pages as static documents; they update the DOM in response to user events. When testing, this creates a synchronization gap between the script and the browser.

A test that normally runs in 10 microseconds on the tester machine may take significantly longer -- 10 milliseconds -- in the browser. Real-world scenarios, such as a slow internet connection, can cause tests to fail. Without an intelligent waiting strategy, race conditions become prevalent.

For testers using Playwright, waiting is critical to avoid test flakiness, which is often caused by timing issues, especially when no changes have been made to the source code. Developers can focus on changing the source code -- shipping more features, hiding UI elements, fixing UI elements or implementing progressive enhancements -- when failed tests contain bugs.

Types of waits in Playwright

Playwright categorizes waits into auto-waits -- or implicit waits -- and explicit waits.

Auto-waits/implicit waits

When you call an action on a locator -- such as locator.click() or locator.fill() -- Playwright does not execute it immediately. Rather, it will automatically wait for some actionability checks, which are for the element to be the following:

  • Visible: It's not hidden with display: none, visibility: hidden, or size set to zero.
  • Enabled: It has no disabled attribute or aria-disabled.
  • Stable: It's not moving or animating.
  • Able to receive pointer events.
  • Configured so that the locator resolves to only one element. 

With auto-waits, there is no time specification. They eliminate the need to set hard waits and timeouts, as once a condition is activated, the wait ends automatically. This works well for simple scenarios, but it may not always be sufficient, especially when we encounter more dynamic content.

When auto-wait fails

Despite being the default in Playwright, there are scenarios when using auto-waits might not be the best option.

For example, when a particular element exists in the DOM but is not yet visible, Playwright auto-wait might fail to execute. In these cases, the test could proceed too early, causing errors. Auto-wait can also fail in situations when a form submission triggers a dynamic modal or a page navigation.

Auto-waiting alone might not handle these scenarios well, especially if the page is still loading in the background or if there are asynchronous events that require our attention. This is when it's appropriate to implement explicit waits.

Explicit waits

This type of waiting mechanism lets you explicitly specify a precise condition that must be met before an action is implemented. Explicit waits can be condition-based or unconditioned. Unconditioned waits are mostly referred to as a hard waits. Hard waits are fixed, hardcoded delays.

Below are several examples of condition-based explicit waits:

1. Controlled page navigation with wait_for_url()

In the following scenario, the form submission triggers navigation. Let's write a test in Python for the login flow on our test website, Techtarget.com:

from playwright.sync_api import sync_playwright, expect

def test_techtarget_login_flow():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)  

        page = browser.new_page()
        page.goto("https://www.techtarget.com/login")         
        page.wait_for_load_state("networkidle")
        expect(page.get_by_text(Log In")).to_be_visible() 
        expect(page.get_by_placeholder("Corporate Email Address")).to_be_visible()
        expect(page.get_by_placeholder("Password")).to_be_visible()
        expect(page.get_by_role("button", name="Log In")).to_be_visible()
        test_email = "[email protected]"
        test_password = "yourtestpassword"

        page.get_by_placeholder("Corporate Email Address").fill(test_email)  

        page.fill("input[type='password']", test_password)
        page.get_by_role("button", name="Log In").click()


        page.wait_for_url("https://www.techtarget.com”, timeout=20000)

        # Verify success 

        print("Form submission successful")
       
        browser.close()

# Run the test
test_techtarget_login_flow()

Note that the waitForURL(url, options) format is standard for the JavaScript, TypeScript, .NET (C#) and Java versions of Playwright.

2. Handling dynamic elements with wait_for_selector(selector, options)

Using the wait_for_url() function is not suitable for a website where form submissions do not result in a URL redirect but instead load additional content, such as a modal and new fields appearing on the same page. If the page navigation is delayed or a network request is still in progress when the page reloads, the script will end up waiting for a navigation that occurs too early, or it will proceed before the page is fully loaded. 

This is a source of flakiness in tests. A better solution is to use wait_for_selector(), which polls the DOM until an element matching the selector reaches a desired state.

3. Handling multiple asynchronous events with wait_for_response(), wait_for_request() and wait_for_event()

With various processes, such as API calls and DOM updates, running in parallel, intercepting the network layer is crucial for user interactions that initiate longer computations. The wait_for_response() function ensures your tests wait for specific network calls, especially when there is no visible change to the UI. wait_for_request() confirms that a call was initiated, while wait_for_event() waits for browser events/page events like pop-ups and dialogue.

It can be used with a glob URL pattern, a regular expression or a predicate that takes a response object. 

4.  wait_for_load_state(state, options)

When multiple network requests and responses are required for a page to load, this becomes redundant, as it doesn't guarantee that all resources have been loaded. This is where wait_for_load_state() can be used.

await page.getByRole('button').click(); // Click triggers navigation.
await page.wait_for_load_state(); // The promise resolves after 'load' event.

It returns when the desired load state has been reached. This is mostly suitable if you need to ensure the entire page has been loaded. 

5.  Waiting for custom JavaScript functions with wait_for_function()

Sometimes, you may want to evaluate whether certain conditions have returned a specific status. In such a scenario, you can use the wait_for_function(). Once your status has changed to your custom status, the test script proceeds.

The main difference between this and wait_for_selector() is that you can configure it to automatically detect the exact function you want to wait for, instead of a selector that waits for an element whose function can change anytime.

For example: 

# Sync
page.wait_for_function("() => window.uploadComplete === true")
# Async
await page.wait_for_function("() => window.uploadComplete === true", timeout=30000)
print("File upload completed!")

It can also detect state changes that are not tied to DOM elements. It's more useful when an element exists that is not in a ready state. For example, a button can be visible but still be disabled internally.

Hard waits and timeouts

Hard waits are fixed delays in testing used to pause the execution of code for a predefined period, regardless of the app's loading state. Hard waits are usually achieved using wait_for_timeout() -- where what's between the parentheses is a value of milliseconds.

For example, the below is a hard wait to pause execution for 3 seconds, denoted by 3,000 milliseconds:

await page.waitForTimeout(3000);

Hard waits can lead to a waste of time. In the above example, the script was written to pause the execution for three seconds. However, if for some reason, the app loads in one second, the app would have to wait until the three seconds elapse anyway.

Hard waits can also be ineffective. If the specified test waiting time is too short for the condition, it will result in a failure when the hard wait time is exceeded. Using our previous code snippet, we can see that our hard wait was set to three seconds. If the app takes longer than three seconds to load, an error will be thrown.

Timeouts set a limit on the amount of time a script will wait until a condition is met, and if that condition isn't met, the test script fails. Timeouts are often used as an alternative to hard waits. Unlike hard waits, which continue waiting even if the condition is met within the stipulated time frame, timeouts immediately stop waiting and proceed if the condition is met within that time.

Below is an example timeout script:

wait page.wait_for_selector("element ID", {timeout:3000}):

In this example, we see that once the element we are referencing executes within the three seconds, the script continues instead of waiting until three seconds elapse.

Though timeouts are more flexible than hard waits, they are not without their own problems. One major problem with timeouts, which they also share with hard waits, is that they can fail a test if it executes after the specified time has elapsed. Using our timeout code snippet example, we can see that if our element responds after exactly three seconds, the test will fail automatically.

Web-first assertions

Web-first assertions are built to handle the dynamic nature of web applications by retrying until the specific conditions are met. They are better suited for handling the dynamic nature of web apps because of their auto-retry feature, which is combined with the built-in auto-wait.

Below are common example use cases for web-first assertions:

# Python (sync or async API)
from playwright.sync_api import expect   # or async_api for await

# Visibility
expect(page.locator('.status')).to_be_visible()
expect(page.get_by_role('button')).to_be_visible()
expect(page.get_by_role('button')).to_be_hidden()

# Text checks
expect(page.locator('h1')).to_have_text('Welcome')
expect(page.locator('.error')).to_contain_text('Invalid')

# State checks
expect(page.get_by_role('button')).to_be_enabled()
expect(checkbox).to_be_checked()
expect(page.locator('.item')).to_have_count(5)

# Values and attributes
expect(page.locator('input')).to_have_value('test')
expect(page.locator('button')).to_have_attribute('disabled', 'true')

Waiting for custom conditions with retrying and polling APIs

Playwright auto-waits handle most situations that require waiting mechanisms, but some situations require using custom logic, such as certain API responses. A common situation is when you need to poll an external/background API that isn't directly triggered by a page action.

Polling will query the API at specific intervals until the condition is accomplished.

The following example is provided to illustrate the point. This scenario waits for a long-running job to complete after a button click:

import time
from playwright.sync_api import sync_playwright, expect, TimeoutError as PlaywrightTimeoutError

def wait_for_api_condition(page, url, condition, timeout=60000, interval=2000):
    """Polls API until condition is met."""
    start = time.time()
    while (time.time() - start) * 1000 < timeout:
        json_data = page.evaluate("""
            async (url) => {
                try {
                    const res = await fetch(url, { credentials: 'include' });
                    if (!res.ok) return null;
                    return await res.json();
                } catch { return null; }
            }
        """, url)
        if json_data and condition(json_data):
            return json_data
        page.wait_for_timeout(interval)
    raise PlaywrightTimeoutError(f"API condition not met within {timeout}ms")

# Scenario: Wait for long-running job completion after button click
with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto("https://example.com/start-job")  # Hypothetical app page
   
    # Trigger job
    page.get_by_role("button", name="Start Processing").click()
    expect(page.get_by_text("Job started")).to_be_visible()
   
    # Poll API for completion
    result = wait_for_api_condition(
        page,
        "https://api.example.com/job/status",
        lambda data: data.get("status") == "completed",
        timeout=120000,  # 2 minutes
        interval=5000    # Every 5 seconds
    )
    print("Job completed:", result)
    browser.close()

Another alternative is to exclusively use pure API polling if the API does not require browser cookies.

Dealing with overlays

UI elements like popups, loading spinners and modals are all overlays that appear above the main content. Sometimes, these overlays can interfere with Playwright, as they might block the elements that the Playwright test is interacting with.

There are Playwright methods used to handle these overlays and test the elements they might obscure. Some overlays might not be visible, but still cause interference with page elements.

To handle this, use the following code:

# Wait for overlay to detach (completely removed from DOM)
overlay = page.locator('.transparent-overlay')
overlay.wait_for(state="detached")  # Waits until overlay is gone
page.locator('#start-button').click()

Using Playwright's locator, we locate the transparent overlay, wait for it to be detached and interact with whatever element is behind it.

You can also bypass the overlays and force interaction with the element using the force option:

await page.locator('#submitButton').click({ force: true });

Alternatively, you can wait for the overlay to disappear if it is timed. Once it has disappeared, you can interact with your page's elements. A page with a spinner is a good example, as in the following code:

const loadingSpinner = page.locator('.loading-Spinner');
await loadingSpinner.wait_for({ state: 'hidden' });
// Interact with the element behind the spinner
await page.locator('#submit-button').click();

Best practices

The following are some best practices for waiting in Playwright.

1. Avoid using hardcoded delays unless necessary. Hard waits are useful in some contexts, but they should only be used when necessary. The built-in methods of implicit wait mechanisms can -- in virtually any scenario -- do the work of hard waits. For example, instead of using a specific timeout to time for an element that is expected to be visible, simply use the implicit wait until the element becomes visible. This can greatly reduce flaky tests.

2. Use locators over generic selectors. Instead of waiting for selectors, use the locator method to find elements on a page.

3. Combine different wait mechanisms for complex scenarios. Use different waiting methods when possible to achieve a better testing experience.

4. Include error handling. Errors always happen, regardless of your level of expertise. To ensure that your script doesn't fail unexpectedly, use error-handling techniques like try and catch, especially if your work is complex.

Troubleshooting common issues

Below are some examples of common issues and ways to troubleshoot them.

Network tests

Certain elements might load unusually in Playwright. Most concerning are those that rely on external sources. To ensure that testing is successful, use the waitUntil option so that the test commences once the page has successfully loaded and is idle. Below is an example use of waitUntil:  

await page.goto('https://www.techtarget.com', {waitUntil: 'networkIdle' });

Timeout

If you are using an explicit wait and must specify a timeout, keep your timeout range very near to what you expect the script to do. For example, if the element would typically take five seconds, you could use something like the following:

await page.waitForSelector('#element', { timeout: 5500 }); 

 This gives it nearly enough time, should a slight delay occur. Another way of handling timeout errors is by combining them with other wait mechanisms.

Tips for debugging

Below are some tips for debugging waits in Playwright.

1. Run the Playwright Inspector tool. Debugging has been made easier with the Playwright Inspector tool. Run your tests with the PWDEBUG=1 environment variable, as in the following code:

PWDEBUG=1 pytest -s   # or npx playwright test --debug

2. Run in headed mode. Always start with headless=False to visually see what's happening. Add slow_mo in milliseconds to slow down actions for easier observation:

browser = p.chromium.launch(headless=False, slow_mo=500)  # slow_mo adds delay between actions

3. Use Playwright's built-in Trace Viewer. Use this to record traces for later analysis:

context = browser.new_context()
context.tracing.start(screenshots=True, snapshots=True, sources=True)
# ... run failing test steps ...
context.tracing.stop(path="trace.zip")

Then view with:

npx playwright show-trace trace.zip

Trace Viewer displays the timeline, actions, network, screenshots and DOM snapshots.

Use page.pause() to pause execution at an exact point.

Wisdom Ekpotu is a DevOps engineer and technical writer focused on building infrastructure with cloud-native technologies.

Dig Deeper on Application management tools and practices