Why Your Playwright Tests Are Flaky — The Async Trap Every SDET Falls Into
Table of Contents
The first time we ran our migrated Playwright suite — 340 tests, freshly ported from Selenium — 23% of them failed intermittently. The locators were correct. The test logic was sound. Every failing test passed when run individually. We spent two days blaming the CI environment before we realized the problem: we were writing Playwright tests with a Selenium mindset, and JavaScript’s async model was punishing us for it.
The Mindset Shift: Selenium Waits for You, Playwright Doesn’t (Exactly)
In Selenium, driver.findElement() either finds the element or throws. There’s no ambiguity about when the operation completes. If you add an implicit wait, Selenium polls the DOM on a timer and blocks your thread until the element appears or the timeout expires. It’s synchronous, it’s predictable, and it trained an entire generation of SDETs to think sequentially.
Playwright’s auto-waiting is smarter — it retries assertions and actions until they succeed or time out. But it only works correctly if you’re actually await-ing the operations. Skip an await, and you’ve got a fire-and-forget operation that might finish before or after the next line runs. That “might” is where flaky tests live.
// This LOOKS correct but has a subtle timing issuetest('applies discount code', async ({ page }) => { await page.goto('/checkout'); await page.getByLabel('Discount code').fill('SAVE20'); page.getByRole('button', { name: 'Apply' }).click(); // Missing await! await expect(page.getByText('20% discount applied')).toBeVisible();});That missing await on line 5 means the click fires but the test doesn’t wait for it to complete before checking for the discount text. On a fast machine, the click resolves before the assertion. On a loaded CI runner, it doesn’t. That’s your intermittent failure.
The 3 Async Mistakes I See on Every Migration
After helping teams port Selenium suites to Playwright, I’ve seen the same three mistakes on every single project. They account for the vast majority of “flaky” tests that aren’t actually flaky — they’re just incorrectly async.
Mistake 1: Missing await on Actions
// BAD — click fires but test doesn't wait for ittest('navigates to dashboard', async ({ page }) => { await page.goto('/login'); await page.getByTestId('email').fill('user@example.com'); await page.getByTestId('password').fill('password123'); page.getByRole('button', { name: 'Sign In' }).click(); // fire-and-forget await expect(page).toHaveURL(/dashboard/); // sometimes passes, sometimes not});// GOOD — every action is awaitedtest('navigates to dashboard', async ({ page }) => { await page.goto('/login'); await page.getByTestId('email').fill('user@example.com'); await page.getByTestId('password').fill('password123'); await page.getByRole('button', { name: 'Sign In' }).click(); await expect(page).toHaveURL(/dashboard/);});How to catch it: Add the @typescript-eslint/no-floating-promises ESLint rule. It flags every unhandled Promise. We added this rule and found 31 missing awaits in a 340-test suite. That single lint rule fixed most of our flake.
Mistake 2: Asserting Before the Page Is Ready
In Selenium, we’d often add an explicit wait after navigation: WebDriverWait(driver, 10).until(...). SDETs migrating to Playwright sometimes skip this because they’ve heard “Playwright auto-waits.” It does — but only for actions and web-first assertions, not for arbitrary page state.
// BAD — checking element count before page finishes loadingtest('shows all dashboard widgets', async ({ page }) => { await page.goto('/dashboard'); const widgets = await page.locator('.widget').count(); expect(widgets).toBe(6); // fails if widgets load asynchronously});// GOOD — use web-first assertion that retriestest('shows all dashboard widgets', async ({ page }) => { await page.goto('/dashboard'); await expect(page.locator('.widget')).toHaveCount(6);});The difference is critical. locator.count() returns the count at that instant — it doesn’t retry. expect(locator).toHaveCount(6) retries until the count matches or the timeout expires. That’s Playwright’s auto-waiting in action, but only if you use the right assertion style.
Mistake 3: Race Conditions in Setup and Teardown
This one bit us hardest. We had beforeEach hooks that set up test data via API calls and afterEach hooks that cleaned up. In Selenium, these ran synchronously on the test thread. In Playwright, if you don’t await every async operation in your hooks, setup might not finish before the test starts.
// BAD — API setup might not complete before test runstest.beforeEach(async ({ request }) => { request.post('/api/test-data', { data: { user: 'testuser' } }); // no await!});// GOOD — await ensures data exists before test startstest.beforeEach(async ({ request }) => { const response = await request.post('/api/test-data', { data: { user: 'testuser' } }); expect(response.ok()).toBeTruthy();});We found 8 un-awaited API calls in our setup hooks. These caused the most confusing failures because the test data sometimes existed (if the API call happened to resolve quickly) and sometimes didn’t.
How We Fixed Our 23% Flake Rate
Here’s the exact playbook we followed to take our suite from 23% intermittent failures to under 1%:
- Added
no-floating-promisesESLint rule — found and fixed 31 missingawaits. This alone dropped flake rate to about 8%. - Replaced all raw
.count()and.textContent()calls with web-first assertions — swappedexpect(await el.count()).toBe(n)withawait expect(el).toHaveCount(n). Dropped to about 3%. - Audited every
beforeEachandafterEachhook — found 8 un-awaited API calls and 2 un-awaited navigation calls. Fixed these and dropped to under 1%. - Added a CI stability check — we run the full suite 5 times on each PR. If any test fails in any of the 5 runs, the PR is blocked. This catches new async issues before they merge.
The whole effort took about 3 days. Not 3 days of investigation — 3 days total, including the fixes. The investigation was the hard part. Once we understood the pattern, the fixes were mechanical.
Intermittent Failure Rate
Before
23%
After
Under 1%
The Rule I Now Teach Every Team
When I onboard a team to Playwright, I give them one rule: if a function returns a Promise, you await it. No exceptions. Even if you “know” the operation will complete before the next line needs it. Even if the test passes locally without the await. The moment you skip an await because “it works fine,” you’ve planted a flaky test that will haunt your CI at 2 AM.
If you’re building your locator strategy from scratch, start with text-based locators that match how users think. And if you’re migrating from Selenium and wondering whether your custom wrapper classes still make sense in Playwright, the short answer is: most of them don’t.
Your Next Move
Open your test suite and run this search: page.getBy or page.locator followed by .click(), .fill(), or .check() — without await on the same line. If you find even one, you’ve found a future flaky test. Fix it now, before it wakes someone up at 2 AM.
If your team is migrating from Selenium and the flake rate is climbing, it’s almost certainly an async issue. The tests aren’t flaky. Your async handling is.
Related Posts
I Migrated 3 Teams Off Cypress — Here's When It's Still the Right Choice
An honest take on Cypress vs Playwright migrations from an SDET who's done three — including when migrating is the wrong call.
XPath text() vs Dot — Why Your Text Match Fails
The real difference between XPath text(), dot, contains(), and normalize-space() for test automation — with examples that explain the flaky failures.
Your Selectors Keep Breaking — Start Using Text
Why text-based locators reduce test maintenance by up to 60% and how to implement them in Selenium and Playwright with real enterprise examples.
Get weekly QA automation insights
No fluff, just battle-tested strategies from 10+ years in the trenches.
No spam. Unsubscribe anytime.