expect.toPass assertion in Playwright

expect.toPass is a Playwright assertion that retries a callback until all checks pass. Explore its timeout, intervals, and practical use cases.

Written by Nithya Mani Nithya Mani
Reviewed by Sarthak Sharma Sarthak Sharma
Last updated: 28 July 2026 14 min read

Key Takeaways

  • expect.toPass reruns an entire assertion callback until it passes or times out, making it useful for custom conditions that change over time.
  • Use standard locator assertions for single UI checks. Choose expect.toPass when several related checks or application states must pass together.
  • Keep actions outside the callback, read fresh state on every retry, and set timeouts based on expected behaviour rather than masking slow tests.

The last time I was testing a status update in Playwright, the assertion failed even though the feature was working. I initially blamed the locator, but the actual reason was timing. The API response was slightly delayed, the component had not finished rendering, and the test checked the value before the final state appeared.

This can happen because of slow network responses, delayed state updates, animations, background polling, or differences between local and CI execution. The difficult part is knowing whether the test is genuinely failing or simply checking too early.

And if that sounds familiar, do not worry. I have run into it more times than I can count. expect.toPass helps in such cases by retrying a block of assertions until they pass or the timeout is reached.

What expect.toPass Is and Why Playwright Includes It?

expect.toPass retries an entire callback until every assertion inside it passes. If any assertion fails, Playwright waits for the next retry interval and runs the callback again.

Playwright includes it because not every wait can be expressed through a single locator assertion. Assertions such as toHaveText() and toBeVisible() already retry on their own, but they work best when one locator and one expected state are enough.

You may need to confirm that a request has completed, the UI has updated, and a status value has changed as part of the same check. Instead of adding a fixed delay and hoping the application is ready, you can retry the actual conditions that prove it is ready.

How Playwright’s expect.toPass Works?

As mentioned above, expect.toPass runs the callback once and checks whether all assertions pass. If one fails, Playwright waits for the next configured interval and runs the complete callback again.

This continues until either:

  • every assertion passes
  • the timeout is reached

You can control both the total timeout and the delay between attempts:

await expect(async () => {

  const status = await page.locator('#status').innerText();

  expect(status).toBe('Completed');

}).toPass({

  intervals: [100, 250, 500],

  timeout: 5000

});

In this example, Playwright retries the callback after 100 ms, then 250 ms, and then 500 ms for later attempts until the assertion passes or five seconds have elapsed. Each attempt reads the current value again, so the test checks the application’s latest state rather than reusing the result from the first attempt.

When to Use expect.toPass in Playwright?

Some UI behaviors are inherently asynchronous. expect.toPass fits naturally in scenarios such as:

  • UI elements that appear only after background async processes complete
  • Status indicators that change after polling or server-side events
  • Interfaces that use progressive hydration or streaming
  • Multi-step screens where transitions occur on timers

UI state is almost never instantaneous-it’s a moving target shaped by async events. This aligns closely with why expect.toPass exists: to provide room for UI volatility without compromising test accuracy.

Writing expect.toPass: A Practical Example

A typical use case is validating a status message that updates after a server response.

