Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 24
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 5
CI/CD 6
  • GitHub Actions
  • Slack Reporting
QA Strategy 4
Case Studies 5
Blog/Automation
AutomationHalmurat T./October 18, 2022/6 min

The Async Trap Behind Flaky Playwright Tests

Filed underplaywright/framework-design/design-patterns
The Async Trap Behind Flaky Playwright Tests

Table of Contents
  • The Mindset Shift: Selenium Waits for You, Playwright Doesn’t (Exactly)
  • The 3 Async Mistakes I See on Every Migration
  • Mistake 1: Missing await on Actions
  • Mistake 2: Asserting Before the Page Is Ready
  • Mistake 3: Race Conditions in Setup and Teardown
  • How We Fixed Our 23% Flake Rate
  • The Rule I Now Teach Every Team
  • Your Next Move

On this page

  • The Mindset Shift: Selenium Waits for You, Playwright Doesn’t (Exactly)
  • The 3 Async Mistakes I See on Every Migration
  • Mistake 1: Missing await on Actions
  • Mistake 2: Asserting Before the Page Is Ready
  • Mistake 3: Race Conditions in Setup and Teardown
  • How We Fixed Our 23% Flake Rate
  • The Rule I Now Teach Every Team
  • Your Next Move

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.

tests/checkout.spec.ts
// This LOOKS correct but has a subtle timing issue
test('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

[ WARNING ]

This is the most common and the most dangerous because TypeScript won’t warn you. page.click() returns a Promise<void> — if you don’t await it, the operation runs in the background while your test charges ahead.

tests/login.spec.ts
// BAD — click fires but test doesn't wait for it
test('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
});
tests/login.spec.ts
// GOOD — every action is awaited
test('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.

tests/dashboard.spec.ts
// BAD — checking element count before page finishes loading
test('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
});
tests/dashboard.spec.ts
// GOOD — use web-first assertion that retries
test('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.

tests/fixtures.ts
// BAD — API setup might not complete before test runs
test.beforeEach(async ({ request }) => {
request.post('/api/test-data', { data: { user: 'testuser' } }); // no await!
});
tests/fixtures.ts
// GOOD — await ensures data exists before test starts
test.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%:

  1. Added no-floating-promises ESLint rule — found and fixed 31 missing awaits. This alone dropped flake rate to about 8%.
  2. Replaced all raw .count() and .textContent() calls with web-first assertions — swapped expect(await el.count()).toBe(n) with await expect(el).toHaveCount(n). Dropped to about 3%.
  3. Audited every beforeEach and afterEach hook — found 8 un-awaited API calls and 2 un-awaited navigation calls. Fixed these and dropped to under 1%.
  4. 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.

§ Delta · Intermittent Failure Rate 96% reduction

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.

§ Further Reading 03 of 03
01Automation

Migrating Off Cypress? Here's When to Keep It

An honest take on Cypress vs Playwright migrations from an SDET who's done three, including the signals that tell you not to migrate your suite just yet.

Read →
02Automation

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 real flaky failures.

Read →
03Automation

Stop Launching a Browser Per Test in Playwright

Launching a new browser per test in Playwright wastes 3-6 seconds each time. BrowserContext gives you the same isolation in milliseconds. Here's how to switch.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.