Playwright anti-patterns: 5 flaky code shapes we reject
Most flaky Playwright tests wait on time instead of on the app. These are the code shapes TestWatch rejects in every generated test, with the exact message the check returns and the rewrite it accepts.
By Vasyl Harastei. . 6-minute read.
A flaky Playwright test is usually not Playwright’s fault. It is a test that waits on time instead of on the app, and it does that in one of a few recognisable code shapes. A person writes them under deadline; an AI model writes them just as readily.
TestWatch writes Playwright tests with an AI agent, so it checks every generated test for these 5 shapes before the test is accepted. A test that contains one goes back to the agent with a message that names the line and the fix. Below is each shape: the code the check rejects, its message word for word, and the rewrite it accepts. Every snippet on this page goes through that same check each time we test the site, so the messages are the ones it really returns.
Why these shapes break
Playwright’s web-first assertions, like await expect(locator).toHaveText('Saved'), retry until the page matches or the assertion times out. That retry is what makes a test independent of how fast the machine is. Each shape below gives it up: it sleeps for a fixed time, waits for the wrong signal, reads the page once, never waits at all, or makes a decision from a single read. That is also why such a test passes on your laptop and fails on a slower CI runner, the subject of why Playwright tests pass locally but fail in CI.
Fixed sleeps: waitForTimeout
Two seconds is a guess. On a fast machine the test wastes most of it; on a busy runner the save takes three and the next line fails. Delete the sleep and wait for the result the next step depends on.
Rejected
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(2000);
await expect(page.getByRole('status')).toHaveText('Saved');
Accepted
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');
Waiting for networkidle
networkidle means no network connections for at least 500 ms. A page that polls for updates, sends analytics or holds a websocket never reaches it, and a client-rendered page can reach it before the list is on screen. Playwright’s documentation marks this option as discouraged and says to rely on assertions instead.
Rejected
await page.goto('/orders');
await page.waitForLoadState('networkidle');
await page.getByRole('link', { name: 'Order 1042' }).click();
Accepted
await page.goto('/orders');
await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible();
await page.getByRole('link', { name: 'Order 1042' }).click();
Asserting a snapshot: expect(await locator.textContent())
This reads the text once, at whatever moment the line runs, and compares the copy. If the cart counter updates a moment later, the test fails. The retrying matcher reads the element again until it matches.
Rejected
await page.getByRole('button', { name: 'Add to cart' }).click();
expect(await page.getByTestId('cart-count').textContent()).toBe('1');
Accepted
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
A web-first assertion without await
Web-first assertions are asynchronous, and Playwright’s docs say they must be awaited. Without await, the test moves on, or finishes, before the check has run. It can pass with the success message nowhere on the page.
Rejected
await page.getByRole('button', { name: 'Place order' }).click();
expect(page.getByText('Thanks! Your order has been placed.')).toBeVisible();
Accepted
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Thanks! Your order has been placed.')).toBeVisible();
Branching on isVisible()
isVisible() answers right now, without waiting. If the welcome dialog appears 200 ms after sign-in, the if has already decided there is none and skips the tour. The dialog then sits over the dashboard: a later click it covers waits and times out, while a check that the heading is visible still passes. Whether the test fails depends on timing, not on the app.
A test should know what the app will show. If this account gets the welcome tour, assert it and skip it on every run; if it never does, leave those lines out. When it varies between runs, that is test data to fix, for example a fresh account per run, not a branch to add.
Rejected
await page.getByRole('button', { name: 'Sign in' }).click();
if (await page.getByRole('dialog', { name: 'Welcome' }).isVisible()) {
await page.getByRole('button', { name: 'Skip tour' }).click();
}
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Accepted
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('dialog', { name: 'Welcome' })).toBeVisible();
await page.getByRole('button', { name: 'Skip tour' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Popups that aren’t part of your app are a different case. For a third-party cookie banner or chat widget, Playwright’s page.addLocatorHandler (version 1.42 and later) clicks it away whenever it gets in the way. In TestWatch the test fixture already blocks or dismisses those, so a generated test never contains dismissal code, and our check rejects every branch on a one-shot state read, even one right after waiting for either outcome.
What the check allows
A rule that flags too much gets ignored, so the check leaves these alone:
Retrying a computed value. expect.poll re-runs the function until the value matches, so reading a count inside it is fine:
await expect.poll(async () => page.getByRole('row').count()).toBeGreaterThan(2);
A longer timeout on one step. A report that takes a minute to build may need more than the default budget. An explicit timeout on that assertion is fine; a fixed sleep before it isn’t:
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 60_000 });
A count as a loop bound. Reading how many rows there are and visiting each is iteration, not a decision:
const n = await page.getByRole('row').count();
for (let i = 0; i < n; i++) {
await expect(page.getByRole('row').nth(i)).toBeVisible();
}
Does it make a difference?
In our own eval, a test counts as brittle when it was accepted and catches the planted bugs but fails on the unchanged app, breaks on a harmless change, or passes only sometimes. Counted with today’s rules, our earliest runs (September 2026) had 7% brittle tests over 58 trials; the run on 18 October 2026 had 0% over 87. This check is one of several changes made in between, so that shows a direction, not what caused it. The cases and every trial are on How we measure TestWatch.
What this doesn’t catch
The check reads code; it doesn’t run it. It won’t notice a locator that matches the wrong element, a test that depends on data another test left behind, or an app that is genuinely slow. And it can’t tell you whether the assertion checks the right thing, which is the bigger question for a generated test: our AI Playwright test generator page covers the other checks a test has to pass before we accept it.
Questions
Is waitForTimeout ever fine in a Playwright test?
Not as a way to wait for the app. A fixed sleep is too short on a slow machine and wasted time on a fast one. Wait for what the next step needs instead, with a web-first assertion such as toBeVisible or toHaveText.
Why does waitForLoadState networkidle make tests flaky?
It waits until the page has had no network connections for a moment. Pages that poll, send analytics or keep a websocket open never get there, and client-rendered pages can get there before the content is on screen. Playwright’s own docs mark it as discouraged for testing.
How do I handle a popup that only sometimes appears?
If it isn’t part of your app, like a cookie banner or a chat widget, register page.addLocatorHandler for it and Playwright clicks it away whenever it blocks a step. If it is your app’s own dialog, make the test data decide whether it appears, then assert it and handle it every run.
Try it on your own app
Describe a user flow in plain English and TestWatch writes a Playwright test you can read and export. Passing runs make no AI calls.
Start freeHow the test generator works
Free: 5 tests, 500 runs a month, 2 apps and one daily schedule. Unlimited teammates, no card.