await expect(async () => { const text = await page.locator(‘#status’).innerText();
expect(text).toBe(‘Completed’);
}).toPass();

The callback keeps retrying until the element contains the final expected value.

Understanding Playwright Retry Behavior

Playwright does not run the callback continuously. After a failed attempt, it waits for the next retry interval and then runs the entire callback again.

By default, the retry intervals are 100 ms, 250 ms, 500 ms, and 1 second. Once Playwright reaches the last value, it continues using that interval for the remaining attempts.

expect.toPass has no timeout by default and does not use the general expect timeout configured for other assertions. Set the timeout directly when the condition needs time to change.

await expect(async () => {

  const status = await page.locator('#status').innerText();

  expect(status).toBe('Completed');

}).toPass({

  timeout: 5000

});

This is more reliable than adding waitForTimeout(5000) because the test can continue as soon as the expected state appears. It does not wait for the full five seconds on every run.

expect.toPass vs Standard Playwright Assertions

Standard assertions like toHaveText, toBeVisible, or toHaveCount already include auto-retrying, but only for DOM states Playwright can observe directly. expect.toPass expands this to any custom condition-DOM-related, API-related, or logic-related.
This makes it useful when testing:

  • computed values
  • multi-step conditions
  • external state checks
  • a combination of multiple assertions

In short, when a single locator cannot express the test’s intent, expect.toPass fills the gap.

Using expect.toPass for Dynamic or Flaky UI Elements

Dynamic UIs often flicker through intermediate states-loading spinners, placeholder content, partial hydration, or mounted/unmounted fragments. These transitional states are a major cause of flakiness.

expect.toPass provides resilience. For example, when validating that a loader disappears:

await expect(async () => { expect(await page.locator(‘#loader’).count()).toBe(0);
}).toPass();

This avoids brittle assumptions about exact timing.

Handling Async Operations with expect.toPass

Asynchronous data pipelines, animations, and event-based rendering often rely on timers or background tasks.
Example:

await expect(async () => { const ready = await page.evaluate(() => window.appReady);
expect(ready).toBe(true);
}).toPass();

This ensures the test waits for the true application-ready state instead of racing the UI.

Best Practices for expect.toPass

expect.toPass can reduce timing-related failures, but only when the callback represents a condition that should become true. These practices help you use its retries without hiding a slow application, repeating side effects, or making failures harder to diagnose.

1. Use locator assertions before reaching for expect.toPass

Playwright locator assertions such as toHaveText(), toBeVisible(), and toHaveCount() already retry. Wrapping one of these assertions in expect.toPass usually adds another retry layer without improving the check.

// Prefer this

await expect(page.getByTestId('status')).toHaveText('Completed');


// Avoid this

await expect(async () => {

  await expect(page.getByTestId('status')).toHaveText('Completed');

}).toPass();

Use expect.toPass when the condition cannot be represented by one auto-retrying assertion. This may include checking values from multiple elements, validating an API response, or combining UI and application-state checks.

2. Keep actions outside the callback

The complete callback runs again after every failed attempt. Any click, form submission, record creation, or API mutation inside it may therefore happen several times.

// The action runs once

await page.getByRole('button', { name: 'Submit' }).click();



await expect(async () => {

  const status = await page.getByTestId('status').textContent();

  expect(status).toBe('Completed');

}).toPass({

  timeout: 5000

});

The callback should normally read state and assert it. It should not perform the action that creates that state.

3. Make every attempt independent

Do not rely on a value captured before expect.toPass starts. Playwright needs to read the current state during every attempt.

// Incorrect: status is read only once

const status = await page.getByTestId('status').textContent();


await expect(async () => {

  expect(status).toBe('Completed');

}).toPass();

Read the value inside the callback instead:

await expect(async () => {

  const status = await page.getByTestId('status').textContent();

  expect(status).toBe('Completed');

}).toPass({

  timeout: 5000

});

This ensures each retry checks the latest application state rather than the result of the first read.

4. Set a timeout for the state being tested

expect.toPass has a timeout of 0 by default. It also does not automatically use the general timeout configured for other expect assertions. Set the timeout directly unless you intentionally want only the initial attempt.

await expect(async () => {

  const response = await page.request.get('/api/jobs/123');

  expect(response.status()).toBe(200);

}).toPass({

  timeout: 10_000

});

The timeout should reflect how long the application is allowed to take, not how long the test can tolerate waiting. If a status should update within five seconds, a thirty-second timeout can hide a performance regression.

5. Match retry intervals to the expected update pattern

The default intervals are 100, 250, 500, and 1000 milliseconds. After the final value is reached, Playwright continues using that interval for later attempts.

The defaults work for UI changes that should happen quickly. Longer intervals are more suitable when the underlying system updates through background jobs, scheduled polling, or eventually consistent APIs.

await expect(async () => {

  const response = await page.request.get('/api/report/status');

  const body = await response.json();



  expect(body.status).toBe('ready');

}).toPass({

  intervals: [1000, 2000, 5000],

  timeout: 30_000

});

Frequent polling does not make a slow backend complete sooner. It only sends more requests and adds noise to logs.

6. Assert the final state, not every temporary state

A retry block should describe what must eventually be true. Avoid assertions about intermediate states unless those states are part of the requirement.

await expect(async () => {

  await expect(page.getByTestId('payment-status')).toHaveText('Paid');

  await expect(page.getByRole('button', { name: 'Download receipt' }))

    .toBeEnabled();

}).toPass({

  timeout: 10_000

});

This callback describes the completed payment state. It does not depend on whether the interface briefly showed Processing, a spinner, or another temporary value.

Common Mistakes to Avoid

Even when the callback looks correct, a few implementation mistakes can make expect.toPass behave differently from what you intended. These are the ones that show up most often.

1. Catching assertion errors inside the callback

expect.toPass retries only when the callback throws an error. If you catch the assertion failure and do not throw it again, Playwright treats the callback as successful.

await expect(async () => {

  try {

    const status = await page.getByTestId('status').textContent();

    expect(status).toBe('Completed');

  } catch (error) {

    console.log(error);

  }

}).toPass({

  timeout: 5000

});

This block can pass even when the status never becomes Completed.

Let the assertion error reach expect.toPass. Add logging if needed, but do not swallow the failure.

await expect(async () => {

  const status = await page.getByTestId('status').textContent();

  expect(status).toBe('Completed');

}).toPass({

  timeout: 5000

});

2. Forgetting to await asynchronous assertions

An asynchronous assertion must finish before the callback returns. Without await, the callback may complete before Playwright receives the assertion result.

await expect(async () => {

  expect(page.getByTestId('status')).toHaveText('Completed');

}).toPass();

The locator assertion returns a promise, so it should be awaited:

await expect(async () => {

  await expect(page.getByTestId('status')).toHaveText('Completed');

}).toPass({

  timeout: 5000

});

In this specific example, the outer expect.toPass is unnecessary because toHaveText() already retries. The same missing await problem can still occur when the callback contains other asynchronous checks.

3. Creating nested retries without realising it

Locator assertions retry internally. When you place one inside expect.toPass, the inner assertion may use its full timeout during every outer attempt.

await expect(async () => {

  await expect(page.getByTestId('status')).toHaveText('Completed', {

    timeout: 5000

  });

}).toPass({

  timeout: 10_000

});

This is not a simple ten-second retry. Each callback attempt can spend up to five seconds inside toHaveText() before the outer retry continues.

Use direct values inside expect.toPass when you need the outer block to control the retry cycle:

await expect(async () => {

  const status = await page.getByTestId('status').textContent();

  expect(status).toBe('Completed');

}).toPass({

  timeout: 10_000

});

4. Using expect.toPass when expect.poll expresses the check better

Both APIs retry, but they are suited to different shapes of checks. expect.toPass reruns a callback containing assertions. expect.poll repeatedly evaluates one value and applies a matcher to the returned result.

For a single value, expect.poll is often easier to read:

await expect.poll(async () => {

  const response = await page.request.get('/api/job/123');

  const body = await response.json();


  return body.status;

}, {

  timeout: 10_000

}).toBe('Completed');

Use expect.toPass when the callback needs several related assertions. Use expect.poll when you are repeatedly retrieving one value.

5. Assuming the final error shows every failed attempt

A callback may fail for different reasons across retries. The first attempt might fail because the element is missing. A later attempt might find the element but receive the wrong text. The reported failure usually reflects the last unsuccessful attempt, not the full sequence.

When the state changes during retries, use Playwright traces, network logs, or limited diagnostic logging to understand how it progressed.

await expect(async () => {

  const status = await page.getByTestId('status').textContent();



  console.log(`Current job status: ${status}`);

  expect(status).toBe('Completed');

}).toPass({

  timeout: 10_000

});

Keep this logging focused. Printing large responses on every attempt can make CI logs harder to inspect.

Debugging expect.toPass Failures

When expect.toPass times out, the main question is not just why the last assertion failed. You need to understand what happened across the retry window.

Start by checking whether the callback ever saw the expected state. A locator may have returned null on early attempts, an intermediate value later, and the wrong final value at timeout. Temporary logging can help you see that sequence.

await expect(async () => {

  const status = await page.getByTestId('status').textContent();




  console.log(`Current status: ${status}`);

  expect(status).toBe('Completed');

}).toPass({

  timeout: 10_000

});

Keep the log limited to the value that matters. Logging the full DOM or a large API response on every retry can make the actual failure difficult to find.

Next, confirm that the callback reads fresh state on every attempt. If a value is captured before expect.toPass starts, the retries will keep checking the same result.

// This value never changes during retries

const status = await page.getByTestId('status').textContent();



await expect(async () => {

  expect(status).toBe('Completed');

}).toPass({

  timeout: 10_000

});

Move the read inside the callback so every attempt gets the current value.

For UI failures, use Playwright Trace Viewer to inspect screenshots, DOM snapshots, console messages, and network activity around the failed step. This can show whether the component never rendered, the request failed, or the expected state appeared and disappeared before the callback checked it.

For API or background-job checks, log the response status and the specific field being asserted. A retry timeout may be caused by an authentication failure, a 500 response, stale test data, or a job that was never created.

await expect(async () => {

  const response = await page.request.get('/api/jobs/123');

  const body = await response.json();



  console.log({

    httpStatus: response.status(),

    jobStatus: body.status

  });



  expect(response.ok()).toBe(true);

  expect(body.status).toBe('Completed');

}).toPass({

  timeout: 15_000

});

Also check how close the test gets to the timeout. If it regularly passes after nine seconds with a ten-second limit, the retry is working, but the system is operating too close to the accepted boundary. That should be investigated rather than fixed by repeatedly increasing the timeout.

Performance Considerations with Retries

While retries enhance reliability, each additional attempt adds time. expect.toPass should therefore be applied only where conditions are truly dynamic. Overuse creates unnecessary overhead.

Teams often combine deterministic assertions for stable steps with retry-based assertions reserved for transition points.

Using expect.toPass in Page Object Models

Page Objects can embed retry logic, simplifying test files and centralizing complex transitions.

Example pattern:

async waitForActivation() { await expect(async () => {
expect(await this.page.locator(‘#state’).innerText()).toBe(‘Active’);
}).toPass();
}

This improves reusability and keeps state-synchronization logic in one place.

Using expect.toPass for Cross-Browser Testing

Browsers handle layout, hydration, and animation differently. Differences between Chromium, WebKit, and Firefox often surface in tests that rely on timing.
Retry-based assertions absorb timing variance, especially in areas influenced by:

  • varying JavaScript execution speeds
  • different rendering engines
  • animation timing differences

This makes suites more consistent across browser engines.

Scaling expect.toPass in CI Pipelines

CI runners often run on slower hardware, revealing timing issues that never appear locally. Google Web.dev highlights that slower CPUs lead to longer main-thread tasks and delayed rendering, which directly affects assertion stability.

expect.toPass mitigates these timing differences, creating more stable CI pipelines, especially under high parallelization or distributed execution.

Conclusion

expect.toPass is useful when a test needs to wait for a custom condition or a group of related checks to become true. It gives you more control than a single locator assertion, especially when the result depends on multiple UI states, API responses, or background processing.

The key is to use it only where retries are genuinely needed. Keep actions outside the callback, read fresh state on every attempt, and set timeouts based on expected application behaviour. When a test keeps passing close to the timeout, treat that as a signal to investigate rather than a reason to increase the limit.

Version History

  1. Jul 28, 2026 Current Version

    Reworked selected sections to remove generic explanations, clarify how expect.toPass behaves, and add practical guidance on retries, debugging, and common implementation mistakes.

    Sarthak Sharma
    Reviewed by Sarthak Sharma Senior Software Development Engineer
Tags
Playwright
Nithya Mani
Nithya Mani

Lead Engineer

Nithya Mani is a Lead Engineer with 8+ years of experience in customer solutions. She specializes in creating tailored testing solutions that address real customer needs and optimize workflows.

Assertions Failing Too Early?
Validate retry-based assertions across real browsers at scale.