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.
Read More: Playwright vs Cypress: A Comparison
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.
Read More: Web Scraping with Playwright
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.
Read More: Explaining Playwright Architecture
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.
Read More: How to install Playwright in 2025
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.