The failure looked random until we grouped it by symptom. A Playwright suite that usually passed started failing on the same checkout page, but with different errors each run:
textError: locator.click: Timeout 30000ms exceeded. waiting for getByRole('button', { name: 'Pay now' }) element is not stable
textError: expect(locator).toHaveText(expected) failed Expected string: "Paid" Received string: "Pay now"
textError: locator.click: Element is not attached to the DOM
textError: page.waitForLoadState: Timeout 30000ms exceeded. waiting for load state "networkidle"
The common reaction is to blame Playwright, add waitForTimeout(1000), or turn every action into a custom retry loop. That usually makes the suite slower and less predictable. The more useful model is this: auto-waiting solves a narrow class of timing problems, not all of them.
Playwright waits before actions for things like visibility, enabled state and event receivability. It also retries web assertions for a while. What it does not do is infer the semantic moment your app is actually ready. If the DOM is visible but still hydrating, if the node you found gets replaced during render, if the spinner never lets the network go idle, or if you cached an element handle from an earlier tree, auto-waiting will patiently wait for the wrong thing.
This is the practical shape of a playwright flaky tests fix in production: stop treating flakiness as one class of problem. Split it into specific races, then rewrite locators and assertions so they match the state transition you care about.
What auto-waiting actually guarantees, and what it does not
Before the five races, it is worth being precise about the mechanism people overestimate.
For actions like click(), Playwright performs actionability checks. It waits for the target to be:
- attached
- visible
- stable
- enabled
- able to receive events
For assertions like expect(locator).toHaveText(), Playwright retries until the assertion passes or times out.
That helps with simple rendering delays. It does not help when:
- the app swaps one matching element for another
- the UI becomes visible before event handlers are wired
- animation changes hit targets during the click
- background requests keep the page busy forever
- your test stores references to old DOM nodes
If you understand those boundaries, most flaky tests stop looking mysterious.
Race 1: visible is not clickable yet because animation is still moving it
The first class of failure shows up as “element is not stable”, intercepted clicks, or clicks landing on the wrong thing during transitions.
A common example is a drawer or modal whose submit button appears immediately, but its container is still animating. Playwright sees a visible button. Your app still has a moving overlay.
Consider this test:
tsimport { test, expect } from '@playwright/test'; test('submits profile form', async ({ page }) => { await page.goto('http://localhost:3000/settings'); await page.getByRole('button', { name: 'Edit profile' }).click(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Profile updated')).toBeVisible(); });
This is innocent, but if the modal fades and slides in, the Save button may match before its final position is reached.
Why the naive fix fails
Teams often do one of these:
tsawait page.waitForTimeout(500); await page.getByRole('button', { name: 'Save' }).click();
or:
tsawait page.getByRole('button', { name: 'Save' }).click({ force: true });
The timeout bakes in a guess about animation duration. It breaks on slower CI or after design tweaks. force: true disables checks that were warning you about a real race.
Rewrite the test around the settled UI state
Wait for the animated container to reach the state your user actually needs, then act inside it.
If your app exposes a reliable attribute when the transition ends, use it:
tsimport { test, expect } from '@playwright/test'; test('submits profile form', async ({ page }) => { await page.goto('http://localhost:3000/settings'); await page.getByRole('button', { name: 'Edit profile' }).click(); const dialog = page.getByRole('dialog', { name: 'Edit profile' }); await expect(dialog).toHaveAttribute('data-state', 'open'); await dialog.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Profile updated')).toBeVisible(); });
If you do not have such an attribute, add one. Testability here is an app contract, not a Playwright trick.
If changing the app is not possible, target the final surface rather than the moving child. For example, assert the dialog is visible and that a blocking overlay is gone:
tsconst dialog = page.getByRole('dialog', { name: 'Edit profile' }); await expect(dialog).toBeVisible(); await expect(page.locator('.modal-backdrop.entering')).toHaveCount(0); await dialog.getByRole('button', { name: 'Save' }).click();
Production note
Animations in CI are often slower because CPU is constrained. If your design system supports reduced motion, set it in tests so transitions finish deterministically:
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ use: { colorScheme: 'light', reducedMotion: 'reduce', }, });
That is better than arbitrary sleeps because it aligns with a supported browser feature and often disables the race at the source.
Race 2: hydration makes the control look ready before handlers exist
This one is common on React, Next.js, Vue and similar apps. Server-rendered HTML appears quickly. Buttons and inputs are visible. The client bundle has not finished hydrating, so clicks are dropped or state gets reset on the next render.
A representative test:
tstest('adds item to cart', async ({ page }) => { await page.goto('http://localhost:3000/products/sku-123'); await page.getByRole('button', { name: 'Add to cart' }).click(); await expect(page.getByRole('status')).toHaveText('Added'); });
Intermittent failure:
textError: expect(locator).toHaveText(expected) failed Expected string: "Added" Received string: ""
The click happened on a visible button, but no client handler processed it.
Why the naive fix fails
The usual workaround is waitForLoadState('networkidle') after goto().
tsawait page.goto('http://localhost:3000/products/sku-123'); await page.waitForLoadState('networkidle'); await page.getByRole('button', { name: 'Add to cart' }).click();
This is fragile for two reasons:
- hydration readiness is not the same thing as network silence
- many apps keep analytics, polling or prefetch requests alive, so
networkidleis either delayed or never reached
Rewrite the test around a hydration signal
The best fix is to expose a small app-level ready marker when interactive wiring is complete.
Example app code:
html<body data-test-hydrated="false"> <div id="root"></div> <script type="module" src="/src/main.ts"></script> </body>
ts// src/main.ts import { createRoot } from 'react-dom/client'; import { App } from './App'; createRoot(document.getElementById('root')!).render(<App />); queueMicrotask(() => { document.body.setAttribute('data-test-hydrated', 'true'); });
Then the test becomes:
tstest('adds item to cart', async ({ page }) => { await page.goto('http://localhost:3000/products/sku-123'); await expect(page.locator('body')).toHaveAttribute('data-test-hydrated', 'true'); await page.getByRole('button', { name: 'Add to cart' }).click(); await expect(page.getByRole('status')).toHaveText('Added'); });
If you cannot change the app, wait for a post-hydration effect that matters to the interaction, not for global idle. For example, a disabled button becoming enabled:
tsconst addToCart = page.getByRole('button', { name: 'Add to cart' }); await expect(addToCart).toBeEnabled(); await addToCart.click();
This only works if the disabled state actually tracks hydration. If the button is enabled from the server HTML, you still need a stronger signal.
A useful anti-pattern to remove
Do not place interaction immediately after navigation if the page is known to hydrate asynchronously:
tsawait page.goto(url); await page.getByRole('button', { name: 'Continue' }).click();
Make every first interaction on such pages wait for one explicit readiness contract.
Race 3: the node you found gets detached during render
This failure usually appears under list filtering, virtualized tables, or React key churn:
textError: locator.click: Element is not attached to the DOM
Or the test clicks an element that matched earlier, but the framework re-renders the subtree between hover, scroll and click.
Here is a typical flaky pattern:
tstest('removes a line item', async ({ page }) => { await page.goto('http://localhost:3000/cart'); const row = page.locator('[data-testid="cart-row"]').filter({ hasText: 'SKU-123', }); await row.getByRole('button', { name: 'Remove' }).click(); await expect(row).toHaveCount(0); });
Looks fine, but if the cart rerenders after totals recompute, the original row subtree may be detached and replaced. The nested click races with the replacement.
Why the naive fix fails
A lot of code reaches for nth() or CSS paths to make the locator more specific:
tsawait page.locator('[data-testid="cart-row"] >> nth=0 >> text=Remove').click();
Specificity does not solve detachment. It makes the selector more brittle while the same race remains.
Another common mistake is converting the locator to an element handle too early:
tsconst handle = await row.elementHandle(); await handle?.click();
That freezes the reference to one concrete node, exactly the thing the framework is about to replace.
Rewrite using locators that resolve late
Locators are lazy. Keep them as locators until the action, and re-anchor the postcondition to a stable parent.
tstest('removes a line item', async ({ page }) => { await page.goto('http://localhost:3000/cart'); const cart = page.getByTestId('cart'); const row = cart.getByTestId('cart-row').filter({ hasText: 'SKU-123' }); await row.getByRole('button', { name: 'Remove' }).click(); await expect(cart.getByTestId('cart-row').filter({ hasText: 'SKU-123' })).toHaveCount(0); });
This still uses the row locator for the click, but the expectation re-queries through the stable cart container instead of asserting on the old row instance.
If the remove action triggers a known request, pair the click with the domain event rather than the transient DOM state:
tsawait Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/cart/items/SKU-123') && resp.request().method() === 'DELETE' ), row.getByRole('button', { name: 'Remove' }).click(), ]); await expect(cart.getByTestId('cart-row').filter({ hasText: 'SKU-123' })).toHaveCount(0);
The point is not to wait more. It is to wait on the state transition that actually defines success.
Production note
If you still see occasional detach errors, inspect trace snapshots around the failing action. In many cases the problem is a component library replacing whole subtrees for tiny state changes. Tests can work around this, but repeated occurrences are usually a sign to simplify rerender behavior in the app.
Race 4: networkidle is the wrong readiness gate
This one deserves its own section because it causes both direct failures and cargo-cult waiting everywhere else.
Test code:
tstest('opens report details', async ({ page }) => { await page.goto('http://localhost:3000/reports'); await page.waitForLoadState('networkidle'); await page.getByRole('link', { name: 'Quarterly revenue' }).click(); await expect(page.getByRole('heading', { name: 'Quarterly revenue' })).toBeVisible(); });
On a modern app, this often fails with:
textError: page.waitForLoadState: Timeout 30000ms exceeded. waiting for load state "networkidle"
Why? Because the page might be doing any of the following:
- analytics beacons
- websocket reconnects
- polling for notifications
- image lazy loading
- route prefetches
- feature flag fetches
None of these tells you whether the report list is usable.
Why the naive fix fails
The naive fix is to increase the timeout:
tsawait page.waitForLoadState('networkidle', { timeout: 60000 });
That just waits longer for a condition your app may never satisfy.
Another bad pattern is putting networkidle after every click:
tsawait page.getByRole('link', { name: 'Quarterly revenue' }).click(); await page.waitForLoadState('networkidle');
That slows the suite and still does not guarantee the UI state you need.
Rewrite the wait around the specific UI or API event
If the page renders a table when ready, assert on that:
tstest('opens report details', async ({ page }) => { await page.goto('http://localhost:3000/reports'); const reportsTable = page.getByRole('table', { name: 'Reports' }); await expect(reportsTable).toBeVisible(); await expect(reportsTable.getByRole('row')).toHaveCount(6); await page.getByRole('link', { name: 'Quarterly revenue' }).click(); await expect(page.getByRole('heading', { name: 'Quarterly revenue' })).toBeVisible(); });
If there is a definitive backend request that drives the screen, wait for that specific response in parallel with the navigation or action:
tstest('opens report details', async ({ page }) => { await page.goto('http://localhost:3000/reports'); await Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/reports') && resp.ok() ), page.reload(), ]); await page.getByRole('link', { name: 'Quarterly revenue' }).click(); await expect(page.getByRole('heading', { name: 'Quarterly revenue' })).toBeVisible(); });
For SPA transitions, prefer URL and content assertions:
tsawait page.getByRole('link', { name: 'Quarterly revenue' }).click(); await expect(page).toHaveURL(/\/reports\/quarterly-revenue$/); await expect(page.getByRole('heading', { name: 'Quarterly revenue' })).toBeVisible();
Rule of thumb
Use networkidle only when your application explicitly models readiness that way, which is rare. Most of the time, readiness should be tied to one of these:
- a semantic element is visible
- a control is enabled
- a specific request finished
- the URL changed
- a test-only app readiness attribute is set
Race 5: stale locators are usually stale handles or stale assumptions
Strictly speaking, Playwright locators are designed to avoid Selenium-style stale element references because they resolve fresh for each action. Yet teams still create a stale-locator problem in practice by doing one of three things:
- storing
ElementHandles and using them later - deriving counts or text once, then acting on changed UI
- creating broad locators whose first match changes as the page updates
A flaky example:
tstest('edits the second address', async ({ page }) => { await page.goto('http://localhost:3000/account/addresses'); const editButtons = page.getByRole('button', { name: 'Edit' }); await editButtons.nth(1).click(); await page.getByLabel('City').fill('Leeds'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saved')).toBeVisible(); });
This fails when the list is resorted, a default address banner appears, or one card is added asynchronously. nth(1) is not stale as an API object, but it encodes a stale assumption about ordering.
Why the naive fix fails
People often double down on indexing:
tsawait page.locator('.address-card .edit').nth(1).click();
That is more coupled to DOM shape and still depends on unstable ordering.
Or they read text first and click later:
tsconst cards = await page.locator('.address-card').allTextContents(); const index = cards.findIndex(t => t.includes('Work')); await page.getByRole('button', { name: 'Edit' }).nth(index).click();
Between reading and clicking, the list can change.
Rewrite to bind action to stable identity
Find the card by content or test id, then click within that card.
tstest('edits the Work address', async ({ page }) => { await page.goto('http://localhost:3000/account/addresses'); const card = page.getByTestId('address-card').filter({ hasText: 'Work' }); await expect(card).toHaveCount(1); await card.getByRole('button', { name: 'Edit' }).click(); await page.getByLabel('City').fill('Leeds'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByTestId('address-card').filter({ hasText: 'Work' })).toContainText('Leeds'); });
If your app has stable IDs, use them directly:
html<div data-testid="address-card" data-address-id="addr_work"> <h2>Work</h2> <button>Edit</button> </div>
tsconst card = page.locator('[data-address-id="addr_work"]'); await card.getByRole('button', { name: 'Edit' }).click();
The important distinction
A locator is rarely the stale thing. The stale thing is your selection strategy. If the strategy depends on position, transient text, or a previously materialized node, retries will just re-run a brittle assumption.
A compact rewrite playbook for flaky tests
When you need a quick playwright flaky tests fix, classify the symptom first. Here is the practical mapping.
If the error mentions stability or intercepted clicks
Original:
tsawait page.getByRole('button', { name: 'Save' }).click();
Rewrite:
tsconst dialog = page.getByRole('dialog', { name: 'Edit profile' }); await expect(dialog).toHaveAttribute('data-state', 'open'); await dialog.getByRole('button', { name: 'Save' }).click();
If the click seems to do nothing after SSR navigation
Original:
tsawait page.goto(url); await page.getByRole('button', { name: 'Add to cart' }).click();
Rewrite:
tsawait page.goto(url); await expect(page.locator('body')).toHaveAttribute('data-test-hydrated', 'true'); await page.getByRole('button', { name: 'Add to cart' }).click();
If you get detached node errors
Original:
tsconst row = page.locator('[data-testid="cart-row"]').filter({ hasText: 'SKU-123' }); await row.getByRole('button', { name: 'Remove' }).click(); await expect(row).toHaveCount(0);
Rewrite:
tsconst cart = page.getByTestId('cart'); const row = cart.getByTestId('cart-row').filter({ hasText: 'SKU-123' }); await row.getByRole('button', { name: 'Remove' }).click(); await expect(cart.getByTestId('cart-row').filter({ hasText: 'SKU-123' })).toHaveCount(0);
If networkidle times out or adds random latency
Original:
tsawait page.waitForLoadState('networkidle');
Rewrite:
tsawait expect(page.getByRole('table', { name: 'Reports' })).toBeVisible();
Or:
tsawait Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/reports') && resp.ok()), page.reload(), ]);
If your locator uses nth() on dynamic UI
Original:
tsawait page.getByRole('button', { name: 'Edit' }).nth(1).click();
Rewrite:
tsconst card = page.getByTestId('address-card').filter({ hasText: 'Work' }); await card.getByRole('button', { name: 'Edit' }).click();
Instrument the suite so the race is obvious next time
You do not need a huge observability stack to debug flaky browser tests. A few defaults go a long way.
Use Playwright trace collection on retries:
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ retries: 2, use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, });
When a test fails, inspect:
- whether the target moved between hover and click
- whether the button was visible before hydration completed
- whether the subtree got replaced before the action
- whether your wait was tied to
networkidlewhile requests kept flowing - whether
nth()resolved to a different item on the retry
For CI, keep worker count realistic. Too much parallelism on a small runner introduces extra timing variance that hides the real issue. That does not cause these races by itself, but it makes them easier to hit.
A minimal GitHub Actions job for reproducible runs:
yamlname: playwright on: push: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - run: npm test env: CI: true
And a practical Playwright config:
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, use: { baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', reducedMotion: 'reduce', }, expect: { timeout: 5000, }, });
This is not a substitute for fixing the race. It just ensures the evidence survives long enough for you to see it.
A test review checklist that catches most of these before merge
When reviewing Playwright code, I use a short checklist:
- Does the first interaction after
goto()rely on hydration being complete? - Does the test wait on a semantic UI condition, or on elapsed time?
- Is
networkidlebeing used as a generic readiness proxy? - Does any selector depend on
nth()for a dynamic list? - Are there any
ElementHandles stored across rerenders? - Does the postcondition assert on a stable parent, rather than a possibly detached child?
- Can the app expose a testable readiness attribute instead of making the test infer it?
Most flaky tests become obvious under this lens.
The production lesson
Playwright auto-waiting is useful, but it is not mind reading. It knows when an element is actionable by browser mechanics. It does not know when your application is ready by business semantics.
That is why the same suite can fail with five different symptoms while the underlying issue is always the same category mismatch: the test waits for visible, attached, or quiet, when the real requirement is settled, hydrated, current, specific, or identified.
If you need a durable playwright flaky tests fix, do not add bigger timeouts. Rewrite each flaky step so the locator names the correct thing and the assertion waits for the exact state change:
- animation: wait for the settled container state
- hydration: wait for an app readiness signal
- detached nodes: re-query through stable parents and assert on the new tree
- network idle: wait for semantic UI or request completion, not global silence
- stale locators: remove positional assumptions and bind actions to stable identity
That is the difference between a suite that passes after enough retries and one that tells you something useful when it breaks.
