Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

Hardcoded Waits Are Killing Your Tests: A Field Guide to Web-First Assertions

April 19, 2026 EST. READ: 12 MIN #Quality Assurance

Open any Playwright codebase that's older than six months. Search for waitForTimeout. You'll find dozens of them. Each one started life as waitForTimeout(500) when the test was flaky. Then someone bumped it to 1000. Then 2000. Then 5000. The test still flakes occasionally, but now your CI takes 40 minutes instead of 12.

This is the most common anti-pattern in Playwright suites, and it's the single biggest reason teams give up on automation. The fix is understanding the difference between manual assertions and web-first assertions — a distinction the Playwright docs explain badly. I'll explain it the way I'd explain it to a junior on my own team.

Table of Contents

Manual vs Web-First Assertions: The Actual Difference

A manual assertion evaluates once and immediately:

// Manual - evaluates immediately
const text = await page.locator('h1').textContent();
expect(text).toBe('Welcome');
// If h1 hasn't rendered yet, textContent() returns null, this fails.

A web-first assertion retries until the condition is true or a timeout expires:

// Web-first - retries until h1 has the right text
await expect(page.locator('h1')).toHaveText('Welcome');
// Polls every ~50ms for up to 5s by default.

The web-first version eliminates the race condition. You don't need waitForTimeout before it. You don't need to waitForSelector first. The assertion itself does the waiting. That's the entire pattern.

The visual difference in practice

// BEFORE: 8 lines, 1 hardcoded wait, still flaky
await page.click('button');
await page.waitForTimeout(2000);
await page.waitForSelector('.success-toast');
const text = await page.locator('.success-toast').textContent();
if (text === null) throw new Error('No toast');
expect(text).toContain('Saved');

// AFTER: 2 lines, no hardcoded wait, deterministic
await page.getByRole('button').click();
await expect(page.getByRole('alert')).toContainText('Saved');

This is the migration. Multiply by every test in your suite.

The Complete List of Web-First Assertions

If your assertion isn't on this list, it's not web-first. Memorize the list and you'll never write a manual assertion again.

AssertionUse for
toBeVisible()Element is in DOM and visible
toBeHidden()Element is detached or invisible
toBeAttached()Element is in DOM (visibility doesn't matter)
toBeEnabled() / toBeDisabled()Form control state
toBeChecked()Checkbox/radio state
toBeEditable()Input is not readonly/disabled
toBeEmpty()Element has no children/text
toBeFocused()Element has keyboard focus
toBeInViewport()Element is scrolled into view
toHaveText()Exact text match
toContainText()Substring match
toHaveValue()Input value
toHaveAttribute()HTML attribute presence/value
toHaveClass()CSS class presence
toHaveCount()Number of matching elements
toHaveCSS()Computed CSS property value
toHaveJSProperty()DOM property (not attribute)
toHaveURL()Page URL
toHaveTitle()Page title
toHaveScreenshot()Visual regression

Anything wrapped in await expect(locator).<assertion> retries. Anything wrapped in expect(value) (no locator) does not.

Migrating an Existing Suite, One Anti-Pattern at a Time

The migration is mechanical. Five common patterns and their fixes:

Anti-pattern 1: Wait then assert

// BEFORE
await page.waitForSelector('.user-name');
const name = await page.locator('.user-name').textContent();
expect(name).toBe('Alice');

// AFTER
await expect(page.getByTestId('user-name')).toHaveText('Alice');

Anti-pattern 2: Wait then count

// BEFORE
await page.waitForTimeout(1000);
const rows = await page.locator('table tr').count();
expect(rows).toBe(5);

// AFTER
await expect(page.locator('table tr')).toHaveCount(5);

Anti-pattern 3: Wait for URL change

// BEFORE
await page.click('a:has-text("Settings")');
await page.waitForTimeout(500);
const url = page.url();
expect(url).toContain('/settings');

// AFTER
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL(/\/settings/);

Anti-pattern 4: Wait for invisibility

// BEFORE
await page.click('button:has-text("Save")');
await page.waitForTimeout(2000); // Hope spinner is gone

// AFTER
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByTestId('spinner')).toBeHidden();

Anti-pattern 5: Wait for API to complete

// BEFORE
await page.click('button:has-text("Submit")');
await page.waitForTimeout(3000); // API hopefully done

// AFTER
await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/submit') && r.status() === 200),
  page.getByRole('button', { name: 'Submit' }).click(),
]);

I've migrated suites with 200+ tests using a regex search-and-replace plus an afternoon of manual review. git grep -n waitForTimeout gives you the full list.

Cases Where Waiting Is Legitimate (Very Few)

