TutorialsJan 21, 202614 min read

Mastering waits and timeouts in Playwright

Waweru Mwaura

Software Engineer

If you have written any kind of end-to-end tests or UI tests you probably know that the greatest headache to deal with is test flakiness due to browser actions not behaving in the way that you expect them to behave. This flakiness can be a major bottleneck especially in CI/CD pipelines due to constant failures. Improper handling of waits and timeouts can be a large contributor to test flakiness and most often than not tests do not take full advantages of built in features to create stability in the written tests.

Because web applications use dynamic loading, it’s not always obvious that all elements will be fully loaded when the page loads. This calls for a robust testing strategy to handle dynamic elements to be able to write reliable end-to-end tests. This is where Playwright shines as it is built around ensuring that flakiness is avoided at all cost from the get-go.

In this tutorial, we’ll explore how to properly use waits and timeouts in Playwright. We’ll cover both explicit and implicit waits, learn when and how to override default timeouts, and apply best practices to make your end-to-end tests more resilient and reliable.

Prerequisites

Before you get started, you need to have the following to better understand this tutorial:

  • Node.js: You’ll need Node.js (version 16 or later) installed on your machine.
  • A CircleCI account: You can sign up for a free CircleCI account if you don’t have one.
  • A GitHub account: Your project will need to be on GitHub to connect it to CircleCI.
  • Git CLI installed on your machine and familiarity with git concepts
  • Basic knowledge of Playwright: Familiarity with writing basic Playwright tests will be helpful.
  • Familiarity with CI/CD: A basic understanding of continuous integration and delivery concepts will be beneficial.
  • Clone the project from GitHub using the command git clone https://github.com/CIRCLECI-GWP/playwright-waits-and-timeouts.git

Understanding waits in Playwright

At the core of waiting in a test, the goal is synchronization. Our test script needs to wait for the application that you are testing to get to a certain state before you can perform a certain action or create an assertion. The synchronization between the test and the application is what ensures that the dynamic elements are no longer are problem to validate as the test knows where the application state is at in any given test step. The beauty is that Playwright already has most of the waits already built in waiting activation.

Auto-waits

As mentioned above, for most actions, you do not need to explicitly create a wait in your test code as Playwright is constantly expecting that the web elements will be dynamically loaded. Before you perform a Playwright action like click(), fill() or something like textContent(), Playwright automatically performs a series of actionability checks. These includes waiting for the element to be:

  1. Attached to the DOM.
  2. Visible on the page.
  3. Stable with no animations that are incomplete.
  4. Enabled and able to receive events e.g clickable.

If any of these checks fail within a defined timeout, the action step in Playwright automatically fails. This is important for ensuring the stability of tests because the actionability step and the readiness of the application under test are already enabled in Playwright. The actionability checks also ensure that you do not need to use explicit waits for element states, making your test code cleaner and also readable.

Actionability checks

Explicit waits

While auto-waiting is a powerful tool in the Playwright toolbox, situations arise when a certain condition needs to be met before the next step of the test can execute and this might not be directly tied to an action. A good example would be waiting for a download action of a file to happen before you execute a set of commands or even waiting for a network request to be completed before you click a button. For such instances, Playwright has in-built methods like waitForSelector(), waitForResponse() or even waitForLoadState(). These ensure that actions outside the normal actionability of elements are covered too.

Smart waits & timeouts

While “smart waits” is not really a Playwright term, they are a way that you can use Playwright’s auto waiting and assertion features intelligently. Instead of hard-coding a waitForTimeout() (which is a bad practice), you can instead use a locator and a web-first assertion. Something like expect(locator).toBeVisible() will wait for the element to be visible and uses a default timeout. Because it is tied to an action you can observe, you can consider this a “Smart” timeout.

Timeouts are a Playwright safety net. Every wait in Playwright always has a timeout, either automatically obtained from configuration or explicitly provided in the test. The default timeout in Playwright is 30 seconds, but this is configurable. Playwright throws a TimeoutError when the condition being waited for exceeds the timeout that is provided by the condition. This also ensures that your tests do not hang indefinitely with no feedback loop. The earlier example of waiting for a locator is a good example of an explicit timeout. This assertion expect(locator).toBeVisible({ timeout: 40000 }) waits for 40 seconds as a maximum timeout waiting for the locator, if the locator will not be visible within the 40 seconds the TimeoutError will be thrown by the test.

Smart waits

Playwright in-built waits

Playwright has a rich set of waiting mechanisms to ensure that test scripts utilize any or all of them for stability and also ease of maintainability.

Time

Waiting using time uses page.waitForTimeout(milliseconds) to pause the test for a fixed duration. While this is an in-built method in Playwright, it is recommended for debugging only. Writing it in your test code leads to flakiness and slowness. The time needs to elapse without knowledge of whether the action or actionability you were waiting for happened. There is an example of this in app.spec.ts in the playwright/tests directory of the cloned repository above. To try it out run:


