Web-first assertions are Playwright expect() checks on a Locator or Page that automatically retry until the condition is met or a timeout expires. They wait for the UI to reach the expected state instead of asserting once against a snapshot.
A classic Selenium bug is asserting immediately after an async action: element.getText() runs before the DOM updates, so the assertion fails intermittently. Playwright's expect(locator).toHaveText('Saved') polls the DOM until the text matches or the assertion timeout (default 5s) elapses. This applies to a whole family: toBeVisible, toBeEnabled, toHaveText, toHaveValue, toHaveCount, toContainText, toHaveURL, toHaveScreenshot and more. Because they retry, you rarely need explicit waits before assertions. It's important to distinguish auto-retrying assertions on locators (which poll) from non-retrying assertions on plain values (expect(2 + 2).toBe(4)), which evaluate once.
After clicking 'Save', a toast appears ~800ms later once the API responds. expect(page.getByRole('status')).toHaveText('Changes saved') polls until the toast text appears, passing as soon as it does — no sleep, no manual wait, and no intermittent failure from asserting before the toast renders.
// Auto-retrying — waits for the state to arrive
await expect(page.getByRole('status')).toHaveText('Changes saved');
await expect(page.getByRole('row')).toHaveCount(5);
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
// Override the retry timeout for one slow assertion
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 15_000 });
// NON-retrying — evaluates once (plain value)
expect(user.role).toBe('admin');Scenario: A test does await page.locator('.toast').textContent() then expect(text).toBe('Saved'). It passes locally but fails ~15% of the time on CI. Diagnose and fix it using web-first assertions.