UI automation and timing don’t really go well together.
Either a test checks an element too soon, an API takes too long, or a transition finishes late. Suddenly, a passing test fails.
If you’re using frameworks like React, Vue, or Angular, you’ve definitely run into this.
Components load asynchronously.
Animations get delayed.
And state updates happen just milliseconds apart.
So, how do you test something that’s true eventually, but not right away?
That’s where Playwright’s expect.toPass comes in. Instead of expecting everything to be ready immediately, it lets you retry an assertion until the condition is met. Simple, right?
Overview
Benefits of playwright expect.toPass
- Handles timing issues by retrying assertions until conditions are met
- Reduces flaky tests in dynamic environments
- Ensures reliable validation even with asynchronous UI changes
- Simplifies waiting logic for better readability
- Works well with modern frameworks like React, Vue, and Angular
- Reliable in CI environments with varying network or rendering delays
- Built-in retry mechanism for eventual consistency
With expect.toPass, you can turn these unpredictable situations into reliable tests. Want to see how it works? Let’s dive in.
What expect.toPass Is and Why Playwright Includes It?
expect.toPass is a retry-capable Playwright assertion. Unlike typical assertions that evaluate once and fail instantly, this one repeatedly runs a callback until it succeeds or times out. Modern apps frequently rely on asynchronous effects-server responses, client-side hydration, or UI transitions-and a single static check often fails to capture the final stable state.
Retry logic gives the UI time to “settle,” aligning the test with real user experience rather than momentary transitions.
How Playwright’s expect.toPass Works?
Playwright re-runs the callback function until:
- the inner assertions succeed, or
- a configured timeout expires
The callback behaves like a polling mechanism built directly into the test runner. Because retry logic is combined with Playwright’s Web-First Assertions engine, it automatically waits for DOM stability, visibility updates, and element readiness before making the next attempt.
This makes it ideal for validating states that converge over time.
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
Retries are spaced automatically, allowing the test runner to wait for the underlying UI state to stabilize. This avoids the common mistake of adding fixed waitForTimeout delays, which introduce unnecessary slowness without guaranteeing reliability.
Instead, expect.toPass adapts dynamically to real conditions, making tests more efficient in both fast and slow environments.
Playwright’s retry behavior can vary across devices and browsers, making it crucial to test on real environments. BrowserStack Automate enables you to run retries across real devices and browsers, ensuring accurate results and eliminating inconsistencies in your test suite.
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
When using expect.toPass in Playwright, it’s essential to apply the assertion effectively to avoid unnecessary delays and ensure tests remain reliable. Here are some best practices to make the most of this powerful feature:
1. Use Meaningful Timeouts
While Playwright allows for retry logic, it’s crucial to set a timeout that reflects realistic UI behaviors. A timeout that is too long can unnecessarily delay the test, while one that is too short can cause tests to fail prematurely. Ensure the timeout aligns with the expected UI transition time.
await expect(async () => { const text = await page.locator(‘#status’).innerText();
expect(text).toBe(‘Completed’);
}).toPass({ timeout: 5000 });Here, a timeout of 5000ms (5 seconds) would be ideal for cases where transitions typically take this long.
2. Keep the Callback Simple
Avoid overcomplicating the callback logic inside expect.toPass. Complex logic within the callback can make debugging harder and obscure the actual failure reason. Try to focus on a single state or behavior that the test is validating.
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Ready’);
}).toPass();This straightforward callback makes the test easy to understand and maintain.
3. Avoid Using expect.toPass for Static Elements
expect.toPass is designed for dynamic conditions, so using it for static, predictable elements only introduces unnecessary overhead. For elements that should appear or change immediately, use standard assertions like toBeVisible() or toHaveText() instead.
await expect(page.locator(‘#username’)).toBeVisible();
This direct assertion is faster and more appropriate for static content.
4. Apply Consistent Retry Patterns Across the Suite
Consistency is key. When you use expect.toPass in multiple tests or across different parts of your project, ensure it follows a consistent pattern. Centralizing retry logic in utility functions or page objects helps maintain uniformity.
// In a page objectasync waitForStatus() {
await expect(async () => {
expect(await this.page.locator(‘#status’).innerText()).toBe(‘Active’);
}).toPass();
}This approach reduces duplication and keeps retry logic consistent across tests.
5. Use it for Flaky or Async UI Elements
expect.toPass is especially useful for flaky elements or components that depend on asynchronous operations, like API responses or dynamic content. For example, elements that take time to load or appear after a certain event can benefit from retry logic.
await expect(async () => { const element = await page.locator(‘#welcome-message’);
expect(await element.isVisible()).toBe(true);
}).toPass();This ensures the test retries until the element becomes visible after dynamic updates.
6. Use Assertions Inside expect.toPass to Validate Actual UI Expectations
Instead of wrapping interactions or complex workflows inside the callback, focus on assertions that reflect expected UI behaviors. For instance, checking if a button is disabled, if a modal appears, or if a text message changes can all be good candidates for expect.toPass.
await expect(async () => { const buttonText = await page.locator(‘#submit-button’).innerText();
expect(buttonText).toBe(‘Submit’);
}).toPass();By using assertions, tests provide clear feedback on what went wrong when the condition is not met.
7. Avoid Using Too Many Retries
Too many retries can slow down your tests and obscure performance problems. expect.toPass should be reserved for conditions where the UI or app genuinely requires a retry, such as for async behaviors. For deterministic states, regular assertions are more efficient.
8. Monitor and Adjust Based on Test Results
After using expect.toPass, monitor its performance closely, especially in CI environments. If tests consistently take longer to pass or fail due to excessive retries, revisit the timeout settings or consider optimizing the underlying app logic.
A retry is useful, but excessive retries could indicate underlying performance issues in the app itself.
Common Mistakes to Avoid
While expect.toPass is a powerful tool for handling dynamic UI states in Playwright, it’s important to use it correctly to avoid common pitfalls. Below are some of the most frequent mistakes teams make when using expect.toPass, and tips on how to avoid them.
1. Wrapping Complex Logic Inside the Callback
One of the biggest mistakes when using expect.toPass is overloading the callback with too much logic. The callback should focus on a single, specific condition or state. Introducing multiple assertions or complex logic inside the callback can make debugging difficult and reduce the clarity of your tests.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Active’);
expect(await page.locator(‘#user’).isVisible()).toBe(true);
}).toPass();Solution:
Break down complex logic into smaller, more manageable assertions or helper functions.
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Active’);
}).toPass();
await expect(async () => {
expect(await page.locator(‘#user’).isVisible()).toBe(true);
}).toPass();
2. Using expect.toPass for Static or Immediate Conditions
expect.toPass is intended for conditions that change over time or require retries. It should not be used for static conditions that should pass immediately, such as checking whether a button is visible or if text is already set. For those cases, standard assertions like toBeVisible or toHaveText should be used.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#button’).isVisible()).toBe(true);
}).toPass();Solution:
Use standard assertions instead of retry-based ones when the condition is predictable and immediate.
await expect(page.locator(‘#button’)).toBeVisible();
Read More: Explaining Playwright Architecture
3. Using Too Long Timeouts
While expect.toPass allows you to set timeouts for retry logic, setting timeouts that are too long can unnecessarily slow down your tests. Timeouts should be set to reflect realistic expectations for how long the UI should take to stabilize based on the application’s behavior and typical network conditions.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Completed’);
}).toPass({ timeout: 30000 }); // 30 seconds timeout is excessive for most use casesSolution:
Adjust the timeout to a reasonable value, ensuring it reflects the actual time the UI or component should need to reach the desired state.
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Completed’);
}).toPass({ timeout: 5000 });4. Overusing expect.toPass for Every Assertion
expect.toPass should be used only for dynamic conditions where retries are necessary. Overusing it for static assertions or where the condition is immediately predictable adds unnecessary retries and impacts test performance.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#header’).innerText()).toBe(‘Welcome’);
}).toPass();Solution:
Use expect.toPass only when you expect conditions to change over time or when waiting for something that requires retries. For static checks, use immediate assertions instead.
await expect(page.locator(‘#header’)).toHaveText(‘Welcome’);
5. Forgetting to Adjust for Browser-Specific Timing Differences
When running tests across different browsers (like Chromium, Firefox, or WebKit), the timing of UI elements, especially animations and transitions, may vary. Failing to account for these differences can lead to unreliable tests, particularly when using expect.toPass. For example, animations or API calls may take longer in WebKit compared to Chromium, causing inconsistent results.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Completed’);
}).toPass();Solution:
Account for these differences by adjusting timeouts or using expect.toPass only when necessary. Consider using browser-specific settings or adjusting logic to reflect differences in execution speeds.
6. Ignoring Flaky Tests During Development
expect.toPass is great for reducing flakiness, but it shouldn’t be used as a “band-aid” for poorly written tests or failing code. Using it to mask test instability without addressing the root cause leads to misleading results. If a test is flaky, it’s important to investigate why it’s failing and resolve the underlying issue, rather than just retrying the condition.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#alert’).isVisible()).toBe(true);
}).toPass(); // Masking an unstable test instead of fixing the issueSolution:
Investigate and address the underlying issue causing instability before using expect.toPass to stabilize the test.
7. Not Validating Test Failures Appropriately
Because expect.toPass retries assertions, it can mask issues if failure conditions are not properly logged or captured. Always ensure failure reasons are clear and appropriately logged, as they can reveal genuine UI or backend issues rather than just automation timing problems.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#status’).innerText()).toBe(‘Completed’);
}).toPass();Solution:
Use logging, assertions, and proper error messages to ensure failures are clearly communicated and not hidden by the retry logic.
8. Not Using expect.toPass for Asynchronous Operations
expect.toPass is ideal for handling asynchronous operations like waiting for elements to load, API calls to complete, or dynamic data to update. Using it in situations where synchronization isn’t required defeats its purpose and causes unnecessary delays.
Incorrect Usage:
await expect(async () => { expect(await page.locator(‘#loadingSpinner’).isVisible()).toBe(false);
}).toPass(); // Should be used for async checks, not this simple caseSolution:
For simpler scenarios, use the standard expect assertions. Reserve expect.toPass for async operations or where time-sensitive conditions need retries.
Read More: How to install Playwright in 2025
Debugging expect.toPass Failures
When expect.toPass fails, it indicates that the system never reached the desired final state. Playwright’s trace viewer provides DOM timelines, network requests, and console logs that help identify:
- selectors that never stabilize
- rendering delays
- stalled network activity
- unexpected UI regressions
Tracing is especially useful in dynamic flows where small timing mismatches cause major differences in behavior.
Debugging failures in expect.toPass is easier with BrowserStack Automate, which provides real-time logs, videos, and network traces across real devices and browsers. This helps pinpoint exactly where retries are failing and ensures more stable, reliable tests.
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.
Why Use BrowserStack Automate with expect.toPass?
Retry-based logic becomes significantly more accurate when tested under genuine device and browser conditions. Different CPUs, memory constraints, network speeds, and rendering engines influence when UI states stabilize.
BrowserStack Automate is a cloud-based testing solution that helps test retry-heavy assertions under real-world circumstances, not idealized local setups.
Key Features of BrowserStack Automate:
| Feature | What It Is | Why It Matters for expect.toPass |
| Real Devices & Browsers | Live Android, iOS, Windows, macOS environments | Shows how dynamic UI states behave on actual hardware |
| Parallel Execution | Run suites concurrently across platforms | Validates retry behavior at scale on demand |
| Debugging Artifacts | Video, logs, network traces | Helps diagnose why callbacks fail during retries |
| CI/CD Integrations | Works with Jenkins, GitHub Actions, GitLab | Ensures consistent retry behavior across pipelines |
| Latest & Legacy Browsers | Full engine ecosystem | Confirms UI transitions behave consistently across versions |
Conclusion
expect.toPass is a powerful addition to Playwright’s assertion toolkit, offering a flexible way to validate UI states that evolve over time.
It reduces flakiness, supports dynamic interfaces, and ensures tests reflect true user experience rather than momentary transitions. When paired with BrowserStack Automate’s real-device coverage, this approach creates stable, robust automation workflows that remain reliable across browsers, environments, and performance profiles.