// playwright/tests/app.spec.ts

test("should wait for timeout - ONLY FOR DEBUGGING PURPOSES", async ({
    page,
  }) => {
    // 1. Navigate to the app.
    await page.goto(`${APP_URL}/dashboard`);

    // 2. Go to the products page.
    await page.getByRole("button", { name: "Products" }).click();

    await page.waitForURL("**/dashboard");

    // 3. Click the button to load products.
    await page.getByRole("button", { name: "Load Products" }).click();

    // 4. Wait for the loading spinner to be hidden.
    // This is a crucial wait pattern for dynamic content. The test will pause
    // here until the loader is gone, indicating the data has loaded.
    const loader = page.locator("#loading-spinner");
    await page.waitForTimeout(3000);  // THIS IS ONLY MEANT FOR DEBUGGING PURPOSES AND SHOULD NOT BE USED IN PRODUCTION TESTS
    await expect(loader).toBeHidden({ timeout: 5000 }); // Wait up to 5s.
  });

This test uses the waitForTimeout() method to show how to wait explicitly for a specific time to pass before an action is taken in Playwright. This should only be used in debugging conditions and not in the real world tests.

Note: Provided timeout of 3 seconds will have to be waited for by Playwright which makes this a bad test practice as whether or not the condition is met, the test will still wait for the full 3 seconds.

Element

While waiting for time is not the best approach, you can use for elements to be present in the DOM. By using locator.waitFor(options) you can explicitly wait for an element to be in a specific state such as being visible or hidden. A good use case would be toggling a button and verifying that some items are hidden when you toggle off and subsequently visible when you toggle on. This can be implemented as shown below while waiting for the Log In button to be enabled. The code snippets can be found in the cloned repository in the file app.spec.ts in playwright/tests directory


// playwright/tests/app.spec.ts

  test("should handle login form interaction correctly", async ({ page }) => {
      // 1. Navigate to the app. - this is the URL hosting the application
      await page.goto(APP_URL);

      // 2. Navigate to the login page within the app.
      await page.getByRole("button", { name: "Login" }).click();

      // 3. Assert that the "Log In" button is initially disabled.
      // Playwright's `toBeDisabled` assertion handles this check gracefully.
      const loginButton = page.getByRole("button", { name: "Log In" });
      await expect(loginButton).toBeDisabled();

      // 4. Fill in the form fields.
      await page.getByLabel("Username").fill("testuser");
      await page.getByLabel("Password").fill("password123");

      // 5. Assert that the button is now enabled.
      // `toBeEnabled` is a web-first assertion that will wait and retry
      // until the element is no longer disabled.
      await loginButton.waitFor({ state: "visible", timeout: 5000 });

      // 6. Click the login button and assert navigation to the dashboard.
      await loginButton.click();
      await expect(
        page.getByRole("heading", { name: "Welcome, Test User" })
      ).toBeVisible();
    });

This test waits for the state of the Log In button to be visible before proceeding with other actions. Waiting for an element provides a large array of possibilities; you can wait for loading screens, modals, buttons, menus, and even iframes and perform actions only when those elements are present.

There are multiple Playwright built-in functions that you can use to wait for Playwright navigation before carrying out an action:

  • page.waitForURL(url, options) waits for the page to go to a new URL after an action triggers a page load
  • page.waitForLoadState(state, options) waits for the page to get to a certain loading state. This loading state can either be load or domcontentloaded.

You can find these functions in the playwright/tests/app.spec.ts file. The file is also shown here:


  // playwright/tests/app.spec.ts
  test("should wait for the navigation load state before showing products", async ({
    page,
  }) => {
    // 1. Navigate to the app.
    await page.goto(`${APP_URL}/dashboard`);

    // 2. Go to the products page.
    await page.getByRole("button", { name: "Products" }).click();

    await page.waitForURL("**/dashboard");

    // 3. Click the button to load products.
    await page.getByRole("button", { name: "Load Products" }).click();

    // 4. Wait for the loading spinner to be hidden.
    // This is a crucial wait pattern for dynamic content. The test will pause
    // here until the loader is gone, indicating the data has loaded.
    const loader = page.locator("#loading-spinner");
    await expect(loader).toBeHidden({ timeout: 5000 }); // Wait up to 5s.

    // 5. Assert that the product list is now visible.
    const productList = page.locator(".product-item");
    await expect(productList).toHaveCount(3);
    await expect(productList.first()).toHaveText("Laptop");

    // Navigate to the products page and wait for the page resources to load.
    await page.goto(`${APP_URL}/products`);
    await page.waitForLoadState("load");
  });