About 5% of waits are legitimate. Use waitForTimeout when:

  • You're testing the absence of something for a duration. "After 3 seconds of inactivity, the auto-save should fire." You can't assert this without waiting.
  • You're throttling a test of rate-limited UI. If your form rate-limits to one submit per 2 seconds, you wait 2 seconds between submits.
  • You're testing animation timing specifically. If you're testing that the modal animates in over exactly 300ms, you might need precise waits.

Even for these cases, prefer waitForFunction over waitForTimeout if there's any state change you can poll for:

// Better than waitForTimeout(3000) - waits for the actual auto-save flag
await page.waitForFunction(
  () => (window as any).__autoSaveCompleted === true,
  { timeout: 5000 }
);

Tuning Timeouts the Right Way

Web-first assertions have configurable timeouts. The default is 5 seconds for assertions, 30 seconds for actions. If your real-world flows are slower, tune at the right level:

// Per-assertion timeout (preferred):
await expect(page.getByText('Loading complete')).toBeVisible({ timeout: 15000 });

// Per-test timeout:
test('long-running export', async ({ page }) => {
  test.setTimeout(120_000); // 2 minutes
  // ...
});

// Global default for all assertions:
// playwright.config.ts
expect: {
  timeout: 10_000, // 10 seconds default for all expects
}

Don't bump the global timeout to 60 seconds because one test is slow. Bump that one assertion. Otherwise every flaky test takes 60 seconds to fail in CI.

Debugging Assertions That Won't Pass

An assertion times out. The element seems to exist. What's happening?

Three diagnostic steps:

Step 1: Run with the trace viewer

npx playwright test --trace=on
npx playwright show-trace test-results/.../trace.zip

Step through the timeline. The assertion failure will show the exact DOM state at the moment of failure. 90% of the time you'll see the element was there but had wrong text/attribute, and you can fix the assertion.

Step 2: Pause in headed mode

npx playwright test --headed --debug

Inspector opens. Step through. When the assertion runs, you can see the actual DOM and inspect why your locator isn't matching.

Step 3: Add a strict locator and assert what you see

const locator = page.getByRole('button', { name: 'Save' });
console.log('Count:', await locator.count());
console.log('First HTML:', await locator.first().innerHTML());
await expect(locator).toBeVisible();

If count: 0, your locator is wrong. If count: 5, it's matching too many elements (use strict: true or .first() / .nth()).

FAQs

What about page.waitForLoadState('networkidle')?

Different category. waitForLoadState waits for navigation events, not assertions. Use it for full page loads if you need it. Don't use it as a substitute for web-first assertions on individual elements.

Is locator.waitFor() the same as expect(locator).toBeVisible()?

Functionally similar but the latter is preferred. waitFor() is silent if the wait succeeds; the assertion gives you a clear test failure with a meaningful message.

What about page.waitForFunction?

Useful for waiting on JavaScript state that doesn't show up in the DOM (window globals, app-specific flags). For DOM-visible state, prefer the standard assertions.

Should I add { strict: true } to all my locators?

Locators are strict by default in Playwright Test (unlike page.locator in plain Playwright). If your locator could match multiple elements, the test fails with a clear "strict mode violation" message.

How do I assert that something doesn't appear?

await expect(locator).toBeHidden() or await expect(locator).toHaveCount(0). Both retry until the condition is true. Don't use waitForTimeout followed by a manual count check.

What if I need to wait for an animation to finish before screenshot?

Use animation: 'disabled' in toHaveScreenshot. Better: disable animations globally in your config — see my race-conditions post.

How do I test debounced inputs?

Wait for the actual side effect of the debounce (the API call, the rendered result), not the debounce timer itself. page.waitForResponse('**/api/search') after typing is more reliable than waitForTimeout(300).

Can I write my own web-first assertion?

Yes. Use expect.poll(fn, { timeout }) to retry any function until it returns truthy. Useful for custom conditions that don't fit existing assertions.

Why do my assertions fail with "locator resolved to multiple elements"?

Strict mode caught a non-unique locator. Either tighten the locator (getByRole with a name, or getByTestId) or use .first()/.nth(0) to explicitly pick one.

Should I migrate everything at once?

No — incremental is fine. Migrate one test file at a time, run the suite, ensure nothing breaks. Use a linter rule (eslint-plugin-playwright has one) to prevent new waitForTimeout calls while you're cleaning up old ones.

Wrap-Up

Web-first assertions are not a feature you opt into — they're the right way to use Playwright. If your suite has hardcoded waits, you have technical debt and the cost compounds with every flaky CI run. The migration is mechanical and high-leverage.

If you have a 200+ test suite full of waitForTimeout and you'd rather not spend three weeks migrating it yourself, that's exactly the kind of cleanup I do in framework engagements. Or book a free call and I'll audit your top three flaky tests on the call.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire