Almost every flaky end-to-end test traces back to the same root cause: the test checked for something before the app was ready to show it. The classic patch is a fixed sleep, page.waitForTimeout(5000), dropped in right before the assertion. It works on your machine and fails in CI, or works this week and fails after a deploy that makes one request 200ms slower.
Playwright's web-first assertions are the fix. They turn "wait, then check" into a single polling operation that retries until the condition is true or a timeout expires. Understanding exactly what retries — and what silently does not — is the difference between a suite that's stable for years and one you babysit.
What a fixed wait actually costs
A hard-coded sleep is wrong in both directions at once. It's too short when the app is slow, so the test fails intermittently. It's too long on the common path, so every run pays the full delay whether it needs it or not. Multiply a few waitForTimeout(5000) calls across a few hundred tests and you've added minutes of dead time to every CI run, while still not being reliable.
// Before: guess a duration, hope it's enough
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(5000);
const banner = await page.locator('.toast').textContent();
expect(banner).toBe('Saved');
There is no duration that is both fast and safe here. That's the trap.
What web-first assertions do instead
When you pass a Locator to expect() and call a web-first matcher, Playwright doesn't evaluate the condition once. It polls: it re-queries the DOM, re-checks the condition, and keeps going until the assertion passes or the assertion timeout (5 seconds by default) is reached. The moment the toast appears with the right text, the assertion resolves — usually in a few hundred milliseconds, not five seconds.
// After: no duration to guess
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('.toast')).toHaveText('Saved');
That single line waits for the element to exist, become visible enough to have text, and hold the expected value. If the app is fast, it returns fast. If the app is slow, it waits — up to the timeout — instead of failing at a fixed boundary.
The retrying matchers cover the conditions you actually assert on. Most take a Locator; toHaveURL() and toHaveTitle() take the page itself, but they retry the same way:
| Matcher | Passes when |
|---|---|
toBeVisible() / toBeHidden() | Element is (not) visible |
toHaveText() / toContainText() | Text equals / contains |
toHaveValue() | Input holds the value |
toHaveCount() | Locator resolves to N elements |
toBeEnabled() / toBeDisabled() | Control is (not) interactive |
toBeChecked() | Checkbox / radio state |
toHaveURL() / toHaveTitle() | Page navigated as expected |
toHaveAttribute() | Attribute matches |
Each one re-samples until it's satisfied. You almost never need an explicit wait before them.
The line that quietly breaks retrying
Here's the subtlety that catches people who think they've adopted web-first assertions. The retry behavior lives in the matcher, and it only kicks in when the value being checked is a Locator. The moment you resolve the locator to a plain value yourself, you take a single snapshot and hand expect() something static — retrying is gone.
// Looks modern, still flaky — textContent() runs ONCE
const text = await page.locator('.status').textContent();
expect(text).toBe('Done');
// Correct — expect() holds the locator and polls
await expect(page.locator('.status')).toHaveText('Done');
The first version reads the DOM exactly once, at whatever instant that line runs. If the status hasn't flipped to Done yet, expect('Loading').toBe('Done') fails immediately with no retry. It'll pass most of the time and fail exactly when the app is a little slow — which is the definition of flaky.
The same trap hides inside the boolean query methods:
// isVisible() returns a boolean immediately — no waiting
expect(await page.locator('.modal').isVisible()).toBe(true);
// toBeVisible() polls until the modal shows up (or times out)
await expect(page.locator('.modal')).toBeVisible();
isVisible(), isEnabled(), isChecked(), textContent(), and friends are point-in-time reads. They're useful when you genuinely want the current state without waiting — but they are not assertions, and wrapping them in expect() gives you a non-retrying check wearing the costume of a retrying one.
Tuning the timeout instead of sleeping
When something legitimately takes longer than five seconds — a report that renders after a heavy query, say — don't reach back for a sleep. Raise the timeout on that one assertion:
await expect(page.getByTestId('report')).toBeVisible({ timeout: 30_000 });
Or set a project-wide default in your config, so slow assertions get more room without per-line noise:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: { timeout: 10_000 },
use: { actionTimeout: 15_000 },
});
The key difference from a sleep: a longer timeout is a ceiling, not a fixed cost. A 30-second timeout that resolves in 800ms costs you 800ms. A waitForTimeout(30000) costs you thirty seconds, every time.
A few more misuse patterns
toBeTruthy()on a locator.expect(locator).toBeTruthy()always passes — aLocatorobject is truthy whether or not the element exists. UsetoBeVisible()ortoBeAttached().- Looping over
.all()to assert. Fetching every element and asserting in a JS loop bypasses retrying and races the DOM. PrefertoHaveCount()and per-index locators, ortoContainText()against the container. - Chaining a real wait before a web-first matcher.
await page.waitForTimeout(1000); await expect(...)just adds a fixed second on top of an assertion that was already going to wait correctly. Delete the sleep. - Asserting on a detached snapshot after navigation. Re-query with a fresh locator after the page changes; don't reuse a value you read before the transition.
Where this fits
TestVibe generates standard Playwright tests from a plain-language description of the behavior you want, and one thing a good generated test is judged on is whether it actually verifies the expected result rather than just clicking around. Because the generated code runs in a real cloud browser before you rely on it, the assertions have to hold against genuine timing — exactly the environment where web-first matchers earn their keep and fixed sleeps fall apart. The same discipline that keeps a hand-written suite stable is what makes generated tests trustworthy.
Adopt one rule and most timing flake disappears: pass a Locator to expect(), use a web-first matcher, and never resolve the value yourself first. Reach for a longer timeout when you need patience, never a sleep. Get early access to generate Playwright tests that follow these patterns by default, or read more in the Playwright generation docs.