In this test, you will use Playwright’s navigation in-built waits to verify that you can wait for a specific URL to load by using either the waitForURL() and waitForLoadState() Playwright methods. These methods wait and verify that a specific URL has been loaded and that the load status has completed for the new page. In this case, it’s the /products page.

Network

Network is among the most important inbuilt waits in Playwright. The function page.waitForResponse(urlOrPredicate, options) waits for specific network responses. A good case includes:

  • Making requests using Playwright browser interactions.
  • Verifying that the requests are successful before you trigger the next test action.

This wait ensures that you can test applications and fetch data asynchronously from all the endpoints that the test application interacts with. Because you aren’t using a live API for the test application you’ll use a sample test snippet that is not included in the test repository. This simple code snippet shows the utilization of the sample network test:

// sample network fetch test
test('should wait for network response of the API response', async ({ page }) => {
    await page.goto('https://myapp.com/products');
    // Start listening for the API response BEFORE the action that triggers it.
    const responsePromise = page.waitForResponse('**/api/v1/products');
    // This action fetches product data from the backend.
    await page.getByRole('button', { name: 'Load Products' }).click();
    // Wait for response to resolve
    const response = await responsePromise;
    // You can now assert things about the response itself
    await expect(response.status()).toBe(200);
    // And then assert the UI updated as a result of the data.
    await expect(page.locator('.product-list > .product-item')).toHaveCount(10);
});

This snippet shows how Playwright uses the waitForResponse() wait to verify that the /products endpoint response has been returned before the Products of the product page load. This creates more robustness in the test and handles a point of failure where the API response may not be received from the endpoint.

State

To wait for a given state before continuing with a test action in Playwright you can use the function page.waitForFunction(pageFunction, arg, options). This is the most flexible wait as it allows us to wait for any given condition by executing a JavaScript function in the browser context until a truthy value is returned. A simple implementation of a test of adding items to cart from your sample application is as shown below. This is also part of app.spec.ts in your cloned repository.

  // playwright/tests/app.spec.ts
 test("should wait for a state update on item addition", async ({ page }) => {
    // 1. Navigate to the app's home page.
    await page.goto(APP_URL);

    const cartBadge = page.locator(".cart-badge");

    // 2. Assert the initial state of the cart.
    // `toHaveText` is also a web-first assertion that will retry.
    await expect(cartBadge).toHaveText("Cart (0 items)");

    // 3. Click the "Add to Cart" button.
    const addButton = page.getByRole("button", { name: "Add to Cart" });
    await addButton.click();

    // 4. Assert the cart count has updated to 1.
    await expect(cartBadge).toHaveText("Cart (1 items)");

    // 5. Click "Add to Cart" again.
    await addButton.click();

    // Assert final state of the cart after two additions.
    await page.waitForFunction(() => {
      const cartCounter = document.querySelector(".cart-badge");
      return cartCounter && cartCounter.innerText.includes("2 items");
    });

    // 6. Click the "Remove from Cart" button.
    const removeButton = page.getByRole("button", { name: "Remove from Cart" });
    await removeButton.click();

    // 7. Assert the cart count has updated back to 1.
    await expect(cartBadge).toHaveText("Cart (1 items)");
  });

This test shows how you can use a custom waitForFunction() to wait for a specific state in Playwright to be achieved before you go to the next step. This creates flexibility by letting you wait for any kind of action in Playwright. You can customize it as much as you want to create rigidity in your tests.

Best practices of using waits

  • Rely on auto-waits: Trust Playwright’s auto-waiting mechanism. Actions like click() and assertions like expect(locator).toBeVisible() have auto-waiting built-in. This should be your default approach in most of your cases or even for elements that take too long to be rendered.
  • Avoid waitForTimeout: This cannot be stressed enough. Hard-coded waits are a primary source of flaky and unreliable tests. Only use them for local debugging and not for test code that executed in a CI/CD platform.
  • Use web-first assertions: Assertions like expect(locator).toHaveText() will automatically wait and retry as this is built in to Playwright, making your tests more resilient and your intent clearer.
  • Be specific with locators: Use user-facing locators like getByRole, getByText, and getByTestId. The more specific your locator, the more reliable your test will be. The above approaches ensure that Playwright can check whether your element has your defined role, text or testId making it easier to debug failure when your test does so.
  • Use waitForResponse for Data-Driven UI: If a UI element appears after a background API call, it’s much more reliable to wait for the API response than to guess how long it will take for the element to render or just arbitrarily wait for some time to lapse.

Wait patterns

To write reliable tests in Playwright, you have a few common scenarios and the recommended wait patterns using practical snippets.

Waiting for an element to appear after a click

In this case it is recommended to use a web-first assertion which makes the test cleaner and also achieves the goal of an explicit wait.

test('waits for modal to appear', async ({ page }) => {
    await page.goto('https://myapp.com');

    // This click opens a modal dialog.
    await page.getByRole('button', { name: 'Show Terms' }).click();

    // The assertion will wait automatically for the element to become visible.
    const modalTitle = page.getByRole('heading', { name: 'Terms and Conditions' });
    await expect(modalTitle).toBeVisible({ timeout: 5000 }); // Optional custom timeout
});

Waiting for a loading spinner to disappear

If waiting for an element to be hidden from the DOM or to be detached you can use web first assertions, instead of using general timeouts without knowing when the elements will be hidden or detached.

test('waits for loader to disappear', async ({ page }) => {
    await page.goto('https://myapp.com/data');

    // The action that shows a loader
    await page.getByRole('button', { name: 'Fetch Data' }).click();

    // The assertion will wait for the loader element to be removed from the DOM or hidden.
    const loader = page.locator('#loading-spinner');
    await expect(loader).toBeHidden({ timeout: 10000 }); // Wait up to 10s for data to load

    // Now it's safe to interact with the data table.
    await expect(page.locator('table > tbody > tr').first()).toBeVisible();
});

Waiting for a button to become enabled

Often, you’ll need to wait for an element like a button to be enabled and clickable after an action such as filling in a form. Using web first assertions you can prevent flakiness by ensuring you are waiting for actionability steps to pass before executing the assertions.

test('waits for submit button to be enabled', async ({ page }) => {
    await page.goto('https://myapp.com/signup');
    const submitButton = page.getByRole('button', { name: 'Create Account' });

    // Initially, the button is disabled.
    await expect(submitButton).toBeDisabled();

    // Fill out the form.
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('a-strong-password');
    await page.getByLabel('I agree to the terms').check();

    // The assertion waits for the button's 'disabled' attribute to be removed.
    await expect(submitButton).toBeEnabled();

    await submitButton.click();
});

Integrating CircleCI and running tests

Now that you have learned how to add waits and timeouts in tests, you’ll want to run your tests in the CI/CD using CircleCI.

Note: This step is optional if you have already cloned the repository. If you have not and are looking to add CircleCI into another project, you can use the configuration file shown.

You need to create a .circleci/config.yml file in the root directory of your project. This file will define the steps to check out your code, install dependencies, and run your Playwright tests. Here is a sample config.yml file designed for running Playwright tests:

version: 2.1

jobs:
  build:
    docker:
      - image: mcr.microsoft.com/playwright:v1.52.0-jammy
    working_directory: ~/repo
    steps:
      - checkout
      # Download and cache dependencies
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-

      - run: cd playwright && npm install
      - run: cd playwright && npx playwright install
      - run: cd playwright && npm run test

      - store_artifacts:
          path: ./playwright-report
          destination: playwright-report-first

Let’s break down this configuration file:

  • version: specifies the CircleCI version; in this case ` 2.1:`.
  • jobs: defines the tasks to be executed. There is one job, named test.
  • steps: these are the individual commands to be run.
    • checkout: checks out your source code from the repository.
    • node/install-packages: a command from the node orb that installs project dependencies (e.g., npm install).
    • npx playwright install –with-deps: This is a crucial step. It downloads the browser binaries that Playwright needs to run tests. –with-deps ensures all necessary OS-level dependencies are installed.
    • npm run test: executes your Playwright tests.
    • store_artifacts: saves the Playwright HTML report, which you can view in the “Artifacts” tab of your CircleCI job. It’s optional to have artifacts.

With this configuration, every time you push a change to your GitHub repository, CircleCI will automatically trigger a build, install everything needed, and run your full suite of UI and API tests. Now that you have a CircleCI configuration, you can add your Project to CircleCI.

Setting up a project on CircleCI

If you cloned the sample project repository, your project is already integrated with Git. Otherwise, if you started creating this project from scratch, to set up CircleCI, you need to initialize a Git in your project by running this command:

git init

Next, create a .gitignore file in the root directory. Inside the file, add node_modules to ignore npm-generated modules from being added to your remote repository. Add a commit and then push your project to GitHub.

Log in to CircleCI and create a project by linking to your GitHub repository.

After the setup is complete, trigger the pipeline manually and it should execute successfully:

Project test run

Conclusion

This tutorial covered the critical concepts of waits and timeouts in Playwright, focusing on how to write stable, reliable, and maintainable end-to-end tests. You also learned about Playwright’s auto-waiting mechanisms, explicit waits, smart waits, and best practices for handling dynamic web elements. You now know how to avoid flaky tests by leveraging web-first assertions and minimizing the use of hard-coded timeouts. You used practical wait patterns for common scenarios and showed how to integrate your Playwright tests into a CircleCI pipeline for automated testing in CI/CD workflows.

By mastering these techniques, you can ensure that your tests are robust and your development pipeline remains efficient. With Playwright’s powerful waiting strategies and CircleCI’s automation, you’re well-equipped to handle even the most dynamic web applications with confidence. Until next time, happy testing!